本篇文章翻译自 Node.js 官网的同名文章也算是经典老物了, 不过官网的文章也随着 Node.js 的演化在修改, 这篇文章最后的编辑时间是 2019年9月10日请注意时效性, 地址在文章的最后有给出.javascript
首次翻译英语水平有限, 错误之处还请多多指教.html
事件循环容许node.js执行非阻塞I/O操做. 虽然 JavaScript 是单线程的, 可是事件循环会尽量的将操做转移到系统内核中来完成.java
现代的操做系统内核都是多线程的, 它们能够在后台处理多种操做. 一旦这些操做完成, 系统内核会通知 Node.js 以便将事件回调放入轮询队列中等待执行. (咱们会在随后的内容讨论它们的具体工做细节)node
当 Node.js 启动的时候, 他会初始化事件循环, 处理输入的脚本内容 (或者进入 REPL), 脚本可能会调用异步接口, 设置定时器, 或者调用 process.nextTick()
, 而后开始处理事件循环(eventloop).npm
下面的简图中展现了事件循环的操做流程:segmentfault
┌───────────────────────┐ ┌─>│ timers │ │ └──────────┬────────────┘ │ ┌──────────┴────────────┐ │ │ I/O callbacks │ │ └──────────┬────────────┘ │ ┌──────────┴────────────┐ │ │ idle, prepare │ │ └──────────┬────────────┘ ┌───────────────┐ │ ┌──────────┴────────────┐ │ incoming: │ │ │ poll │<─────┤ connections, │ │ └──────────┬────────────┘ │ data, etc. │ │ ┌──────────┴────────────┐ └───────────────┘ │ │ check │ │ └──────────┬────────────┘ │ ┌──────────┴────────────┐ └──┤ close callbacks │ └───────────────────────┘
每个方框表明了事件循环中不一样的阶段(全部阶段执行完成算是一次事件循环).
每个阶段都有一个由回调组成的 FIFO 队列被用于执行. 虽然不一样的队列执行方式不一样, 总的来看, 当事件循环进入该阶段后会执行该阶段对应的操做, 而后调用对应的回调直到队列耗尽或者达到了回调执行上限. 在到达上述状况后事件循环进入下一阶段, 而后继续这样的流程.api
因为处理单个操做可能会产生新的操做以及在轮询阶段产生的新事件会被内核排队, 在轮询事件(poll events)的过程当中轮询事件会被排队. 所以, 执行一个长耗时的回调会超出在轮询阶段设定的定时器的阈值.浏览器
Windows and the Unix/Linux 平台略有差异, 可是这不影响咱们的讨论. 咱们最关心的是 Node.js 实际执行的那部分也就是上面的内容.
setTimeout()
和 setInterval()
设定的回调.setImmediate
回调都会在这里执行), node会在适当条件下在这里阻塞.setImmediate
回调将会在次执行.socket.on('close', ...)
.Node 会在两次完整的事件循环间检查是否存在 I/O 操做和或者 timer, 若是没有就会退出执行.bash
timer(计时器) 指定了执行给定回调的阈值时间, 而不是人们所想的准确执行时间. 定时器回调将会在指定的时间到达后尽快的执行, 不过 timer 的执行会受到操做系统调度和其余回调执行的影响被延后.多线程
从技术上讲, 决定是否执行 timer 回调是在轮询阶段控制的, 在 timer 阶段才会执行这些回调.
举例来讲, 你制定了一个延时 100ms 的 timer, 而后异步进行读取文件花费了 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. 当回调执行完成, 轮询队列中没有任何内容了, 此时事件循环会看到已经到达阈值的 timer, 而后在 timer阶段去执行回调. 因此在这个例子中的延时函数会在 105ms 后执行.
为了防止事件循环被长时间空置, libuv 有一个最大限值(取决于操做系统)用于限制轮询队列的执行次数.
系统操做例如: TCP类型错误执行回调会安排在这个阶段执行. 例如当尝试 TCP 链接的时候接收到了一个 ECONNREFUSED
错误, 有些 *nix 系统会进行等待而不是当即抛出错误. 这些回调会被添加到队列中在 pending callbacks
阶段执行.
事件轮询阶段主要有两大功能:
当事件轮询到了 poll 阶段的时候发现没有计时器到达阈值, 此时会发生两种状况:
若是轮询队列为空, 此时
setImmediate()
任务, 事件循环会结束轮询阶段直接跳入 check 阶段去执行那些 setImmediate()
任务.setImmediate()
任务, 事件循环会在轮询阶段等待新的任务被添加到轮询队列中, 而后当即处理这些添加进来的任务.轮询队列为空后, 事件循环将检查已达到时间阈值的计时器. 若是有计时器到达阈值, 事件循环会移动到 timer 阶段而后执行那些计时器回调.
这个阶段容许在轮询阶段完成后执行回调. 若是轮询阶段进入等待, 而且有被 setImmediate()
设定的回调, 那么事件循环有可能会移动到 check 阶段而不是继续在轮询阶段等待.
setImmediate()
其实是一个特殊的计时器, 在事件循环的一个单独阶段中执行. 它经过 libuv API 在轮询阶段结束后执行由 setImmediate()
设定的回调.
一般来讲, 随着代码的运行事件循环终将进入事件轮询阶段并在此等待链接的传入或者请求等. 可是若是存在使用 setImmediate()
设定的任务且时间轮询进入了等待(idle 阶段), 事件循环会进入到 check 阶段而不是继续等待下去.
若是 socket 或者 handle 忽然的关闭, 它们的 close
事件会在这个阶段执行. 不然它会经由 process.nextTick()
执行.
setImmediate()
vs setTimeout()
setImmediate()
和 setTimeout()
很像, 但根据调用时机的差别它们的行为方式有所区别.
setImmediate()
被设计在当前的事件轮询阶段(poll phase)结束后执行脚本一次.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
译者说明: 在脚本执行首次执行完成后, setImmediate
和 setTimeout
被添加到了事件循环中. 在第二轮事件循环中若是进程性能通常已经到达 timer 的阈值了就会在 timer 阶段执行定时器任务, 随后执行 setImmediate
设定的任务. 若是线程性能足够就会由于不够计时器阈值跳过 timer 阶段去执行 setImmediate
设定的任务.
可是若是你将这两个计时器移动到 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
译者说明: 文件操做是 I/O操做实在 poll 阶段执行的, 回调执行完成后 poll 队列是空着的, 此时 timer 已经在 poll 阶段被设定完成(timer 阶段执行), 此时存在 setImmediate
任务因此直接进入到了 check 阶段.
使用 setImmediate
的优势是始终在定时器前执行(在 I/O循环中), 而无论设置了多少个定时器.
process.nextTick()
你可能注意到了 process.nextTick()
没有出如今以前的图中, 虽然它是异步 API 的组成部分. 从技术角度来看 process.nextTick()
并非事件循环的一部分. nextTickQueue
老是在当前操做执行完成后执行
水平有限, 有关 "操做" 的定义在原文以下:Here, an operation is defined as a transition from the underlying C/C++ handler, and handling the JavaScript that needs to be executed.
回到刚才的流程图上, 你能够在图上的任意阶段执行 process.nextTick()
, 全部经过 process.nextTick()
注册的回调都会在事件循环进入到下一个阶段前处理. 这种设计会形成一些很差的状况, 若是你递归调用 process.nextTick()
他会 "饿死" I/O, 由于这会阻止事件循环进入到事件轮询阶段.
为何容许这样的设计?
为何这样的设计被包含到了 Node.js 中?这是 Node.js 设计理念的一部分, 接口永远应该是异步的即便是它同步也没有问题, 举例来讲:
function apiCall(arg, callback) { if (typeof arg !== 'string'){ return process.nextTick( callback, new TypeError('argument should be string') ); } }
这段代码会对参数进行检查当类型错误会抛出 error. process.nextTick()
在最近的更新中容许传入参数, 而后将参数传入到回调中而没必要嵌套一个函数来包装实现相似的功能.
上段代码中咱们会向用户通知错误, 可是只有用户的代码执行完成后这个错误才会被执行. 借助于 process.nextTick()
咱们能够确保 apiCall()
调用的 callback
永远在当前用户代码执行完成以后以及在事件循环进入下一阶段前执行代码. 为了达到这一点, JS 调用栈容许展开后当即执行那些给定的回调, 这样作容许用户经过 process.nextTick
建立递归的代码可是不会形成 V8 引擎的栈溢出错误 RangeError: Maximum call stack size exceeded from v8
.
水平有限, 原文以下:To achieve this, the JS call stack is allowed to unwind then immediately execute the provided callback which allows a person to make recursive calls to
process.nextTick()
without reaching aRangeError: Maximum call stack size exceeded from v8
.
这种理念可能会致使问题出现, 举例来讲:
let bar; // 这是拥有异步接口设计的函数, 在其内部确实同步的 function someAsyncApiCall(callback) { callback(); } // 内部的回调会在 someAsyncApiCall 执行完成前调用 someAsyncApiCall(() => { // 因为 someAsyncApiCall 当即执行, 此时的 bar 还未被指定值 console.log('bar', bar); // undefined }); bar = 1;
用户定义了一个拥有异步接口的函数, 可是内部倒是同步的. 当提供给 someAsyncApiCall
的回调执行后, 回调和 someAsyncApiCall
在事件循环的执行阶段是同样的, 由于 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')
回掉还未被注册.
经过使用 nextTick()
来将内部的 listening
事件排队, 让脚本有机会去执行完成. 这才可让用户去注册它们想要的监听器.
process.nextTick()
vs setImmediate()
对于使用者来讲这两个接口的功能是相似的, 可是它们的名称却使人难以琢磨.
process.nextTick()
在事件循环的某个阶段中所有执行setImmediate()
在事件循环的随后的迭代中触发原文:
process.nextTick()
fires immediately on the same phasesetImmediate()
fires on the following iteration or 'tick' of the event loop
从本质上看, 它们应该交换名称. process.nextTick()
从调用到出发所花费的时间比 setImmediate()
还要短, 可是这个坑已经被埋了过久了很难再被修复了. 若是要是修改命名会让 npm 上的大部分包挂掉. 随着 npm 上的包愈来愈多尝试修复的代价也愈来愈高. 虽然命名有问题, 可是也没法修改了.
咱们开发者在全部的状况下都使用 setImmediate
由于它更加容易推理(也可让代码更具兼容性, 好比在浏览器中运行).
process.nextTick()
?主要缘由有两个:
有一个符合用户预期的例子:
const server = net.createServer(); server.on('connection', (conn) => { }); server.listen(8080); server.on('listening', () => { });
假设事件循环中第一个运行的是 listen()
, 可是用于监听的回调是使用 setImmediate
设置的. 除非主机名称已经被传入, 不然将当即绑定到端口. 要使事件循环继续, 它必须进入到轮询阶段. 这意味着在 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', () => { 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!'); });
The Node.js Event Loop, Timers, and process.nextTick()由setTimeout和setImmediate执行顺序的随机性窥探Node的事件循环机制