理解Node.js事件循环阶段及其执行JavaScript代码的方式


  1. 原文→
  2. 其余:
    1. 微任务(Microtasks):
    2. 宏任务(Macrotasks):
  3. 事件循环Tick:

原文→

我相信,如果您正在阅读本文,那么您一定已经听说了Node.js拥有著名的事件循环,它如何处理Node.js中的并发机制,以及它如何使Node.js成为事件驱动 I/O 的独特平台。 作为事件驱动的 I/O,执行的所有代码都是回调形式。 因此,重要的是要知道事件循环如何并且以何种顺序执行所有回调。 从这里开始,在此博客文章中,术语“事件循环”是指Node.js的事件循环。

从本质上讲,事件循环是一种在某些阶段进行迭代的机制。 您肯定听说过一个称为“事件循环迭代”的术语,该术语表示事件循环迭代贯穿其所有阶段。

在本文中,我将向您展示事件循环的底层架构及其所有阶段是什么,在哪个阶段中执行哪些代码,以及一些细节和最后一些示例,我认为它们将使您更好地理解关于事件循环的概念。

以下是事件循环按照其顺序迭代的所有阶段的图表:

因此,事件循环是Node.js中的一种机制,它在一系列循环中进行迭代。 以下是事件循环迭代的各个阶段:

每个阶段都有一个 队列/堆,事件循环使用该 队列/堆 来 推送/存储 要执行的回调(Node.js中存在一个误解,即只有一个全局队列,在该队列中,回调被排队执行,这是不正确的)。

  1. Timers(计时器):
    JavaScript中的Timers回调(setTimeout,setInterval)保留在堆内存中,直到过期为止。 如果堆中有任何过期的Timers,则事件循环将使用与它们关联的回调,并以其延迟的升序开始执行它们,直到Timers队列为空。 但是,Timers回调的执行由事件循环的 Poll(轮询) 阶段控制(我们将在本文后面看到)。
  2. Pending callbacks(待处理回调):
    在此阶段,事件循环执行与系统相关的回调(如果有)。 例如,假设您正在编写一个 Node 服务,而其他进程正在使用您要运行该进程的端口,则 Node 将抛出 ECONNREFUSED 错误,某些* nix系统可能由于操作系统正在处理其他一些任务而让回调等待执行。 因此,此类回调将被推送到待处理回调队列中以执行。
  3. Idle/Prepare(空闲/准备):
    在此阶段,事件循环不执行任何操作。 它空闲,准备进入下一阶段。
  4. Poll(轮询):
    这一阶段使Node.js变得独一无二。 在此阶段,事件循环会关注新的异步 I/O 回调。 除setTimeout,setInterval,setImmediate和close回调外,几乎所有回调都将执行。
    基本上,事件循环在此阶段执行两件事:
    • 如果轮询阶段队列中已经有排队的回调,将执行它们,直到所有回调从轮询阶段回调队列中耗尽为止。
    • 如果队列中没有回调,则事件循环将在轮询阶段停留一段时间。 现在,这个“一段时间”还取决于以下几点:
      • 如果setImmediate队列中存在要执行的回调,则事件循环在轮询阶段不会停留更长时间,而将进入下一个阶段,即Check / setImmediate。 再次,它将开始执行回调,直到Check / setImmediate阶段回调队列为空。
      • 事件循环将从轮询阶段移出的第二种情况是,它知道有过期的Timers,这些Timers的回调正在等待执行。 在这种情况下,事件循环将移至下一阶段,即Check / setImmediate,然后移至Closing回调阶段,并最终从Timers阶段开始其下一次迭代。
  5. Check / setImmediate:
    在此阶段,事件循环从Check阶段的队列中获取回调,并开始一个接一个地执行直到队列为空。 当轮询阶段没有剩余要执行的回调并且轮询阶段变为空闲时,事件循环将进入此阶段。 通常,setImmediate的回调在此阶段执行。
  6. Closing callbacks(关闭回调):
    在此阶段,事件循环执行与关闭事件关联的回调,例如 socket.on(‘close’,fn) 或 process.exit()。

除了所有这些,还有一个微任务(microtask)队列,其中包含与process.nextTick相关的回调,我们将在稍后看到。

例子:
让我们从一个简单的示例开始,以了解如何执行以下代码:

1
2
3
4
5
6
function main() {
setTimeout(() => console.log('1'), 0);
setImmediate(() => console.log('2'));
}

main();

让我们回顾一下事件循环图,并结合其阶段说明,并尝试找出上述代码的输出:

当使用Node作为解释器执行时,以上代码的输出为:

1
2
1
2

事件循环进入Timers阶段并执行与上面的setTimeout相关的回调,然后进入随后的阶段(这些阶段并没有任何排队的回调),直到到达Check(setImmediate)阶段,在该阶段执行与它相关的回调函数。因此,输出期望的值。

注意:以上输出是可能被反转的,即:

1
2
2
1

因为事件循环不能精确的在0毫秒时间内执行setTimeout(fn,0)的回调。而是在4-20毫秒的延迟后,执行回调。 (还记得吗?前面提到轮询阶段控制Timers回调的执行,因为它在轮询阶段等待一些I/O)。

现在,事件循环运行任何JavaScript代码时,都会发生两件事:

  1. 当调用我们的JavaScript代码中的函数时,事件循环首先开始(而不是执行函数),然后将初始回调(即函数)注册到相应队列。
  2. 一旦它们注册,事件循环便进入其阶段,并开始迭代和执行回调,直到处理完所有回调为止。

再举一个例子,或者说在Node.js中有一个误解,就是setTimeout(fn,0)总是在setImmediate之前执行,这是不对的! 正如我们在上面的示例中看到的那样,事件循环最初处于Timers阶段,并且setTimeout定时器可能已过期,因此它先执行了,并且这种行为是不可预测的。 然而,并非总是如此,这完全取决于回调的数量,事件循环所处的阶段等等。

不管怎样,如果您执行以下操作:

1
2
3
4
5
6
7
8
function main() {
fs.readFile('./xyz.txt', () => {
setTimeout(() => console.log('1'), 0);
setImmediate(() => console.log('2'));
});
}

main();

上面的代码将始终输出:

1
2
2
1

让我们看看上面的代码是如何执行的:

  1. 当我们调用main()函数时,事件循环首先执行而不是去执行回调。 我们遇到fs.readFile函数和已经注册的回调,并且该回调被推送到I/O阶段队列。 由于所有回调已注册了给定的函数,所以事件循环现在可以自由地开始执行回调。 因此,它从Timers开始遍历其各个阶段。 它在Timers和Pending回调阶段找不到任何内容。
  2. 当事件循环不断遍历其各个阶段并且看到文件读取操作已完成时,它将开始执行回调。

请记住,事件循环开始执行fs.readFile的回调时,它处于I/O阶段,此后,它将移至Check(setImmediate)阶段。

  1. 因此,Check阶段在当前运行的Timers阶段之前。 从而,在I/O阶段,setImmediate的回调将始终在setTimeout(fn,0)之前运行。

(译者注:以上”请记住“后的内容比较难理解,我们可以把fs.readFile的回调执行那一刻,看做是在事件循环A的I/O阶段,执行完回调后,就先后注册了Timers(setTimeout)阶段 和 Check(setImmediate) 阶段的回调,然后事件循环A进入到Check阶段,这时因为刚刚注册过的setImmediate回调存在,所以先执行了setImmediate回调,然后再进入事件循环B的Timers阶段,去执行setTimeout。)

让我们再考虑一个示例:

1
2
3
4
5
6
7
8
function main() {
setTimeout(() => console.log('1'), 50);
process.nextTick(() => console.log('2'));
setImmediate(() => console.log('3'));
process.nextTick(() => console.log('4'));
}

main();

在我们了解事件循环如何执行此代码之前,需要了解一件事:

process.nextTick属于微任务(microtasks),该微任务的优先级高于所有其他阶段,因此与之关联的回调在事件循环完成当前操作后立即执行。 这意味着,无论我们传递给process.nextTick的回调如何,事件循环都将完成其当前操作,然后从微任务队列执行回调,直到执行完。 队列执行完后,它将返回到其离开事件循环之前所处位置的阶段。

  1. 它首先检查微任务队列并在其中执行回调(在以上代码中为process.nextTick的回调)。
  2. 然后,它进入其第一阶段(Timers 阶段),其中50ms的计时器尚未到期。 因此,它前进到其他阶段。
  3. 然后,它进入 Check(setImmediate)阶段,在该阶段中看到计时器到期,并执行记录“ 3”的回调。
  4. 在事件循环的下一个迭代中,它看到50ms的计时器到期,因此记录为“ 1”。

这是上面代码的输出:

1
2
3
4
2
4
3
1

再看一个例子,这次我们将异步回调传递给我们的某一个process.nextTick。

1
2
3
4
5
6
7
8
9
10
function main() {
setTimeout(() => console.log('1'), 50);
process.nextTick(() => console.log('2'));
setImmediate(() => console.log('3'));
process.nextTick(() => setTimeout(() => {
console.log('4');
}, 1000));
}

main();

上面的代码片段的输出是:

1
2
3
4
2
3
1
4

现在,执行上述代码时将发生以下情况:

  1. 所有回调均已注册并推送到其各自的队列中。
  2. 如前面的示例所示,由于微任务队列回调是首先执行的,因此将首先输出“ 2”。 同样,此时,第二个process.nextTick回调即setTimeout(将记录为“ 4”)已开始执行,并最终被推送到Timers阶段队列。
  3. 现在,事件循环进入其正常阶段并执行回调。 它进入的第一阶段是“Timers”。 可以看到50ms的计时器没有到期,因此可以进一步进入下一个阶段。
  4. 然后,它进入“Check(setImmediate)”阶段并执行setImmediate的回调,该回调最终输出“ 3”。
  5. 现在,事件循环的下一个迭代开始。 在事件循环中,事件循环返回到“计时器”阶段,它会同时遇到过期的计时器(即注册时分别为50ms和1000ms),并执行与之关联的回调,该回调首先输出“ 1”,然后再输出“ 4”。

因此,如您所见,事件循环的各种状态,其阶段以及最重要的是process.nextTick及其功能。 它基本上将提供给它的回调放在微任务队列中,并优先执行。

最后一个例子和一个详细的例子,您还记得这篇博文开头的事件循环图吗? 好吧,看看下面的代码。 我希望您弄清楚以下代码的输出是什么。 在代码之后,我对事件循环如何执行以下代码进行了直观介绍。 它将帮助您更好地理解:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
 1   const fs = require('fs');
2
3 function main() {
4 setTimeout(() => console.log('1'), 0);
5 setImmediate(() => console.log('2'));
6
7 fs.readFile('./xyz.txt', (err, buff) => {
8 setTimeout(() => {
9 console.log('3');
10 }, 1000);
11
12 process.nextTick(() => {
13 console.log('process.nextTick');
14 });
15
16 setImmediate(() => console.log('4'));
17 });
18
19 setImmediate(() => console.log('5'));
20
21 setTimeout(() => {
22 process.on('exit', (code) => {
23 console.log(`close callback`);
24 });
25 }, 1100);
26 }
27
28 main();

以下gif指示事件循环如何执行上述代码:

注意:

  1. 以下gif中指示的队列中的数字是以上代码中的回调的行号。
  2. 由于我的重点是事件循环阶段如何执行代码,因此我没有在gif中插入Idle / Prepare阶段,因为它仅由事件循环在内部使用。

    以上代码将输出:
    1
    2
    3
    4
    5
    6
    7
    1
    2
    5
    process.nextTick
    4
    3
    close callback
    或者,也可以是(回忆第一个例子):
    1
    2
    3
    4
    5
    6
    7
    2
    5
    1
    process.nextTick
    4
    3
    close callback

其余:

微任务(Microtasks):

因此,Node.js中有一个东西或者说是在v8中准确的叫做“微任务(Microtasks)”。明确地说,微任务不是事件循环的一部分,而是v8的一部分。在本文前面,您可能已经阅读了有关process.nextTick的信息。 JavaScript中有一些任务属于微任务,分别是process.nextTick,Promise.resolve等。

这些任务的优先级高于其他任务/阶段,这意味着事件循环在结束其当前操作之后,将执行微任务队列的所有回调,直到执行完毕,然后恢复到其离开的阶段继续循环。

因此,每当Node.js遇到上面定义的任何微任务时,它都会将关联的回调推送到微任务队列并立即开始执行(对微任务进行优先级排序)并执行所有回调,直到队列完全执行完毕。

话虽这么说,如果您在微任务队列中放置了很多回调,您可能最终会饿死事件循环,因为它永远不会进入任何其他阶段。

宏任务(Macrotasks):

诸如setTimeout,setInterval,setImmediate,requestAnimationFrame,I / O,UI渲染或其他I / O回调之类的任务都属于宏任务(Macrotasks)。 他们没有事件循环优先级之类的东西。 回调是根据事件循环阶段执行的。

事件循环Tick:

我们说事件循环在其所有阶段迭代一次(事件循环的一次迭代)即是一次tick。
事件循环tick频率高和tick持续时间短(一次迭代所花费的时间)表示健康的事件循环。

page PV:  ・  site PV:  ・  site UV: