[NodeJs系列][译]理解NodeJs中的Event Loop、Timers以及process.nextTick()

时间:2022-01-15 18:47:59

译者注:

  1. 为什么要翻译?其实在翻译这篇文章前,笔者有Google了一下中文翻译,看的不是很明白,所以才有自己翻译的打算,当然能力有限,文中或有错漏,欢迎指正。
  2. 文末会有几个小问题,大家不妨一起思考一下
  3. 欢迎关注微信公众号:前端情报局-NodeJs系列

什么是Event loop

尽管JavaScript是单线程的,通过Event Loop使得NodeJs能够尽可能的通过卸载I/O操作到系统内核,来实现非阻塞I/O的功能。

由于大部分现代系统内核都是多线程的,因此他们可以在后台执行多个操作。当这些操作中的某一个完成后,内核便会通知NodeJs,这样(这个操作)指定的回调就会添加到poll队列以便最终执行。关于这个我们会在随后的章节中进一步说明。

Event Loop解析

当NodeJs启动时,event loop 随即会被初始化,而后会执行对应的输入脚本(直接把脚本放入REPL执行不在本文讨论范围内),这个过程中(脚本的执行)可能会存在对异步API的调用,产生定时器或者调用process.nextTick(),接着开始event loop。

译者注:这段话的意思是NodeJs优先执行同步代码,在同步代码的执行过程中可能会调用到异步API,当同步代码和process.nextTick()回调执行完成后,就会开始event loop

下图简要的概述了event loop的操作顺序:


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

注:每一个框代表event loop中的一个阶段

每个阶段都有一个FIFO(先进先出)的回调队列等待执行。虽然每个阶段都有其独特之处,但总体而言,当event loop进入到指定阶段后,它会执行该阶段的任何操作,并执行对应的回调直到队列中没有可执行回调或者达到回调执行上限,而后event loop会进入下一阶段。

由于任何这些阶段的操作可能产生更多操作,内核也会将新的事件推入到poll阶段的队列中,所以新的poll事件被允许在处理poll事件时继续加入队,这也意味着长时间运行的回调可以允许poll阶段运行的时间比计时器的阈值要长

注意:Windows和Unix/Linux在实现上有些差别,但这对本文并不重要。事实上存在7到8个步骤,但以上列举的是Node.js中实际使用的。

阶段概览

  • timers:执行的是setTimeout()setInterval()的回调
  • I/O callbacks:执行除了 close callbacks、定时器回调和setImmediate()设定的回调之外的几乎所有回调
  • idle, prepare:仅内部使用
  • poll:接收新的I/O事件,适当时node会阻塞在这里(==什么情况下是适当的?==)
  • checksetImmediate回调在这里触发
  • close callbacks:比如socket.on('close', ...)

在每次执行完event loop后,Node.js都会检查是否还有需要等待的I/O或者定时器没有处理,如果没有那么进程退出。

阶段细节

timers

一个定时器会指定阀值,并在达到阀值之后执行给定的回调,但通常来说这个阀值会超过我们预期的时间。定时器回调会尽可能早的执行,不过操作系统的调度和其他回调的执行时间会造成一定的延时。

注:严格意义上说,定时器什么时候执行取决于poll阶段

举个例子,假定一个定时器给定的阀值是100ms,异步读取文件需要95ms的时间


const fs = require('fs'); function someAsyncOperation(callback) {
// 假定这里花费了95ms
fs.readFile('/path/to/file', callback);
} const timeoutScheduled = Date.now(); setTimeout(function() { const delay = Date.now() - timeoutScheduled; console.log(delay + 'ms have passed since I was scheduled');
}, 100); // 95ms后异步操作才完成
someAsyncOperation(function() { const startCallback = Date.now(); // 这里花费了10ms
while (Date.now() - startCallback < 10) {
// do nothing
}
});

就本例而言,当event loop到达poll阶段,它的队列是空的(fs.readFile()还未完成),因此它会停留在这里直到达到最早的定时器阀值。fs.readFile()
花费了95ms读取文件,之后它的回调被推入poll队列并执行(执行花了10ms)。回调执行完毕后,队列中已经没有其他回调需要执行了,那么event loop就会去检查是否有定时器的回调可以执行,如果有就跳回到timer阶段执行相应回调。在本例中,你可以看到从定时器被调用到其回调被执行一共耗时105ms。

注:为了防止event loop一直阻塞在poll阶段,libuv(http://libuv.org/ 这是用c语言实现了Node.js event loop以及各个平台的异步行为的库)会指定一个硬性的最大值以阻止更多的事件被推入poll。

I/O callbacks阶段

这个阶段用于执行一些系统操作的回调,比如TCP错误。举个例子,当一个TCP socket 在尝试连接时接收到ECONNREFUSED的错误,一些*nix系统会想要得到这些错误的报告,而这都会被推到 I/O callbacks中执行。

poll阶段

poll阶段有两个功能:

  1. 执行已经达到阀值的定时器脚本
  2. 处理在poll队列中的事件

当event loop进入到poll阶段且此代码中为设定定时器,将会发生下面情况:

  1. 如果poll队列非空,event loop会遍历执行队列中的回调函数直到队列为空或达到系统上限
  2. 如果poll队列是空的,将会发生下面情况:

    • 如果脚本中存在对setImmediate()的调用,event loop将会结束poll阶段进入check阶段并执行这些已被调度的代码
    • 如果脚本中不存在对setImmediate()的调用,那么event loop将阻塞在这里直到有回调被添加进来,新加的回调将会被立即执行

一旦poll队列为空,event loop就会检查是否有定时器达到阀值,如果有1个或多个定时器符合要求,event loop将将会回到timers阶段并执行改阶段的回调.

check阶段

一旦poll阶段完成,本阶段的回调将被立即执行。如果poll阶段处于空闲状态并且脚本中有执行了setImmediate(),那么event loop会跳过poll阶段的等待进入本阶段。

实际上setImmediate()是一个特殊的定时器,它在事件循环的一个单独阶段运行,它使用libuv API来调度执行回调。

通常而言,随着代码的执行,event loop最终会进入poll阶段并在这里等待新事件的到来(例如新的连接和请求等等)。但是,如果存在setImmediate()的回调并且poll阶段是空闲的,那么event loop就会停止在poll阶段漫无目的的等等直接进入check阶段。

close callbacks阶段

如果一个socket或者handle突然关闭(比如:socket.destory()),close事件就会被提交到这个阶段。否则它将会通过process.nextTick()触发

setImmediate() 和 setTimeout()

setImmediatesetTimeout()看起来是比较相似,但它们有不同的行为,这取决于它们什么时候被调用。

  • setImmediate() 被设计成一旦完成poll阶段就会被立即调用
  • setTimeout() 则是在达到最小阀值是才会被触发执行

其二者的调用顺序取决于它们的执行上下文。如果两者都在主模块被调用,那么其回调被执行的时间点就取决于处理过程的性能(这可能被运行在同一台机器上的其他应用影响)

比如说,如果下列脚本不是在I/O循环中运行,这两种定时器运行的顺序是不一定的(==这是为什么?==),这取决于处理过程的性能:


// timeout_vs_immediate.js
setTimeout(function timeout() {
console.log('timeout');
}, 0); setImmediate(function immediate() {
console.log('immediate');
});

$ node timeout_vs_immediate.js
timeout
immediate $ node timeout_vs_immediate.js
immediate
timeout

但是如果你把上面的代码置于I/O循环中,setImmediate回调会被优先执行:


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

$ node timeout_vs_immediate.js
immediate
timeout $ node timeout_vs_immediate.js
immediate
timeout

使用setImmediate()而不是setTimeout()的主要好处是:如果代码是在I/O循环中调用,那么setImmediate()总是优先于其他定时器(无论有多少定时器存在)

process.nextTick()

理解process.nextTick()

你可能已经注意到process.nextTick()不在上面的图表中,即使它也是异步api。这是因为严格意义上来说process.nextTick()不属于event loop中的一部分,它会忽略event loop当前正在执行的阶段,而直接处理nextTickQueue中的内容。

回过头看一下图表,你在任何给定阶段调用process.nextTick(),在继续event loop之前,所有传入process.nextTick()的回调都会被执行。这可能会导致一些不好的情况,因为它允许你递归调用process.nextTick()从而使得event loop无法进入poll阶段,导致无法接收到新的 I/O事件

为什么这会被允许?

那为什么像这样的东西会被囊括在Node.js?部分由于Node.js的设计理念:API应该始终是异步的即使有些地方是没必要的。举个例子:


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

这是一段用于参数校验的代码,如果参数不正确就会把错误信息传递到回调。最近process.nextTick()有进行一些更新,使得我们可以传递多个参数到回调中而不用嵌套多个函数。

我们(在这个例子)所做的是在保证了其余(同步)代码的执行完成后把错误传递给用户。通过使用process.nextTick()我们可以确保apiCall()的回调总是在其他(同步)代码运行完成后event loop开始前调用的。为了实现这一点,JS调用栈被展开(==什么是栈展开?==)然后立即执行提供的回调,那我们就可以对process.nextTick进行递归(==怎么做到的?==)调用而不会触发RangeError: Maximum call stack size exceeded from v8的错误。

这种理念可能会导致一些潜在的问题。比如:


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 has completed, bar hasn't been assigned any value
console.log('bar', bar); // undefined }); bar = 1;

用户定义了一个异步签名的函数someAsyncApiCall()(函数名可以看出),但实际上操作是同步的。当它被调用时,其回调也在event loop中的同一阶段被调用了,因为someAsyncApiCall()实际上并没有任何异步动作。结果,在(同步)代码还没有全部执行的时候,回调就尝试去访问变量bar

通过把回调置于process.nextTick(),脚本就能完整运行(同步代码全部执行完毕),这就使得变量、函数等可以先于回调执行。同时它也有阻止event loop继续执行的好处。有时候我们可能希望在event loop继续执行前抛出一个错误,这种情况下process.nextTick()变的很有用。下面是对上一个例子的process.nextTick()改造:


let bar; function someAsyncApiCall(callback) {
process.nextTick(callback);
} someAsyncApiCall(() => {
console.log('bar', bar); // 1
}); bar = 1;

这是一个实际的例子:


const server = net.createServer(() => {}).listen(8080); server.on('listening', () => {});

当只有一个端口作为参数传入,端口会被立即绑定。所以监听回调可能被立即调用。问题是:on('listening') 回调在那时还没被注册。

为了解决这个问题,把listening事件加入到nextTick() 队列中以允许脚本先执行完(同步代码)。这允许用户(在同步代码中)设置任何他们需要的事件处理函数。

process.nextTick() 和 setImmediate()

对于用户而言,这两种叫法是很相似的但它们的名字又让人琢磨不透。

  • process.nextTick() 会在同一个阶段执行
  • setImmediate() 会在随后的迭代中执行

本质上,这两个的名字应该互换一下,process.nextTick()setImmediate()更接近于立即,但是由于历史原因这不太可能去改变。名字互换可能影响大部分的npm包,每天都有大量的包在提交,这意味这越到后面,互换造成的破坏越大。所以即使它们的名字让人困惑也不可能被改变。

我们建议开发者在所有情况中使用setImmediate(),因为这可以让你的代码兼容更多的环境比如浏览器。

为什么要使用process.nextTick()?

这里又两个主要的原因:

  1. 让开发者处理错误、清除无用的资源或者在event loop继续之前再次尝试重新请求资源
  2. 有时需要允许回调在调用栈展开之后但在事件循环继续之前运行

下面这个例子会满足我们的期望:


const server = net.createServer();
server.on('connection', function(conn) { }); server.listen(8080);
server.on('listening', function() { });

假设listen()是在event loop开始前运行,但是监听回调是包裹在setImmediate中,除非指定hostname参数否则端口将被立即绑定(listening回调被触发),event loop必须要执行到poll阶段才会去处理,这意味着存在一种可能:在listening事件的回调执行前就收到了一个连接,也就是相当于先于listening 触发了connection事件。

另一个例子是运行一个继承至EventEmitter的构造函数,而这个构造函数中会发布一个事件。


const EventEmitter = require('events');
const util = require('util'); function MyEmitter() {
EventEmitter.call(this);
this.emit('event');
}
util.inherits(MyEmitter, EventEmitter); const myEmitter = new MyEmitter();
myEmitter.on('event', function() {
console.log('an event occurred!');
});

你无法立即从构造函数中真正触发事件,因为脚本还没有运行到用户为该事件分配回调的位置。因此,在构造函数中,您可以使用 process.nextTick() 来设置回调以在构造函数完成后发出事件,从而提供预期的结果


const EventEmitter = require('events');
const util = require('util'); function MyEmitter() {
EventEmitter.call(this); // use nextTick to emit the event once a handler is assigned
process.nextTick(function() {
this.emit('event');
}.bind(this));
}
util.inherits(MyEmitter, EventEmitter); const myEmitter = new MyEmitter();
myEmitter.on('event', function() {
console.log('an event occurred!');
});

译者注(Q&A)

翻译完本文,笔者给自己提了几个问题?

  1. poll阶段什么时候会被阻塞?
  2. 为什么在非I/O循环中,setTimeoutsetImmediate的执行顺序是不一定的?
  3. JS调用栈展开是什么意思?
  4. 为什么process.nextTick()可以被递归调用?

笔者将在之后的文章[《Q&A之理解NodeJs中的Event Loop、Timers以及process.nextTick()》]()探讨这些问题,有兴趣的同学可以关注笔者的公众号: 前端情报局-NodeJs系列获取最新情报

原文地址: https://github.com/nodejs/nod...

[NodeJs系列][译]理解NodeJs中的Event Loop、Timers以及process.nextTick()

来源:https://segmentfault.com/a/1190000017920493