虽然js是单线程的,但是事件循环会尽可能地将异步操作(offloading operations)托付给系统内核,让node能够执行非阻塞的I/O操作
由于大多数现代内核都是多线程的,因此它们可以处理在后台执行的多个操作。当其中任意一个任务完成后,内核都会通知Node.js,以保证将相对应的回调函数推入poll队列中最终执行。稍后我们将在本文中详细解释这一点。
当Node.js服务启动时,它就会初始化事件循环。每当处理到脚本(或者是放置到REPL执行的代码,本文咱不提及)中异步的API, 定时器,或者调用process.nextTick()
都会触发事件循环,
下图简单描述了事件循环的执行顺序
┌───────────────────────────┐
┌─>│ timers │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ pending callbacks │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ idle, prepare │
│ └─────────────┬─────────────┘ ┌───────────────┐
│ ┌─────────────┴─────────────┐ │ incoming: │
│ │ poll │<─────┤ connections, │
│ └─────────────┬─────────────┘ │ data, etc. │
│ ┌─────────────┴─────────────┐ └───────────────┘
│ │ check │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
└──┤ close callbacks │
└───────────────────────────┘
注: 每个方框都是事件循环的一个阶段
每个阶段都有一个待执行回调函数的FIFO队列, 虽然每个阶段都不尽相同,总体上说,当事件循环到当前阶段时,它将执行特定于该阶段的操作,然后就会执行被压入当前队列中的回调函数, 直到队列被清空或者达到最大的调用上限。 当队列被清空或者达到最大的调用上限时,事件循环就会进入到下一阶段,如此反复。
因为任意阶段的操作都有可能调用更多的任务和触发新的事件,这些事件都最终会由内核推入poll阶段,poll事件可以在执行事件的时候插入队列。所以调用栈很深的回调允许poll阶段运行时间比定时器的阀值更久,详细部分请查看定时器和poll部分的内容。
注:Windows和Unix/Linux实现之间存在细微的差异,但这对于本文来说并不重要,最重要的部分在文中会一一指出。 实际上事件循环一共有七到八个步骤, 但是我们只需要关注Node.js中实际运用到的,也就是上文所诉的内容
setTimeout()
和setInterval()
的回调函数setimmediation()
触发的); node将会在合适的时候阻塞在这里setImmediate()
的回调将会在这里触发socket.on("close", ...)
在任意两个阶段之间,Node.js都会检查是否还有在等待中的异步I/O事件或者定时器,如果没有就会干净得关掉它。
定时器将会在一个特定的时间之后执行相应的回调,而不是在一个通过开发者设置预期的时间执行。定时器将会在超过设定时间后尽早地执行,然而操作系统的调度或者运行的其他回调将会将之滞后。
注: 从技术上讲,poll阶段会控制定时器什么时候执行
比如说,你设定了一个100ms过后执行的定时器,但是你的脚本在刚开始时异步读取文件耗费了95ms:
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队列中的回调,当回调结束,此时在队列中没有其他回调,这个时候事件循环将会看到定时器的阀值已经过了,并且是可以尽快执行的时机,这个时候回到timers阶段去执行定时器的回调。这样来说,你将会看到定时器从开始调度到被执行间隔105ms。
注: 为了保证poll阶段不出现轮训饥饿,libuv(一个c语言库,由他来实现Node.js的事件循环和所有平台的异步操作)会提供一个触发最大值(取决于系统),在达到最大值过后会停止触发更多事件。
这个阶段将会执行操作系统的一些回调如同TCP的错误捕获一样。比如如果一个TCP 套接字接收到了ECONNREFUSED
在尝试建立链接的时候,一些*nix系统就会上报当前错误,这个上报的回调就会被推入pending callback的执行队列中去。
poll阶段有两个主要的功能:
当事件循环进入到poll阶段并且没有定时器在被调度中的时候,下面两种情况中的一种会发生:
setImmediate()
,那么事件循环将会结束poll阶段然后继续到check阶段去执行setImmediate()
的回调setImmediate()
, 那么事件循环将等待回调被推入队列,然后立即执行它一旦poll阶段队列为空事件循环将会检查是否到达定时器的阀值,如果有定时器准备好了,那么事件循环将会回到timers阶段去执行定时器的回调
这个阶段允许开发者在poll阶段执行完成后立即执行回调函数。 如果poll阶段变为空闲状态并且还有setImmediate()
回调,那么事件循环将会直接来到check阶段而不是继续在poll阶段等待
setImmediate()
实际上是运行在事件循环各个分离阶段的特殊定时器,它直接使用libuv的API去安排回调在poll阶段完成后执行
通常上来说,在执行代码时,事件循环最终会进入轮询阶段,等待传入连接、请求等。但是,如果还有 setImmediate()
回调,并且轮询阶段变为空闲状态,则它将结束并继续到check阶段而不是等待poll事件。
如果一个socket连接突然关闭(比如socket.destroy()),‘close’事件将会被推入这个阶段的队列中,否则它将通过process.nextTick()触发。
setImmediate
和setTimeout
相似,但是他们在被调用的时机上是不同的。
setImmediate
被设计在当前poll阶段完成后执行setTimeout
执行回调是在更会一个最小的阀值过后执行定时器执行的时机依赖于它们被调用时的上下文环境, 如果他们在主模块中同时被调用,那么他们的执行顺序会被程序(被运行在同一台机子上的应用所影响)的性能所约束
举个例子,如果我们在非I/O循环(比如说主模块)中运行以下脚本,它们的执行顺序就是不确定的,也就是说会被程序的性能所约束。
// 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循环中去,immediate
总是会先执行。
// 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()
的主要优点是setImmediate()
将始终在任何定时器之前执行(如果在I / O周期内调度),与存在多少定时器无关。
process.nextTick()
你可能注意到了process.nextTick()
不在上面展示的图示里,甚至它不是一个异步调用API,从技术上说,process.nextTick()
并不属于事件循环。 相反的,nextTickQueue
会在当前的操作执行完成后运行,而不必在乎是在某一个特定的阶段
回到我的图示,每次你在一个阶段中调用process.nextTick()
的时候,所有的回调都会在事件循环进入到下一个阶段的时候被处理完毕。但是这会造成一个非常坏的情况,那就是饥饿轮训,即递归调用你的process.nextTick()
,这样就会阻止事件循环进入到poll阶段
为什么这样的事情会包含在 Node.js 中?设计它的初衷是这个API 应该始终是异步的,即使它不必是。以此代码段为例:
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.
这种理念可能会导致一些潜在的问题,比如下面的代码:
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()
,脚本就可以按照我们预想的执行,它允许变量,函数等先在回调执行之前被声明。 它还有个好处是可以阻止事件循环进入到下一个阶段,这会在进入下一个事件循环前抛出错误时很有用。代码如下:
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', () => {});
只有端口空闲时,端口才会立即被绑定,可以调用 'listening' 回调。问题是 .on('listening')
回调将不会在那个时候执行。
为了解决这个问题,'listening' 事件在 nextTick() 中排队,以允许脚本运行到完成阶段。这允许用户设置所需的任何事件处理程序。
process.nextTick()
对比 setImmediate()
就用户而言我们有两个类似的调用,但它们的名称令人费解。
实质上,应该交换名称。process.nextTick() 比 setImmediate() 触发得更直接,但这是过去遗留的,所以不太可能改变。进行此操作将会破坏 npm 上的大部分软件包。每天都有新的模块在不断增长,如果这样做了,这意味着我们每天都会有的潜在破损在增长。 虽然他们很迷惑,但名字本身不会改变。
我们建议开发人员在所有情况下都使用 setImmediate()
,因为它更让人理解(并且它导致代码与更广泛的环境,如浏览器 JS 所兼容。)
process.nextTick()
主要有两个原因:
下面就是一个符合用户预期的例子:
const server = net.createServer();
server.on('connection', (conn) => { });
server.listen(8080);
server.on('listening', () => { });
假设 listen() 在事件循环开始时运行,但回调被放置在 setImmediate()中。除非通过主机名,否则将立即绑定到端口。事件循环进行时,会命中轮询阶段,这意味着可能会收到连接请求,从而允许在回调事件之前激发连接事件。
另一个示例运行的函数继承于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!');
});
这里并不能立即从构造函数中触发event
事件。因为在此之前用户并没有给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!');
});