原文:https://nodejs.org/en/docs/guides/event-loop-timers-and-nexttick/node
如下是译文:api
JavaScript 是单线程的,有了 event loop 的加持,Node.js 才能够非阻塞地执行 I/O 操做,把这些操做尽可能转移给操做系统来执行。bash
咱们知道大部分现代操做系统都是多线程的,这些操做系统能够在后台执行多个操做。当某个操做结束了,操做系统就会通知 Node.js,而后 Node.js 就(可能)会把对应的回调函数添加到 poll(轮询)队列,最终这些回调函数会被执行。下文中咱们会阐述其细节。多线程
当 Node.js 启动时,会作这几件事异步
如何处理 event loop 呢?下图给出了一个简单的概览:socket
┌───────────────────────┐
┌─>│ timers │
│ └──────────┬────────────┘
│ ┌──────────┴────────────┐
│ │ I/O callbacks │
│ └──────────┬────────────┘
│ ┌──────────┴────────────┐
│ │ idle, prepare │
│ └──────────┬────────────┘ ┌───────────────┐
│ ┌──────────┴────────────┐ │ incoming: │
│ │ poll │<─────┤ connections, │
│ └──────────┬────────────┘ │ data, etc. │
│ ┌──────────┴────────────┐ └───────────────┘
│ │ check │
│ └──────────┬────────────┘
│ ┌──────────┴────────────┐
└──┤ close callbacks │
└───────────────────────┘
复制代码
其中每一个方框都是 event loop 中的一个阶段。ide
每一个阶段都有一个「先入先出队列」,这个队列存有要执行的回调函数(译注:存的是函数地址)。不过每一个阶段都有其特有的使命。通常来讲,当 event loop 达到某个阶段时,会在这个阶段进行一些特殊的操做,而后执行这个阶段的队列里的全部回调。 何时中止执行这些回调呢?下列两种状况之一会中止:函数
一方面,上面这些操做都有可能添加计时器;另外一方面,操做系统会向 poll 队列中添加新的事件,当 poll 队列中的事件被处理时可能会有新的 poll 事件进入 poll 队列。结果,耗时较长的回调函数可让 event loop 在 poll 阶段停留好久,久到错过了计时器的触发时机。你能够在下文的 timers 章节和 poll 章节详细了解这其中的细节。oop
注意,Windows 的实现和 Unix/Linux 的实现稍有不一样,不过对本文内容影响不大。本文囊括了 event loop 最重要的部分,不一样平台可能有七个或八个阶段,可是上面的几个阶段是咱们真正关心的阶段,并且是 Node.js 真正用到的阶段。性能
一个 Node.js 程序结束时,Node.js 会检查 event loop 是否在等待异步 I/O 操做结束,是否在等待计时器触发,若是没有,就会关掉 event loop。
计时器其实是在指定多久之后能够执行某个回调函数,而不是指定某个函数的确切执行时间。当指定的时间达到后,计时器的回调函数会尽早被执行。若是操做系统很忙,或者 Node.js 正在执行一个耗时的函数,那么计时器的回调函数就会被推迟执行。
注意,从原理上来讲,poll 阶段能控制计时器的回调函数何时被执行。
举例来讲,你设置了一个计时器在 100 毫秒后执行,而后你的脚本用了 95 毫秒来异步读取了一个文件:
const fs = require('fs');
function someAsyncOperation(callback) {
// 假设读取这个文件一共花费 95 毫秒
fs.readFile('/path/to/file', callback);
}
const timeoutScheduled = Date.now();
setTimeout(() => {
const delay = Date.now() - timeoutScheduled;
console.log(`${delay}毫秒后执行了 setTimeout 的回调`);
}, 100);
// 执行一个耗时 95 毫秒的异步操做
someAsyncOperation(() => {
const startCallback = Date.now();
// 执行一个耗时 10 毫秒的同步操做
while (Date.now() - startCallback < 10) {
// 什么也不作
}
});
复制代码
当 event loop 进入 poll 阶段,发现 poll 队列为空(由于文件还没读完),event loop 检查了一下最近的计时器,大概还有 100 毫秒时间,因而 event loop 决定这段时间就停在 poll 阶段。在 poll 阶段停了 95 毫秒以后,fs.readFile 操做完成,一个耗时 10 毫秒的回调函数被系统放入 poll 队列,因而 event loop 执行了这个回调函数。执行完毕后,poll 队列为空,因而 event loop 去看了一眼最近的计时器(译注:event loop 发现卧槽,已经超时 95 + 10 - 100 = 5 毫秒了),因而经由 check 阶段、close callbacks 阶段绕回到 timers 阶段,执行 timers 队列里的那个回调函数。这个例子中,100 毫秒的计时器其实是在 105 毫秒后才执行的。
注意:为了防止 poll 阶段占用了 event loop 的全部时间,libuv(Node.js 用来实现 event loop 和全部异步行为的 C 语言写成的库)对 poll 阶段的最长停留时间作出了限制,具体时间因操做系统而异。
这个阶段会执行一些系统操做的回调函数,好比 TCP 报错,若是一个 TCP socket 开始链接时出现了 ECONNREFUSED 错误,一些 *nix 系统就会(向 Node.js)通知这个错误。这个通知就会被放入 I/O callbacks 队列。
poll 阶段有两个功能:
当 event loop 进入 poll 阶段,若是发现没有计时器,就会:
一旦 poll 队列为空,event loop 就会检查计时器有没有到期,若是有计时器到期了,event loop 就会回到 timers 阶段执行计时器的回调。
这个阶段容许开发者在 poll 阶段结束后当即执行一些函数。若是 poll 阶段空闲了,同时存在 setImmediate() 任务,event loop 就会进入 check 阶段。
setImmediate() 其实是一种特殊的计时器,有本身特有的阶段。它是经过 libuv 里一个能将回调安排在 poll 阶段以后执行的 API 实现的。
通常来讲,当代码执行后,event loop 最终会达到 poll 阶段,等待新的链接、新的请求等。可是若是一个回调是由 setImmediate() 发出的,同时 poll 阶段空闲下来了,event loop就会结束 poll 阶段进入 check 阶段,再也不等待新的 poll 事件。
(译注:感受一样的话说了三遍)
若是一个 socket 或者 handle 被忽然关闭(好比 socket.destroy()),那么就会有一个 close 事件进入这个阶段。不然(译注:我没看到这个不然在否认什么,是在否认「忽然」吗?),这个 close 事件就会进入 process.nextTick()。
setImmediate 和 setTimeout 很类似,可是其回调函数的调用时机却不同。
setImmediate() 的做用是在当前 poll 阶段结束后调用一个函数。 setTimeout() 的做用是在一段时间后调用一个函数。 这二者的回调的执行顺序取决于 setTimeout 和 setImmediate 被调用时的环境。
若是 setTimeout 和 setImmediate 都是在主模块(main module)中被调用的,那么回调的执行顺序取决于当前进程的性能,这个性能受其余应用程序进程的影响。
举例来讲,若是在主模块中运行下面的脚本,那么两个回调的执行顺序是没法判断的:
// timeout_vs_immediate.js
setTimeout(() => {
console.log('timeout');
}, 0);
setImmediate(() => {
console.log('immediate');
});
复制代码
运行结果以下:
$ node timeout_vs_immediate.js
timeout
immediate
$ node timeout_vs_immediate.js
immediate
timeout
复制代码
可是,若是把上面代码放到 I/O 操做的回调里,setImmediate 的回调就老是优先于 setTimeout 的回调:
// 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 的主要优点就是,若是在 I/O 操做的回调里,setImmediate 的回调老是比 setTimeout 的回调先执行。(译者注:怎么老是把一个道理翻来覆去地说)
你可能发现 process.nextTick() 这个重要的异步 API 没有出如今任何一个阶段里,那是由于从技术上来说 process.nextTick() 并非 event loop 的一部分。实际上,无论 event loop 当前处于哪一个阶段,nextTick 队列都是在当前阶段后就被执行了。
回过头来看咱们的阶段图,你在任何一个阶段调用 process.nextTick(回调),回调都会在当前阶段继续运行前被调用。这种行为有的时候会形成很差的结果,由于你能够递归地调用 process.nextTick(),这样 event loop 就会一直停在当前阶段不走……没法进入 poll 阶段。
为何 Node.js 要这样设计 process.nextTick 呢?
由于有些异步 API 须要保证一致性,即便能够同步完成,也要保证异步操做的顺序,看下面代码:
function apiCall(arg, callback) {
if (typeof arg !== 'string')
return process.nextTick(callback, new TypeError('argument should be string'));
}
复制代码
这段代码检查了参数的类型,若是类型不是 string,就会将 error 传递给 callback。
这段代码保证 apiCall 调用以后的同步代码能在 callback 以前运行。用于用到了 process.nextTick(),因此 callback 会在 event loop 进入下一个阶段前执行。为了作到这一点,JS 的调用栈能够先 unwind 再执行 nextTick 的回调,这样不管你递归调用多少次 process.nextTick() 都不会形成调用栈溢出(V8 里对应 RangeError: Maximum call stack size exceeded)。
若是不这样设计,会形成一些潜在的问题,好比下面的代码:
let bar;
// 这是一个异步 API,可是却同步地调用了 callback
function someAsyncApiCall(callback) { callback(); }
//`someAsyncApiCall` 在执行过程当中就调用了回调
someAsyncApiCall(() => {
// 此时 bar 尚未被赋值为 1
console.log('bar', bar); // undefined
});
bar = 1;
复制代码
开发者虽然把 someAsyncApiCall 命名得像一个异步函数,可是实际上这个函数是同步执行的。当 someAsyncApiCall 被调用时,回调也在同一个 event loop 阶段被调用了。结果回调中就没法获得 bar 的值。由于赋值语句还没被执行。
若是把回调放在 process.nextTick() 中执行,后面的赋值语句就能够先执行了。并且 process.nextTick() 的回调会在 eventLoop 进入下一个阶段前调用。(译注:又是把一个道理翻来覆去地讲)
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', () => {});
复制代码
.listen(8080) 这句话是同步执行的。问题在于 listening 回调没法被触发,由于 listening 的监听代码在 .listen(8080) 的后面。
为了解决这个问题,.listen() 函数可使用 process.nextTick() 来执行 listening 事件的回调。
这两个函数功能很像,并且名字也很使人疑惑。
process.nextTick() 的回调会在当前 event loop 阶段「当即」执行。 setImmediate() 的回调会在后续的 event loop 周期(tick)执行。
(译注:看起来名字叫反了)
两者的名字应该互换才对。process.nextTick() 比 setImmediate() 更 immediate(当即)一些。
这是一个历史遗留问题,并且为了保证向后兼容性,也不太可能获得改善。因此就算这两个名字听起来让人很疑惑,也不会在将来有任何变化。
咱们推荐开发者在任何状况下都使用 setImmediate(),由于它的兼容性更好,并且它更容易理解。
There are two main reasons: 使用的理由有两个:
为了让代码更合理,咱们可能会写这样的代码:
const server = net.createServer();
server.on('connection', (conn) => { });
server.listen(8080);
server.on('listening', () => { });
复制代码
假设 listen() 在 event loop 一启动的时候就执行了,而 listening 事件的回调被放在了 setImmediate() 里,listen 动做是当即发生的,若是想要 event loop 执行 listening 回调,就必须先通过 poll 阶段,当时 poll 阶段有可能会停留,以等待链接,这样一来就有可能出现 connect 事件的回调比 listening 事件的回调先执行。(译注:这显然不合理,因此咱们须要用 process.nextTick)
再举一个例子,一个类继承了 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', () => {
console.log('an event occurred!');
});
复制代码
你不能直接在构造函数里执行 this.emit('event'),由于这样的话后面的回调就永远没法执行。把 this.emit('event') 放在 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(() => {
this.emit('event');
});
}
util.inherits(MyEmitter, EventEmitter);
const myEmitter = new MyEmitter();
myEmitter.on('event', () => {
console.log('an event occurred!');
});
复制代码