在nodejs中事件循环分析

在上一篇文章[在chromev8中的JavaScript事件循环分析]()中分析到,在chrome中的js引擎是通过执行栈和事件队列的形式来完成js的异步操作。然而在node中,事件循环表现出的状态与浏览器中大致相同。不同的是node中有一套自己的模型。node中事件循环的实现是依靠的​​libuv​​引擎。我们知道node选择​​chrome v8​​引擎作为js解释器,v8引擎将js代码分析后去调用对应的​​node api​​,而这些api最后则由​​libuv​​引擎驱动,执行对应的任务,并把不同的事件放在不同的队列中等待主线程执行。 因此实际上node中的事件循环存在于​​libuv​​引擎中。

事件循环

当 Node.js 启动时,它将初始化事件循环机制,处理提供的输入脚本,该脚本可能会进行异步 API 调用、计划计时器或调用,然后开始处理事件循环。

When Node.js starts, it initializes the event loop, processes the provided input script (or drops into the REPL, which is not covered in this document) which may make async API calls, schedule timers, or call process.nextTick(), then begins processing the event loop.

可以这么说任何花费太长时间的操作都需要将控制权返回给事件循环的​​JavaScript​​代码,毕竟这会阻塞页面中任何​​JavaScript​​代码的执行,甚至阻塞​​UI​​线程,并且用户无法单击浏览、滚动页面等。

​JavaScript​​中几乎所有的​​I/O​​基元都是非阻塞的,如网络请求、文件系统操作等。被阻塞是可能是个异常,这就是​​JavaScript​​如此之多基于回调(最近越来越多基于​​promise​​和​​async/await​​)的原因。

模型

下面是一个libuv引擎中的事件循环的模型:

   ┌───────────────────────┐
┌─>│ timers │
│ └──────────┬────────────┘
│ ┌──────────┴────────────┐
│ │ I/O callbacks │
│ └──────────┬────────────┘
│ ┌──────────┴────────────┐
│ │ idle, prepare │
│ └──────────┬────────────┘ ┌───────────────┐
│ ┌──────────┴────────────┐ │ incoming: │
│ │ poll │<──connections─── │
│ └──────────┬────────────┘ │ data, etc. │
│ ┌──────────┴────────────┐ └───────────────┘
│ │ check │
│ └──────────┬────────────┘
│ ┌──────────┴────────────┐
└──┤ close callbacks │
└───────────────────────┘

注:模型中的每一个方块代表事件循环的一个阶段
每个阶段都有一个要执行的回调的​​FIFO​​(First In First Out)队列。虽然每个阶段都有自己的特殊性,但通常,当事件循环进入给定阶段时,它将执行特定于该阶段的任何操作,然后在该阶段的队列中执行回调,直到队列用尽或执行最大回调数。当队列已用尽或达到回调限制时,事件循环将进入下一阶段,依此类推。

由于这些操作中的任何一个都可能计划更多操作,并且轮询阶段处理的新事件由内核排队,因此可以在处理轮询事件时对轮询事件进行排队。因此,长时间运行的回调可以允许轮询阶段的运行时间远远超过计时器的阈值。

各阶段分析

从上面这个模型中,我们可以大致分析出node中的事件循环的顺序:

外部输入数据–>轮询阶段(poll)–>检查阶段(check)–>关闭事件回调阶段(close callback)–>定时器检测阶段(timer)–>I/O事件回调阶段(I/O callbacks)–>闲置阶段(idle, prepare)–>轮询阶段…

以上各阶段的名称是根据我个人理解的翻译,为了避免错误和歧义,下面解释的时候会用英文来表示这些阶段。
这些阶段大致的功能如下:

  • timers: 这个阶段执行定时器队列中的回调如​​setTimeout()​​和​​setInterval()​​。
  • pending callbacks: 这个阶段执行几乎所有的回调。但是不包括​​close​​事件,定时器和​​setImmediate()​​的回调。
  • idle, prepare: 这个阶段仅在内部使用,可以不必理会。
  • poll: 等待新的I/O事件,node在一些特殊情况下会阻塞在这里。
  • check:​​setImmediate()​​的回调会在这个阶段执行。
  • close callbacks: 例如socket.on(‘close’, …)这种close事件的回调。

下面我们来按照代码第一次进入​​libuv​​引擎后的顺序来详细解说这些阶段:

times

这个阶段以先进先出的方式执行所有到期的timer加入timer队列里的callback,一个timer callback指得是一个通过​​setTimeout​​或者​​setInterval​​函数设置的回调函数。

说白了就是处理在此指定时间点之后可以执行提供的回调,而不是用户希望执行回调的确切时间。​​timer​​回调将在指定的时间过后尽早运行。

例如,计划超时以​​100​​毫秒的时间点执行,然后脚本开始异步读取需要​​95​​毫秒的文件:

const fs = require('fs');

function someAsyncOperation(callback) {
// Assume this takes 95ms to complete
fs.readFile('/path/to/file', callback);
}

const timeoutScheduled = Date.now();

setTimeout(() => {
const delay = Date.now() - timeoutScheduled;

console.log(`${delay}ms have passed since I was scheduled`);
}, 100);

// do someAsyncOperation which takes 95 ms to complete
someAsyncOperation(() => {
const startCallback = Date.now();

// do something that will take 10ms...
while (Date.now() - startCallback < 10) {
// do nothing
}
});

当事件循环进入轮询阶段时,它有一个空队列(这个时候​​fs.readFile()​​未完成),因此它将等待剩余的毫秒数,直到达到最快计时器的时间点。在等待​​95​​毫秒时,​​fs.readFile()​​完成读取文件,并将需要​​10​​毫秒才能完成的回调添加到轮询队列中并执行。当回调完成时,队列中没有更多的回调,因此事件循环将看到已达到最快计时器的时间点,然后回绕到计时器阶段以执行计时器的回调。在此示例中,您将看到正在调度的计时器与其正在执行的回调之间的总延迟将为 105 毫秒。

pending callbacks

此阶段对某些系统操作(如 TCP 错误类型,不部分是I/O事件)执行回调。例如,如果 TCP 套接字在尝试连接时收到​​ECONNREFUSED​​,则某些操作系统需要等待报告错误。这将排队等待在挂起的回调阶段执行。

poll

当个v8引擎将js代码解析后传入​​libuv​​引擎后,循环首先进入poll阶段,这个阶段有两个主要功能:

  • 计算它应该阻止和轮询 I/O 的时间
  • 处理轮询队列中的事件。

当事件循环进入轮询阶段并且没有配置​​timers​​时,该阶段的执行逻辑如下:

  • 如果​​poll​​队列不为空,则事件循环将循环访问其回调队列,按先进先出的顺序依次执行回调队列,直到队列空间已用尽。
  • 如果轮询队列为空,则会发生以下两种情况之一:
  • 如果代码中已配置了​​setImmediate()​​,则事件循环将结束轮询阶段,并继续到检查阶段以执行这些调度脚本。
  • 如果代码中尚未由​​setImmediate()​​安排,则事件循环将等待将回调添加到队列中,然后立即执行它们。

轮询队列为空后,事件循环将检查已达到时间点的​​timers​​。如果此时有多个计时器已准备就绪,则事件循环将围绕到​​timers​​阶段以执行这些回调。

值得注意的是,poll阶段在执行poll queue中的回调时实际上不会无限的执行下去。有两种情况poll阶段会终止执行poll queue中的下一个回调:

  1. 所有回调执行完毕
  2. 执行数超过了node的限制。

check

正常来说,在执行代码时,事件循环最终将进入​​poll​​阶段,在该阶段,它将等待传入连接、请求等。但是,如果​​setImmediate()​​的回调已安排,并且轮询阶段变为空闲状态,则它将结束并继续到检查阶段,而不是等待轮询事件。

close callbacks

如果套接字或句柄突然关闭,则事件将在此阶段发出。否则,它将通过​​process.nextTick()​​发出

process.nextTick,setTimeout与setImmediate的区别与使用场景

在node中有三个常用的用来推迟任务执行的方法:​​process.nextTick​​,​​setTimeout​​(setInterval与之相同)与​​setImmediate​

这三者间存在着一些非常不同的区别

process.nextTick()

尽管没有提及,但是实际上node中存在着一个特殊的队列,即​​nextTick queue​​。这个队列中的回调执行虽然没有被表示为一个阶段,当时这些事件却会在每一个阶段执行完毕准备进入下一个阶段时优先执行。当事件循环准备进入下一个阶段之前,会先检查​​nextTick queue​​中是否有任务,如果有,那么会先清空这个队列。与执行​​poll queue​​中的任务不同的是,这个操作在队列清空前是不会停止的。这也就意味着,错误的使用​​process.nextTick()​​方法会导致​​node​​进入一个死循环……直到内存泄漏。

const server = net.createServer(() => {}).listen(8080);

server.on('listening', () => {});

这个例子中当,当​​listen​​方法被调用时,除非端口被占用,否则会立刻绑定在对应的端口上。这意味着此时这个端口可以立刻触发​​listening​​事件并执行其回调。然而,这时候​​on('listening)​​还没有将​​callback​​设置好,自然没有​​callback​​可以执行。为了避免出现这种情况,node会在​​listen​​事件中使用​​process.nextTick()​​方法,确保事件在回调函数绑定后被触发。

这么做的原因

其中一部分是设计理念导致,其中API应该始终是异步的,即使它不必是异步的。

function apiCall(arg, callback) {
if (typeof arg !== 'string')
return process.nextTick(
callback,
new TypeError('argument should be string')
);
}

代码段会执行参数检查,如果不正确,则会将错误传递给回调。API 最近进行了更新,​​process.nextTick()​​允许传递参数,以允许它将回调后传递的任何参数作为参数传播到回调,因此您不必嵌套函数。

而js引擎要做的是将错误传递回用户,但只有在允许用户执行其余代码之后。通过使用​​process.nextTick()​​,我们保证​​apiCall()​​始终在用户代码的其余部分之后和允许事件循环继续之前运行其回调。为了实现这一点,允许JS调用堆栈展开,然后立即执行提供的回调,该回调允许人们在没有遇到RangeError: Maximum call stack size exceeded from v8这个异常的时候执行​​process.nextTick()​

这种理念可能会导致一些隐藏的bug

let bar;

// this has an asynchronous signature, but calls callback synchronously
function someAsyncApiCall(callback) {
callback();
}

// the callback is called before `someAsyncApiCall` completes.
someAsyncApiCall(() => {
// since someAsyncApiCall hasn't completed, bar hasn't been assigned any value
console.log('bar', bar); // undefined
});

bar = 1;

setImmediate() vs setTimeout()

在三个方法中,这两个方法最容易被弄混。实际上,某些情况下这两个方法的表现也非常相似。然而实际上,这两个方法的意义却大为不同,主要是区别在于什么时候被调用:

  • ​setTimeout()​​方法是定义一个回调,并且希望这个回调在我们所指定的时间间隔后第一时间去执行。注意这个第一时间执行,这意味着,受到操作系统和当前执行任务的诸多影响,该回调并不会在我们预期的时间间隔后精准的执行。执行的时间存在一定的延迟和误差,这是不可避免的。node会在可以执行timer回调的第一时间去执行你所设定的任务。
  • ​setImmediate()​​方法从意义上将是立刻执行的意思,但是实际上它却是在一个固定的阶段才会执行回调,即poll阶段之后。有趣的是,这个名字的意义和之前提到过的​​process.nextTick()​​方法才是最匹配的。node的开发者们也清楚这两个方法的命名上存在一定的混淆,他们表示不会把这两个方法的名字调换过来—因为有大量的node程序使用着这两个方法,调换命名所带来的好处与它的影响相比不值一提。

setTimeout()和不设置时间间隔的setImmediate()表现上及其相似。猜猜下面这段代码的结果是什么?

// timeout_vs_immediate.js
setTimeout(() => {
console.log('timeout');
}, 0);

setImmediate(() => {
console.log('immediate');
});

实际上,答案是不一定。

在nodejs中事件循环分析

没错,就连node的开发者都无法准确的判断这两者的顺序谁前谁后。这取决于这段代码的运行环境。运行环境中的各种复杂的情况会导致在同步队列里两个方法的顺序随机决定。但是,在一种情况下可以准确判断两个方法回调的执行顺序,那就是在一个I/O事件的回调中。下面这段代码的顺序永远是固定的:

const fs = require('fs');

fs.readFile(__filename, () => {
setTimeout(() => {
console.log('timeout');
}, 0);
setImmediate(() => {
console.log('immediate');
});
});

答案永远是:

immediate
timeout

因为在I/O事件的回调中,setImmediate方法的回调永远在timer的回调前执行。

总结

相比在chrome中执行js代码,在node中的执行更加纯粹一些,异步执行的内容是通过加入队列的形式来实现效果,脚本代码的执行周期也很干净,timer-I/O callbacks-idle, prepare-poll-check-close callbacks完成一个执行周期,其中的poll用来处理异步操作

参考资料

  1. https://zhuanlan.zhihu.com/p/33058983
  2. http://nodejs.cn/learn/the-nodejs-event-loop
  3. https://nodejs.org/en/docs/guides/event-loop-timers-and-nexttick/
© 版权声明

相关文章