浅谈 NodeJS 的事件循环和 Timers

什么是事件循环

事件循环,即 Event Loop,其实就是 JS 管理事件执行的一个流程,具体的管理方法由 JS 运行的环境决定,目前 JS 的主要运行环境有浏览器和 Node。javascript

浏览器和 Node 的事件循环,都是先初始化一个循环,执行同步代码,遇到异步操做时,会将其交给对应的线程处理,主线程则继续往下执行,异步操做执行完毕后,对应的 callback 回调会被推入事件队列,并在合适的时机执行。每执行一次循环体的过程,咱们称之为一个 Tick。html

与浏览器不一样的是,Node 的循环分为几个阶段,每一个阶段分别处理不一样的事件,而浏览器的循环不存在这样的阶段划分。下面咱们介绍一下 Node 事件循环的几个阶段。java

Node 事件循环的流程

事件循环的六个阶段

Node 的事件循环分为几个阶段,以下图(除了 incoming,每个方框表明一个阶段):node

Node 事件循环.png

咱们先简单看看每一个阶段的做用,也就是在一个 Tick 中,Node 是按照怎样的顺序工做的:npm

  • timers:执行 setTimeoutsetInterval 的回调;
  • pending callbacks:上个 Tick 中延迟到这个 Tick 的回调,就会在这个阶段执行;
  • idle,prepare:Node 内部使用;
  • poll:执行大多数异步操做的回调;
  • check:执行 setImmediate 的回调;
  • close callbacks:执行 close 相关的回调,如 socket.on('close', ...)

每一个阶段都对应一个 FIFO 的队列,循环进入某个阶段后,只有在两种状况下会跳出该阶段进入下一个阶段,一是将其队列中的回调所有执行完,二是执行数达到了该阶段的上限。api

poll 阶段

接下来咱们重点看一下 poll 阶段,poll 阶段主要作两件事情:浏览器

  • 计算轮询时间 maxPollTime
  • 执行 poll 队列中的回调

如下为 poll 阶段的流程:markdown

  1. 事件循环进入 poll 阶段后,会先检查 poll 队列是否为空;
  2. 若 poll 队列不为空,则遍历并同步执行队列里的回调(直到所有执行完或达到执行数的上限),若 poll 队列为空,则检查是否有待执行的 setImmediate 回调;
  3. 若是有,则进入 check 阶段,若是没有,则原地等待新的回调被推入 poll 队列,并当即执行这些新推入的回调(等待时间的上限为前面计算出来的 maxPollTime)。

注意:poll 阶段空闲,即 poll 队列为空的时候,一旦有新的 setImmediate 回调,循环就会结束 poll 进入 check 阶段;或者一旦有新的 timer 计时结束,循环就会绕回 Timers 阶段;若是同时出现二者的回调,setImmediate 的优先级更高并发

setImmediate 和 setTimeout(fn, 0) 的执行顺序

首先明确 setImmediatesetTimeout 各自的执行时机:setImmediate 是一个特殊的定时器,它的回调会在 check 阶段执行,也就是紧跟在 poll 阶段后面执行;setTimeout 的回调会在达到 delay 时间后,尽快执行,前提是计时结束并把回调推入了 Timers 的队列。 另外还需明确一点,事件循环除了可以正常进入 Timers 阶段外,poll 阶段一旦空闲而且没有待执行的 setImmediate 回调,就会去检查是否有计时结束的 timer,若是有的话,就会绕回到 Timers 阶段,因此 setTimeout 回调的执行时机实际上有两个。less

好了,咱们如今来看 setImmediatesetTimeout(fn, 0) 回调的执行顺序,能够分为如下两种状况:

  1. 二者都在主模块(main module)调用,则两个回调的执行顺序不肯定;
  2. 二者都不在主模块调用,而是在一个异步操做的回调里被调用,那么 setImmediate 的回调会先于 setTimeout(fn, 0) 执行

如下示例中,咱们都假定 setImmediate 的回调为 cb_immediatesetTimeout(fn, 0) 的回调为 cb_timeout

二者都在 I/O 操做的回调内,cb_immediate 先执行

代码以下:

// test_timer.js
const fs = require('fs');
fs.readFile(__filename, () => {
    setTimeout(() => {
        console.log('timeout');
    }, 0);
    setImmediate(() => {
        console.log('immediate');
    });
});
复制代码

咱们屡次运行会发现,“immediate”始终会在“timeout”以前打印:

$ node test_timer.js
immediate
timeout
复制代码

下面咱们分析一下 cb_immediate 始终先执行的缘由。 首先,I/O 操做的回调在 poll 队列里,是在 poll 阶段执行的。前面咱们提到,poll 阶段一旦空闲,就会检查是否有待执行的 setImmediate 回调,若是有,就会结束等待进入 check 阶段,因此即便这时有新的 timer 计时结束,也要等到 check、close 都结束并进入新的 Tick 才能执行。poll 检查发现 cb_immediate 待执行,因此循环直接进入 check 阶段执行 cb_immediate,而 cb_timeout 最快也要在下一个 Tick 才会被执行。

二者都在 setTimeout 的回调内,cb_immediate 先执行

代码以下:

setTimeout(() => {
  setTimeout(() => {
    console.log('timeout');
  }, 0);
  setImmediate(() => {1
    console.log('immediate');
  });
}, 0);
复制代码

屡次运行后咱们会发现,“immediate”始终比“timeout”先打印。 外层 setTimeout 的回调是在 Timers 阶段执行的,执行完回调后,事件循环继续日后面走,走到 poll 阶段后,一旦 poll 空闲,就会检查并发现有待执行的 setImmediate 回调,发现存在 cb_immediate,因而循环直接进入 check 阶段,率先执行 cb_immediate

综合上面两个示例,咱们能够看出,在同一个异步操做的回调中同时调用 setImmediatesetTimeout(fn, 0)setImmediate 之因此始终会先执行,正是因为计时器一旦错过了 Timers 阶段,下一个执行回调的时机在 poll 阶段,而 poll 阶段检查过程当中,setImmediate 的优先级高于 setTimeout,一旦发现有待执行的 setImmediate 回调,循环就会继续往下走,因此 setImmediate 回调的执行永远先于 setTimeout 回调。

咱们接着看一下在主模块同时调用 setImmediatesetTimeout(fn, 0) 的状况。

二者的调用都在主模块,执行顺序不肯定

代码以下:

setTimeout(() => {
    console.log('timeout');
}, 0);
setImmediate(() => {1
    console.log('immediate');
});
复制代码

运行以上代码,咱们会发现,时而先打印出“timeout”,时而先打印出“immediate”,即二者的执行顺序是不肯定的。

咱们先理一下主要流程:

  1. 主线程执行同步代码;
  2. setTimeout 交给定时器线程处理;
  3. 执行 setImmediate,即调用 libuv 提供的一个 API,该 API 会将 cb_immediate 放入 check 阶段的队列;
  4. 同步代码执行完毕后,事件循环进入 Timers 阶段;
  5. 检查 Timers 队列,若不为空,执行回调(即 cb_timeout),若为空,继续往下执行;
  6. 进入 pending callbacks 阶段;
  7. 进入 idle,prepare 阶段;
  8. 进入 poll 阶段;
  9. poll 空闲后,发现有待执行的 setImmediate 回调(即 cb_immediate),进入 check 阶段;
  10. 执行 cb_Immediate

根据以上流程能够看出,cb_immediatecb_timeout 的执行顺序,就取决于第 e 步中 Timers 的队列是否已经存在 cb_timeout,若是存在,则 cb_timeout 先执行,不然 cb_immediate 先执行。

这里补充一点,在 Node 中,setTimeout(fn, 0) 会被强制改成 setTimeout(fn, 1),这一点在官方文档中有相关说明:

When delay is larger than 2147483647 or less than 1, the delay will be set to 1.

若是循环进入 Timers 阶段的时候,距离 setTimeout 执行已通过去了 1ms,而且 cb_timeout 已经被推入 Timers 的队列,那么循环就会取出 cb_timeout 并执行;反之,若是循环进入 Timers 阶段的时候,cb_timeout 尚未在队列内,那么 cb_timeout 就不会在这个 Tick 被执行,cb_immediate 会先执行。

而循环进入 Timers 阶段的时候,是否已经通过了 1ms,会受到多方面的影响,包括同步代码执行所花费的时间,以及系统的性能,机器的状态差别也会致使每次运行的结果不一样。因此同时在主模块调用 setImmediatesetTimeout(fn, 0),两个回调的执行顺序是不肯定的。

process.nextTick()

process.nextTick() 回调的执行时机

你们可能会发现,咱们在介绍事件循环的阶段时,process.nextTick() 没有出如今任何一个阶段。这是由于 process.nextTick() 不属于任何一个阶段,事实上,它是在 “阶段之间” 执行的。 在任何一个阶段调用了 process.nextTick(),nextTick 的回调都会在当前阶段结束后当即执行,全部回调执行完后,事件循环才进入下一个阶段,因此过多的或长时间执行的 nextTick 回调,实际上是能够阻塞整个事件循环的,因此得当心使用 nextTick。

process.nextTick() VS setImmediate()

咱们回顾一下二者的回调的执行时机,process.nextTick() 的回调是在当前阶段结束后就当即执行,setImmediate() 的回调是在每一个循环的 check 阶段执行。细心的读者就会发现,process.nextTick() 彷佛比 setImmediate() 更“immediate”,更即时。 事实上官方也提到了这一点,二者的名字应该倒过来才比较合理,但修更名字的话,影响范围太大了,npm 上全部用到这两个方法的包都会受到影响,因此即便二者的名字存在必定的迷惑性,可是目前看来,更名是不现实也是不可能的。

Node EventLoop VS 浏览器的 EventLoop

二者有如下两点区别:

  • Node 的事件循环是分阶段的,每一个阶段执行特定的事件回调,而浏览器不分阶段;
  • Node 的微任务会在阶段之间执行,而浏览器的微任务是在每一个宏任务结束后执行

和浏览器同样,Node 也会维护一个微任务列表。和浏览器不一样的是,浏览器是在宏任务即将结束的时候,检查宏任务对应的微任务列表是否为空,若不为空,则执行全部微任务后,再进入下一个 Tick。而 Node 的微任务执行时机是在各个阶段之间,一个阶段结束后,事件循环会去检查微任务列表是否为空,若不为空,则执行完全部微任务后,才进入循环的下一个阶段。 注意,setImmediate 是 Node 特有的宏任务,process.nextTick 是 Node 特有的微任务。

相关文章
相关标签/搜索