async/await会阻塞Node.js线程吗?

114
当在Node.js函数中使用async/await时,它会阻塞Node.js线程直到执行下一行代码吗?

Async/await 具有同步行为,因此它会阻塞当前的执行流程,直到完成为止。 - Lansana Camara
15
不会阻塞线程,即使正在等待,其他请求仍然可以继续到来并得到处理。 - Kevin B
我非常想要澄清@KevinB的评论和这个问题:await不是会'阻塞当前线程'直到等待的操作完成,但允许其他线程继续执行吗? - Pac0
2
@Pac0 在 Node.JS 中,一个进程只有一个线程,在执行 execfork 命令时会形成一个新的进程而不是线程。 - Nidhin David
如果你想一想,如果它是这样工作的,那么我们为什么需要async/await呢?我们可以只编写阻塞代码。 - Felix Kling
6个回答

234

async/await 不会阻塞整个解释器。Node.js 仍然将所有 JavaScript 作为单线程运行,即使一些代码正在等待 async/await,其他事件仍然可以运行它们的事件处理程序(因此 Node.js 不会被阻塞)。事件队列仍在为其他事件提供服务。事实上,将解决 Promise 的事件将允许 await 停止等待并运行下面的代码。

像这样的代码:

await foo();            // foo is an async function that returns a promise
console.log("hello");

与此类似:

foo().then(() => {
    console.log("hello");
});

因此,await会将其后的代码放入一个不可见的.then()处理程序中,其他的操作与使用.then()处理程序时基本相同。

await允许您省略编写 .then()处理程序,并使代码看起来具有同步性(尽管它实际上并非同步)。最终,它是一种缩写,可让您用更少的代码编写异步代码。但需要记住,任何可能拒绝的Promise都必须在其周围某个位置有try/catch语句以捕获和处理该拒绝。

逻辑上,当node.js执行函数遇到 await关键字时,可以将其视为以下内容:

  1. 进行函数调用
  2. 解释器看到该函数被声明为async,这意味着它将始终返回一个Promise。
  3. 解释器开始执行该函数。
  4. 当它遇到 await关键字时,它暂停了进一步执行该函数,直到被等待的Promise解决。
  5. 然后该函数返回一个未解析的Promise。
  6. 此时,解释器继续执行函数调用后面的所有内容(通常是跟随其他代码行的fn().then())。此时,.then()处理程序尚未执行,因为Promise尚未解决。
  7. 在某些情况下,此JavaScript序列完成并将控制权返回给解释器。
  8. 现在解释器可以自由地从事件队列中服务其他事件。最初运行到await关键字的原始函数调用仍然被挂起,但现在可以处理其他事件了。
  9. 在将来的某个时间点,被等待的原始Promise得到解决。当该Promise需要在事件队列中进行处理时,先前暂停的函数将在 await后的行上继续执行。如果还有更多的await语句,则函数执行将再次被暂停,直到该Promise解决。
  10. 最终,函数遇到return语句或到达函数体的末尾。如果有一个return xxx语句,则对xxx求值后,其结果成为此async函数已经返回的Promise的解析值。该函数现在已经执行完毕,并且它之前返回的Promise已解析。
  11. 这将导致调用附加到此函数之前返回的Promise的任何.then()处理程序。
  12. 在这些.then()处理程序运行之后,此async函数的工作最终完成。

因此,虽然整个解释器不会被阻止(仍然可以服务其他JavaScript事件),但包含 await语句的特定async函数的执行已被暂停,直到被等待的Promise解决。重要的是要理解上述第5步。当遇到第一个await时,函数立即返回一个未解析的Promise,并且在解析Promise之前会执行该函数之后的代码。正是出于这个原因,整个解释器没有被阻止。执行继续进行,只有一个函数的内部被挂起,直到Promise得到解决。


6
我发现https://v8.dev/blog/fast-async是一个很好的参考,可以详细解释这个答案中实际过程的细节。 - Sasinda Rukshan

30
async/await提供了一种替代传统使用then在promise上调用的方法。无论是Promises还是asyncawait都不会创建新线程。

当执行await时,其后的表达式将被同步评估。该表达式应该评估为一个promise,但如果不是,则会包装为一个promise,就像你使用await Promise.resolve(expression)一样。

一旦该表达式被评估,async函数将返回--它返回一个promise。然后代码继续执行紧随该函数调用之后的任何代码(同一线程),直到调用堆栈为空。

在某个时间点,为了await而评估的promise将会解析。这将在微任务队列中放置一个微任务。当JavaScript引擎在当前任务没有更多事情可做时,它将消耗微任务队列中的下一个事件(先进先出)。由于此微任务涉及已解决的promise,它将恢复async函数的先前执行状态,并继续处理await之后的任何内容。

函数可能会执行其他await语句,并具有类似的行为,尽管函数现在不再返回到最初调用它的位置(因为该调用已经通过第一个await处理),它仅仅返回,使调用堆栈为空,并留下JavaScript引擎处理微任务和任务队列。

所有这些都在同一线程中发生。


这个怎么样:https://dev59.com/mGYr5IYBdhLWcg3wQYFa#13823336 不,使用阻塞API调用在节点服务器中是不可以的,async/await和同步版本有什么区别? - Toolkit
2
真正等待某件事情发生并且在此之前不允许代码执行到其他地方的代码是同步且阻塞的。我已经解释了async/await的行为,它不是阻塞的,因为代码会继续执行。 - trincot
5
FYI,“await”拥有一些超越语法糖的能力。例如,如果“await”在一个“while()”循环或“for”循环内部,它会以一种无法通过完全重写不使用该类型循环的代码来实现的方式暂停该循环。因此,虽然许多常见用法基本上是语法糖,但它具有超越这些简单功能的能力。 - jfriend00

11
只要 async/await 内部的代码是非阻塞的,例如数据库调用、网络调用、文件系统调用等操作,它不会阻塞程序运行。但是如果 async/await 内部的代码是阻塞的,例如无限循环、像图像处理这样需要大量 CPU 计算的任务,那么它将会阻塞整个 Node.js 进程。本质上,async/await 是 Promise 的语言级封装,使得代码具有同步的“外观和感觉”。

@Toolkit 你可以在运行时确定这一点 - 尝试与应用程序进行交互。节点被阻止或只是简单的内联测试 - 简单的示例是执行async函数,其中包含无限循环,在其中尝试使用超时记录某些内容 setTimeout( ()=>{ console.log(..) } ) - 你永远不会看到这个日志。 - fider
1
@Nidhin David 数据库调用是阻塞的,不是吗?比如一个查询需要40毫秒才能执行,那么事件循环将会被阻塞40毫秒,对吗? - Sana.91
1
这是我长时间以来在这个主题上看到的最佳答案。关于async/await有很多误解,因为大多数人认为async意味着非阻塞,但只有在你指定的情况下,如果代码在另一个线程中执行或使用系统调用,则为真。大多数自己编写的代码 - 比如做某事的for/loop,即使在async/await函数中也会被阻塞。 - Gary
我有一个明确的例子,展示了在所谓的非阻塞异步函数执行之前,一个node-schedule处理程序被调用。这个答案肯定是错误的。 - Koushik Shom Choudhury
@KoushikShomChoudhury,没有人会阻止您通过一个可以接受的答案来证明您的观点。谢谢! - Nidhin David
显示剩余2条评论

8
async/await会阻塞Node.js的线程吗?正如@Nidhin David所说,这取决于async函数内部的代码-数据库调用、网络调用、文件系统调用不会造成阻塞,但长时间的for /while循环、JSON stringify/parse以及恶意/易受攻击的正则表达式(可以搜索ReDoS攻击)会造成阻塞。下面的四个例子中,如果调用/test请求,因为代码string.match(/^(a|a)+$/)是同步的并且需要很长时间来处理,主线程将被阻塞。
这个例子会像预期的那样阻塞主节点线程,没有其他请求或客户端可以被服务。
var http = require('http');

// This regexp takes to long (if your PC runs it fast, try to add some more "a" to the start of string).
// With each "a" added time to complete is always doubled.
// On my PC 27 times of "a" takes 2,5 seconds (when I enter 28 times "a" it takes 5 seconds).
// https://en.wikipedia.org/wiki/ReDoS
function evilRegExp() {
    var string = 'aaaaaaaaaaaaaaaaaaaaaaaaaaab';
    string.match(/^(a|a)+$/);
}

// Request to http://localhost:8080/ wil be served quickly - without evilRegExp() but request to
// http://localhost:8080/test/ will be slow and will also block any other fast request to http://localhost:8080/
http.createServer(function (req, res) {
    console.log("request", req.url);

    if (req.url.indexOf('test') != -1) {
      console.log('runing evilRegExp()');
      evilRegExp();
    }

    res.write('Done');
    res.end();
}).listen(8080);

您可以同时运行多个请求到http://localhost:8080/,速度很快。然后只运行一个慢请求http://localhost:8080/test/,此时没有其他请求(即使是快速的http://localhost:8080/)将被服务,直到慢请求结束(阻塞)。


这个第二个例子使用了promises,但它仍然会阻塞主node线程,无法为其他请求/客户端提供服务。

var http = require('http');

function evilRegExp() {
    return new Promise(resolve => {
        var string = 'aaaaaaaaaaaaaaaaaaaaaaaaaaab';
        string.match(/^(a|a)+$/);
        resolve();
    });
}

http.createServer(function (req, res) {
      console.log("request", req.url);

    if (req.url.indexOf('test') != -1) {
      console.log('runing evilRegExp()');
      evilRegExp();
    }

    res.write('Done');
    res.end();

}).listen(8080);

这个第三个例子使用了async+await,但它也是阻塞的(async+await与原生Promise相同)。
var http = require('http');

async function evilRegExp() {
    var string = 'aaaaaaaaaaaaaaaaaaaaaaaaaaab';
    string.match(/^(a|a)+$/);
    resolve();
}

http.createServer(function (req, res) {
      console.log("request", req.url);

    if (req.url.indexOf('test') != -1) {
      console.log('runing evilRegExp()');
      await evilRegExp();
    }

    res.write('Done');
    res.end();

}).listen(8080);

第四个示例使用setTimeout(),这会导致看似很慢的请求立即被响应(浏览器迅速显示“完成”),但它也会阻塞其他快速请求,直到evilRegExp()结束。

var http = require('http');

function evilRegExp() {
    var string = 'aaaaaaaaaaaaaaaaaaaaaaaaaaab';
    string.match(/^(a|a)+$/);
}

http.createServer(function (req, res) {
      console.log("request", req.url);

    if (req.url.indexOf('test') != -1) {
      console.log('runing evilRegExp()');
      setTimeout(function() { evilRegExp(); }, 0);
    }

    res.write('Done');
    res.end();

}).listen(8080);

那个正则表达式字符串.match(/^(a|a)+$/)有什么问题吗? - mario ruiz
1
@MarioRuiz 这是一个邪恶的正则表达式,根据输入长度需要很长时间来评估。执行时间会随着输入长度呈指数级增长。尝试使用它,你会发现当输入很长时,它永远不会结束。我在我的 JavaScript 评论中也解释了这一点。总的来说,请参阅 https://en.wikipedia.org/wiki/ReDoS。 - mikep
不明白为什么会这样?看起来非常简单... /^(a|a)+$/ - mario ruiz
@PrestonDocks 是的,如果调用 /test 请求,则这4个示例中的每一个都将阻塞主线程,因为代码 string.match(/^(a|a)+$/) 是同步的,并且需要很长时间来处理。我已经更新了我的答案以澄清这一点。 - mikep
@mikep 数据库调用是阻塞的,不是吗?比如一个查询需要40毫秒才能执行,那么事件循环将会被阻塞40毫秒,对吗? - Sana.91
显示剩余2条评论

4

异步函数使我们能够像编写同步代码一样编写基于 Promise 的代码,但不会阻塞执行线程。它通过事件循环异步操作。异步函数将始终返回一个值。仅使用 async 意味着将返回一个 Promise,如果没有返回 Promise,则 JavaScript 会自动将其包装在已解决的 Promise 中并带有其值。

在 Medium 上找到文章。 如何在 JavaScript 中使用 Async Await。


3

我刚刚有了一个“恍然大悟”的时刻,想要把它分享出来。"await" 不会直接返回控制权给 JavaScript - 它将控制权返回给调用者。让我举个例子。这里有一个使用回调函数的程序:

console.log("begin");
step1(() => console.log("step 1 handled"));
step2(() => console.log("step 2 handled"));
console.log("all steps started");

// ----------------------------------------------

function step1(func) {

console.log("starting step 1");
setTimeout(func, 10000);
} // step1()

// ----------------------------------------------

function step2(func) {

console.log("starting step 2");
setTimeout(func, 5000);
} // step2()

我们需要的行为是: 1)两个步骤立即开始执行, 2)当一个步骤准备好处理时(想象一下 Ajax 请求,但这里我们只需等待一段时间),每个步骤的处理都会立即发生。
这里的“处理”代码是 console.log("step X handled")。该代码(在实际应用程序中可能非常长,并且可能包含嵌套的 await),位于回调函数中,但我们更希望它成为一个函数中的顶级代码。
以下是使用 async/await 的等效代码。请注意,我们必须创建一个 sleep() 函数,因为我们需要等待返回 Promise 的函数:
let sleep = ms => new Promise((r, j)=>setTimeout(r, ms));

console.log("begin");
step1();
step2();
console.log("all steps started");

// ----------------------------------------------

async function step1() {

console.log("starting step 1");
await sleep(10000);
console.log("step 1 handled");
} // step1()

// ----------------------------------------------

async function step2() {

console.log("starting step 2");
await sleep(5000);
console.log("step 2 handled");
} // step2()

对我来说,重要的一点是step1()中的await将控制权返回给主体代码,以便可以调用step2()开始该步骤,而step2()中的await也将返回到主体代码,以便可以打印“所有步骤已启动”。一些人建议您使用“await Promise.all()”启动多个步骤,然后在此之后使用结果(将显示在数组中)处理所有步骤。但是,这样做会导致所有步骤都解决之前都不会处理任何步骤。这并不理想,也似乎完全没有必要。

我认为你的观点是,如果其中一些步骤需要更多的处理,那么等待所有初始步骤并不理想。例如:你有承诺A、B和D;等待它们全部完成;使用A和B生成承诺C;等待C;使用C和D返回承诺E。这个例子中的问题是,D可能需要比A&B更长的时间来处理,从而延迟了C。尽管如此,Promise.all()非常有用,这个例子只是展示了一种天真的使用方法。请查看promise_variations.js以获取各种使用promise的方式。 - Chinoto Vokro

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接