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

2次阅读

共计 8086 个字符,预计需要花费 21 分钟才能阅读完成。

译者注:

为什么要翻译?其实在翻译这篇文章前,笔者有 Google 了一下中文翻译,看的不是很明白,所以才有自己翻译的打算,当然能力有限,文中或有错漏,欢迎指正。
文末会有几个小问题,大家不妨一起思考一下
欢迎关注微信公众号:前端情报局 -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 会阻塞在这里(== 什么情况下是适当的?==)

check:setImmediate 回调在这里触发

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 阶段有两个功能:

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

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

如果 poll 队列非空,event loop 会遍历执行队列中的回调函数直到队列为空或达到系统上限

如果 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()
setImmediate 和 setTimeout()看起来是比较相似,但它们有不同的行为,这取决于它们什么时候被调用。

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()?
这里又两个主要的原因:

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

下面这个例子会满足我们的期望:
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)
翻译完本文,笔者给自己提了几个问题?

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

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

正文完
 0