译Node.js的eventloop,timers和process.nextTick()
Posted 猫眼前端团队
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了译Node.js的eventloop,timers和process.nextTick()相关的知识,希望对你有一定的参考价值。
“silhouette on person writing” by JR Korpa on Unsplash
什么是事件循环?
事件循环是实现 Node.js 的非阻塞 I/O 操作的机制,在可能的时候将操作交给系统内核,尽管javascript 其实是单线程运行的。
由于大多数系统内核是多线程的,它们可以在后台同时执行多个操作。当其中的一个操作完成后,内核就会通知 Node.js,将合适的回调添加到轮询队列中,并等待时机执行。我们接下来将会在本文中进一步解释。
事件循环机制解析
当启动 Node.js 时,它会初始化 eventloop,处理提供的输入脚本(或者是丢入REPL,本文档中没有涉及到 REPL 相关知识,REPL 详情请查看 https://nodejs.org/api/repl.html#repl_repl)这会使用 async API calls(异步 api 调用),安排定时器,或者调用 process.nextTick(),然后开始处理 eventloop(事件循环)。
下面的表格展示了一个 event loop 执行的操作顺序的简化概述:
注意:每个盒子对应 eventloop 的一个阶段
每个阶段都会执行一个 FIFO(first in first out)的回调队列。虽然每个阶段都有所不同,一般来说,当事件循环进入一个阶段的时候,会执行这个阶段所有的特定操作,然后执行当前阶段的队列中的回调,直到队列执行完,或者是达到回调的最大数量。当一个队列被执行完毕之后或者达到回调的限制,事件循环会移动到下一个阶段,然后继续。
因为任何的这些操作可能会调度更多的操作,并且轮询阶段的新事件被内核插入队列,因此处理轮询事件时,轮询事件可以排队。这会导致长时间执行的回调可能会允许轮询阶段执行时间比定时器的阈值时间更长。更多详情请查看定时器和轮询的相关章节。
注意:在实际调用中,windows 和 Unix 和 Linux 系统会有很小的差异,但是对此次示范操作无关紧要,最重要的地方在这儿。实际上有七八个步骤,但是我们只关心 Node.js 实际上使用的是上面的那些。
阶段概览
timer :此阶段执行由 setTimeout() 和 setInterval() 规划的的回调
pending callbacks:执行延迟到下次循环迭代的 I/O 回调
idle,prepare:仅限内部使用
poll:检索新的 I/O 事件,执行 I/O 相关的回调(几乎全部和关闭回调概念相关,通过 timers 和 setImmediate 进行调度)node 会在此阶段的适当时候阻塞。
check:setImmediate() 回调将会在此时执行
close callbacks:一些执行关闭的函数,例如 socket.on('close', ...)
在每次事件循环执行的中间,Node.js 会检查是否在等待一些定时器或者是异步I/O 操纵,然后没有的话就会彻底关闭。
阶段详情
定时器(timers)
定时器具体说明了在一个被提供的回调被执行之后的时间而不是人像让它执行的确切时间。定时器回调会在声明的时候传入具体的时间之后就开始执行,然而,操作系统线程或者其他回调的执行会是它们延迟。
注意:技术上,轮询阶段控制定时器执行时机。
例如,你声明了一个在 100ms 之后执行的 setTimeout,然后你的脚本开始异步读取文件用掉了 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
}
});
当事件循环进入轮询阶段,会有个空队列(fs.readFile())未完成,所以会用剩余的时间等待,直到接下来的定时器的时间到了。当等待 95ms 之后,fs.readfile() 完成读取文件,并且他的回调回占用 10ms 来完成将它添加到队列并执行。当回调完成,就没有其他回调了,所以事件循环会看到接下来的定时器的阈值时间已经到了,然后绕回定时器阶段来执行定时器的回调。在这个例子中,你会看到,在定时器被声明到他的回调被执行中的延迟时间是 105ms。
注意:为了防止轮询阶段使事件循环等待,libuv(用来执行 Node.js 的事件循环和此平台其他所有的异步行为的 c 语言库)在它停止轮询更多事件之前,也会有一个严格的最大执行次数限制(取决于系统)。
挂起的回调(pending callbacks)
此阶段执行一些系统操作的回调,比如像 TCP 错误之类的。举例来说如果一个TCP socket 当尝试连接时接收到 ECONNREFUSED(链接被拒绝),一些*nix 系统模块希望等待报错,这些就会在挂起的回调阶段执行。
轮询(poll)
轮询阶段由两个主要的函数:
计算需要阻塞多长时间,并且进行 I/O 轮询
然后处理轮询队列中的事件
当事件循环进入轮询阶段并且没有定时器队列,两件事的其中之一会发生:
如果轮询队列不是空的,事件循环将会迭代回调队列,并同步执行它们,直到队列用尽,或者是达到系统最大执行次数限制。
如果轮询队列是空的,还是发生两件事:
如果脚本是用 setImmediate() 来执行的,事件循环将会结束轮询阶段,然后进入检查阶段,并执行队列脚本。
如果脚本没有用 setImmediate(),事件循环将会等待回调被加到队列中,然后立即执行。
一旦轮询队列是空的,事件循环将会检查定时器,看谁的时间快到了,如果一个或者多个定时器准备执行了,事件循环将会绕回到定时器阶段,来执行那些定时器的回调。
检查(check)
这个阶段在轮询阶段结束之后允许人立即执行回调。如果轮询阶段变成空转并且脚本被 setImmdediate() 插入到队列中了,事件循环将会继续检查阶段而不是空等。
setImmdediate() 实际上是一个特殊的定时器,运行在事件循环的一个分开的阶段。它使用了一个libuv API 来安排回调在轮询阶段结束之后执行 。
通常,当代码被执行时,事件循环会最终会命中轮询阶段,并等待即将到来的连接、请求等。但是,如果一个回调被 setImmediate() 安排执行,而且轮询阶段变成空转,就将会结束并且继续进入检查阶段,而不会等待轮询事件。
关闭回调函数(close callbacks)
如果一个 socket 或者 handle 被突然关闭(e.g.socket.destory())"close"事件会在这个阶段释放,否则将会在 process.nextTick() 中释放。
setImmediate() vs setTimeout()
setImmediate 和 setTimeout 很相似,但是调用时机决定了它们不同的表现。
一旦当前的轮询阶段完成,setImmediate() 就会执行一段脚本
setTimeout() 安排一段脚本在毫秒级的最小阈值时间后执行
这两个函数哪个先执行取决当前被调用的上下文。如果两者都在主模块被调用,时间将会与进程的性能相关(这将会被其他在这台机器上跑的应用影响)
例如如果我运行下面这段没有 I/O 循环的脚本(i.e. 主模块)这两个计时器的执行顺序是不确定的,因为受进程的性能影响。
// 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()
你可能会注意到 process.nextTick() 没有在表格中展示,即使它是异步 API 的一部分,这是因为技术上 process.nextTick() 不是事件循环的一部分。并且,nextTickQueue 队列将会在当前操作完成后立即执行,不管当前是事件循环的哪个阶段。
回头看一下我们的表格,任何时候任何阶段当你调用 process.nextTick() 时,所有传给 process.nextTick() 的回调,在事件循环继续之前都将会被标记成已解决。这会造成一些很坏的情况,因为他允许你通过使用 process.nextTick() 做递归调用来使 I/O 进程等待,这会阻止事件循环进入轮询阶段。
为什么会被允许呢?
为什么 node 中会有这种东西呢?其中的一部分是设计理念,即 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() 来获取异步签名,但是实际上却是同步操作的,当被调用时,提供给函数的回调,在同一个事件循环的同一阶段被调用,因为函数没有做任何异步操作。结果,回调试图引用 bar 这个变量,而在作用域中还没有那个变量,因为脚本还没运行完。
通过把回调函数放在一个 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', () => {});
当仅传一个端口时,端口立即被绑定。所以,“listening” 回调将会被立即调用。问题就是 .on('listening') 回调那会儿还没设置。
为了克服这个问题,listening 事件在 nextTick() 中插入队列,这样脚本就能运行完。这样我们可以设置任何事件处理方法。
process.nextTick() vs setImmediate()
我们有两个回调,他们在用户考虑的范围内很相似,但是它们的名字却很令人疑惑。
process.nextTick() 在当前阶段立即执行
setImmediate() 在事件循环的下一次迭代或者是 tick 的时候立即执行
本质上,名字应该互换一下,process.nextTick() 比 setImmediate() 执行更加迅速,但是这是过去人为决定的不太可能更改。如果让它们交换将会破坏很大一部分的 npm 包。每天都有新的包添加到 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!');
});
你不能在构造器里立即触发一个事件,因为脚本不会运行到用户为事件定义回调函数的地方。所以在构造函数内部,你可以使用 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!');
});
原文:https://nodejs.org/en/docs/guides/event-loop-timers-and-nexttick
译者:郭广坤
校对:熊贤仁
以上是关于译Node.js的eventloop,timers和process.nextTick()的主要内容,如果未能解决你的问题,请参考以下文章