【译】Node.js的事件循环(Event Loop)、定时器(Timers)和 process.nextTick()

翻译自 Node.js 官方文档:The Node.js Event Loop, Timers, and process.nextTick()

什么是事件循环?

事件循环是一种允许 Node.js 实现无阻塞的 IO 操作 —— 尽管 JavaScript 是单线程的 —— 通过尽可能将操作卸到系统内核的机制。

因为大部分现代内核都是多线程的,他们可以在后台执行多个操作。当这些操作的其中一个完成后,内核告知 Node.js 以便对应的回调能添加到 poll 队列以最终执行。下面我们将深入这个主题的细节。

事件循环解析

当 Node.js 启动,它初始化事件循环,处理提供的输入脚本(或将脚本放到 REPL 内,不再本文的讨论范围内),脚本可能做了异步 API 调用、定时器或者调用 process.nextTick()。然后开始处理事件循环。

换句话说,先执行完脚本中同步的代码。

下面的图表展示了事件循环操作顺序的简单概览:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
   ┌───────────────────────────┐
┌─>│ timers │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ pending callbacks │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ idle, prepare │
│ └─────────────┬─────────────┘ ┌───────────────┐
│ ┌─────────────┴─────────────┐ │ incoming: │
│ │ poll │<─────┤ connections, │
│ └─────────────┬─────────────┘ │ data, etc. │
│ ┌─────────────┴─────────────┐ └───────────────┘
│ │ check
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
└──┤ close callbacks │
└───────────────────────────┘

注:左边每个块在事件循环中被称为 “阶段”

每个阶段有一个 FIFO(先进先出)的将要执行的回调函数的队列。虽然每个阶段都有自己独特运行的地方,但通常来说,当事件循环进入一个指定的阶段,它将执行特定于该阶段的任何操作,然后执行该阶段的队列中的回调函数直到队列消耗完毕或执行函数的数量达到了最大值。当回调函数队列被耗尽或达到了回调极限,事件循环将进入下一个阶段,下一个阶段如是。

由于这些操作中的任何一个可以规划更多操作并且在 poll 阶段中处理的新事件(例如新的连接来了,新的数据来了的回调)由内核排队,因此新的 poll 事件可以在处理 poll 事件时继续加入队列。因此,长时间运行的回调可以允许轮询阶段运行的时间比计时器的阈值长得多。有关详细信息,请参阅下面的 timerspoll 章节。

注:Windows 和 Unix/Linux 实现之间存在轻微差异,但对于本文这个并不重要。
这里讲的是最重要的部分,实际上有 7,8 个步骤,但我们关心的,Node.js 实际上使用的就是上面这些

阶段概览

  • timers: 这个阶段执行由 setTimeout()setInterval() 规划的回调
  • pending callbacks: 执行延迟到下一个循环迭代的 I/O 回调
  • idle, prepare: 只在内部使用
  • poll: 检索新的 I/O 事件,执行 I/O 相关回调(除了 close callback,定时器和 setImmediate() 规划之外的几乎所有回调),节点将在适当的时候阻塞
  • check: setImmediate()回调在这里执行
  • close callbacks: 一些 close callbacks,例如 socket.on ('close', fn)fn

在事件循环的每次运行之间,Node.js 检查它是否在等待任何异步 I/O 或定时器,如果没有,则关闭事件循环,进程退出。

阶段细节

timers

一个定时器指定了阈值,阈值是指之后被提供的回调函数可能被执行,而不是我们希望他被执行的确切时间。定时器回调在给定的时间过完后尽可能早地执行,然而,操作系统规划或运行其他回调可能延迟定时器回调的执行。

注:技术上 poll 阶段控制了定时器的回调什么时候执行

一个例子,你规划了一个 timeout 在 100ms 的阈值后执行回调,然后你的脚本开始一个花费 95ms 异步文件读取。

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
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
}
});

当事件循环进入 poll 阶段,它的队列为空(fs.readFile() 还没有完成),所以它将等待剩余的毫秒数,直到最快的定时器阈值到达。然而,等待 95ms 后,fs.readFile() 结束了文件的读取,它的需要花费 10ms 才能完成的回调将加入 poll 队列,此时 poll 队列为非空,开始遍历队列以执行。当该回调结束,队列里没有了其他回调函数,于是事件循环将看到最快的定时器到达了阈值,然后折返回 timers 阶段去执行定时器回调。在上面的例子里,你将看到定时器从规划到它的回调被执行总共延时了 105ms(而不是预期的 100ms)。

注:为了防止 poll 阶段“饿死”事件循环,libuv(实现了 Node.js 所有异步行为及事件循环的 C 库)有一个强制的最大值,到达最大值将会停止 polling 更多事件

pending callbacks

此阶段执行某些系统操作(例如TCP错误类型)的回调。例如,如果 TCP 套接字在尝试连接时收到 ECONNREFUSED,则某些 *nix 系统希望等待报告错误。这将加入到 pending callbacks 的执行队列。

poll

poll 阶段有两个主要的功能

  1. 计算 poll 阶段应该阻塞和轮询 I/O 多久,然后
  2. 处理 poll 队列里的事件代码

当事件循环进入 poll 阶段,开始轮询,会按照下面的规则运行:

  1. 如果 poll 队列不为空,事件循环将遍历回调队列同步执行直到队列耗尽或达到了系统强制的极限

  2. 如果 poll 队列为空,又分为以下两种情况

    1. 如果脚本出现了 setImmediate() 规划(setImmediate() 的回调将加入 check 阶段的队列),事件循环将结束 poll 阶段,然后进入 check 阶段以执行 setImmediate() 的回调
    2. 如果脚本没有出现 setImmediate() 规划,事件循环将检查是否有定时器达到了阈值,根据是否有一个或多个 timer 达到了阈值,又可以分为以下两种情况:
      1. 没有 timer 达到了阈值,事件循环将等待新的回调加到 poll 队列(incoming connection, request 等)
      2. 有一个或多个 timer 达到了阈值,事件循环将折回到 timer 阶段执行定时器的回调
1
2
3
4
5
6
poll -----> <circle wait>
|
|--------
| |
v v
check timer

check

这个阶段允许我们poll 阶段完成后立即执行回调,如果 poll 阶段空闲(idle)并且脚本调用了 setImmediate(),事件循环将进入 check 阶段而不是等待。

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

close callbacks

如果一个 sockethandle 突然被关闭(例如:socket.destroy()close 事件将会在这个阶段触发)。否则它将通过 process.nextTick() 来触发

setImmediate() vs setTimeout()

setImmediate()setTimeout() 相似,但是行为上的差异决定于它们合适被调用

  • setImmediate() 被设计用于一旦当前 poll 阶段完成后便执行其回调
  • setTimeout() 规划在经过最小阈值(以 ms 为单位)后运行的脚本

执行上面两个定时器的顺序将根据调用它们的上下文而有所不同。如果从主模块中调用两者,则时间将受到进程性能的限制(可能受到计算机上运行的其他应用程序的影响)。

例如,如果我们运行不在 I/O 周期内的以下脚本(即主模块中调用),则执行两个定时器的顺序是不确定的,因为它受到进程性能的约束:

1
2
3
4
5
6
7
8
// timeout_vs_immediate.js
setTimeout(() => {
console.log('timeout');
}, 0);

setImmediate(() => {
console.log('immediate');
});
1
2
3
4
5
6
7
$ node timeout_vs_immediate.js
timeout
immediate

$ node timeout_vs_immediate.js
immediate
timeout

但是,如果移动到 I/O 周期内对两个进行调用,则始终首先执行 setImmediate() 的回调:

1
2
3
4
5
6
7
8
9
10
11
// timeout_vs_immediate.js
const fs = require('fs');

fs.readFile(__filename, () => {
setTimeout(() => {
console.log('timeout');
}, 0);
setImmediate(() => {
console.log('immediate');
});
});
1
2
3
4
5
6
7
$ node timeout_vs_immediate.js
immediate
timeout

$ node timeout_vs_immediate.js
immediate
timeout

使用 setImmediate() 而不是 setTimeout() 的主要优点是 setImmediate() 将始终在任何定时器之前执行(如果在相同的 I/O 周期内调度),与存在多少定时器无关。

process.nextTick()

理解 process.nextTick()

你可能注意到了 process.nextTick() 不在上面的示意图表里,尽管它也是异步 API 的一部分。这是因为从技术上来说,process.nextTick() 不是事件循环的一部分。process.nextTick() 将会在当前操作(当前正在运行的队列中的某个回调)完成后马上被执行,不论当前处于事件循环处于哪个阶段。

回看之前的示意图,在给定阶段的任何时间调用 process.nextTick(),在继续事件循环之前,所有传入 process.nextTick() 的回调函数将会被执行。通过递归的 process.nextTick,会“饿死”你的 I/O,阻止事件循环到达 poll 阶段,导致出现一些糟糕的场景。

为什么我们会这样做?

为什么 Node.js 中会出现 process.nextTick() 这样的东西?**有一部分原因是 Node.js 的设计理念:当某个 API 应该始终是异步的,即使在某些地方使用时不是必须的(这个时候 process.nextTick() 可以实现这种同步 API 向异步 API 的转化)**它不是必须的。以此代码段为例:

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

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

我们上面做的是将错误传回给用户,但只有在我们允许其余的用户代码(同步代码)执行之后。通过使用 process.nextTick(),我们保证 apiCall() 始终在用户代码的其余部分之后(即当前的所有同步代码执行之后)和允许事件循环继续之前运行其回调。为了实现这个,JS 调用栈允许被展开,然后立即执行传入的回调函数,回调函数允许我们继续递归调用 process.nextTick() 而不会触发 RangeError: Maximum call stack size exceeded from v8.

笔者注:调用栈展开又可成为调用栈释放

笔者注:此处的意思是指 process.nextTick(() => {}) 的方式会导致函数调用栈溢出的问题,而 process.nextTick(fn, args) 则可以防止函数调用栈溢出

笔者注:类似尾递归优化的原理

(如果没有 process.nextTick() 来转化为异步 API)这种理念(即某些 API 必须是异步的)可能会导致一些潜在的问题。以此片段为例:

1
2
3
4
5
6
7
8
9
10
11
12
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() 定义为具有异步签名(函数名中体现了),但它实际上是同步操作。调用它时,在事件循环的同一阶段调用提供给 someAsyncApiCall() 的回调,因为 someAsyncApiCall() 实际上不会异步执行任何操作。因此,回调尝试引用变量 bar,尽管它在范围内可能没有该变量,因为该脚本还没有运行完成。

通过将回调函数放置在 process.nextTick(),脚本便能够运行完整(同步代码能全部执行完),允许在调用回调之前初始化所有变量,函数等。它还具有暂时跳出事件循环继续的优点。在允许事件循环继续之前,向用户警告错误可能是有用的。以下前一个示例使用 process.nextTick() 的版本:

1
2
3
4
5
6
7
8
9
10
11
let bar;

function someAsyncApiCall(callback) {
process.nextTick(callback);
}

someAsyncApiCall(() => {
console.log('bar', bar); // 1
});

bar = 1;

这是一个实际的例子:

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

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

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

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

process.nextTick() vs setImmediate()

就用户而言,我们有两个类似的呼叫,但它们的名称令人困惑。

  • process.nextTick() 在当前阶段立即被触发
  • setImmediate() 在事件循环的下一趟迭代或称之为 tick 触发

注:这里的一个一趟迭代指的是一个阶段队列的遍历执行

实质上,应该交换名称。 process.nextTick()setImmediate() 更 ‘immediate’,但这是过去已经定好的,不太可能改变。做出这个交换会破坏 npm 上的大部分包。每天都会产生更多新模块,这意味着我们多等待一天,做出交换会带来更多的潜在的破损。尽管这两个的名称造成了困惑,但它们的名字不会改变。

我们推荐开发者在所有情景下都使用 setImmediate(),因为它更容易被理解(并且它有着更广泛的环境兼容性,例如在浏览器的 JS 里)。

为什么要使用 process.nextTick()

两个理由:

  1. 允许用户处理错误(将错误通过 process.nextTick(fn, error) 传递给 fn),清除任何不需要的资源(例如在 nextTick 手动解除同步代码中的内存引用),或者在事件循环继续之前再次尝试请求

  2. 有时需要允许回调在调用栈展开之后但在事件循环继续之前运行。

一个例子是为了匹配用户的期望:

1
2
3
4
5
const server = net.createServer();
server.on('connection', (conn) => { });

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

假设 listen() 在事件循环开始时运行,但是 listening 回调用 setImmediate() 包裹。除非传递 hostname,否则将立即绑定到端口(并立即触发 listening 事件)。要想事件循环继续,它必须先达到轮询阶段,这意味着存在一个可能的空窗期:在 listening 事件的代码(用 setImmediate() 包裹的)执行前就收到了一个连接,也就是相当于先于 listening 触发了 connection 事件。

注:使用 process.nextTick() 可以规避这个问题,保证 listening 的回调先触发

另一个例子是运行一个函数构造函数,比如继承自 EventEmitter,它想在构造函数中调用一个事件:

1
2
3
4
5
6
7
8
9
10
11
12
13
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', () => {
console.log('an event occurred!');
});

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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(() => {
this.emit('event');
});
}
util.inherits(MyEmitter, EventEmitter);

const myEmitter = new MyEmitter();
myEmitter.on('event', () => {
console.log('an event occurred!');
});
FuChee wechat
扫一扫,关注我