[NodeJs系列][译]理解NodeJs中的Event Loop、Timers以及process.nextTick()
译者注:
- 为什么要翻译?其实在翻译这篇文章前,笔者有Google了一下中文翻译,看的不是很明白,所以才有自己翻译的打算,当然能力有限,文中或有错漏,欢迎指正。
- 文末会有几个小问题,大家不妨一起思考一下
- 欢迎关注微信公众号:前端情报局-NodeJs系列
什么是Event loop?
尽管JavaScript是单线程的,通过Event Loop使得NodeJs能够尽可能的通过卸载I/O操作到系统内核,来实现非阻塞I/O的功能。
由于大部分现代系统内核都是多线程的,因此他们可以在后台执行多个操作。当这些操作中的某一个完成后,内核便会通知NodeJs,这样(这个操作)指定的回调就会添加到poll
队列以便最终执行。关于这个我们会在随后的章节中进一步说明。
Event Loop解析
当NodeJs启动时,event loop 随即会被初始化,而后会执行对应的输入脚本(直接把脚本放入REPL执行不在本文讨论范围内),这个过程中(脚本的执行)可能会存在对异步API的调用,产生定时器或者调用process.nextTick()
,接着开始event loop。
译者注:这段话的意思是NodeJs优先执行同步代码,在同步代码的执行过程中可能会调用到异步API,当同步代码和
process.nextTick()
回调执行完成后,就会开始event loop
下图简要的概述了event loop的操作顺序:
┌───────────────────────┐
┌─>│ timers │
│ └──────────┬────────────┘
│ ┌──────────┴────────────┐
│ │ I/O callbacks │
│ └──────────┬────────────┘
│ ┌──────────┴────────────┐
│ │ idle, prepare │
│ └──────────┬────────────┘ ┌───────────────┐
│ ┌──────────┴────────────┐ │ incoming: │
│ │ poll │<─────┤ connections, │
│ └──────────┬────────────┘ │ data, etc. │
│ ┌──────────┴────────────┐ └───────────────┘
│ │ check │
│ └──────────┬────────────┘
│ ┌──────────┴────────────┐
└──┤ close callbacks │
└───────────────────────┘
注:每一个框代表event loop中的一个阶段
每个阶段都有一个FIFO(先进先出)的回调队列等待执行。虽然每个阶段都有其独特之处,但总体而言,当event loop进入到指定阶段后,它会执行该阶段的任何操作,并执行对应的回调直到队列中没有可执行回调或者达到回调执行上限,而后event loop会进入下一阶段。
由于任何这些阶段的操作可能产生更多操作,内核也会将新的事件推入到poll阶段的队列中,所以新的poll事件被允许在处理poll事件时继续加入队,这也意味着长时间运行的回调可以允许poll阶段运行的时间比计时器的阈值要长
注意:Windows和Unix/Linux在实现上有些差别,但这对本文并不重要。事实上存在7到8个步骤,但以上列举的是Node.js中实际使用的。
阶段概览
- timers:执行的是
setTimeout()
和setInterval()
的回调 - I/O callbacks:执行除了 close callbacks、定时器回调和
setImmediate()
设定的回调之外的几乎所有回调 - idle, prepare:仅内部使用
- poll:接收新的I/O事件,适当时node会阻塞在这里(==什么情况下是适当的?==)
- check:
setImmediate
回调在这里触发 - close callbacks:比如
socket.on('close', ...)
在每次执行完event loop后,Node.js都会检查是否还有需要等待的I/O或者定时器没有处理,如果没有那么进程退出。
阶段细节
timers
一个定时器会指定阀值,并在达到阀值之后执行给定的回调,但通常来说这个阀值会超过我们预期的时间。定时器回调会尽可能早的执行,不过操作系统的调度和其他回调的执行时间会造成一定的延时。
注:严格意义上说,定时器什么时候执行取决于poll阶段
举个例子,假定一个定时器给定的阀值是100ms,异步读取文件需要95ms的时间
const fs = require('fs');
function someAsyncOperation(callback) {
// 假定这里花费了95ms
fs.readFile('/path/to/file', callback);
}
const timeoutScheduled = Date.now();
setTimeout(function() {
const delay = Date.now() - timeoutScheduled;
console.log(delay + 'ms have passed since I was scheduled');
}, 100);
// 95ms后异步操作才完成
someAsyncOperation(function() {
const startCallback = Date.now();
// 这里花费了10ms
while (Date.now() - startCallback < 10) {
// do nothing
}
});
就本例而言,当event loop到达poll阶段,它的队列是空的(fs.readFile()
还未完成),因此它会停留在这里直到达到最早的定时器阀值。fs.readFile()
花费了95ms读取文件,之后它的回调被推入poll队列并执行(执行花了10ms)。回调执行完毕后,队列中已经没有其他回调需要执行了,那么event loop就会去检查是否有定时器的回调可以执行,如果有就跳回到timer阶段执行相应回调。在本例中,你可以看到从定时器被调用到其回调被执行一共耗时105ms。
注:为了防止event loop一直阻塞在poll阶段,libuv(http://libuv.org/ 这是用c语言实现了Node.js event loop以及各个平台的异步行为的库)会指定一个硬性的最大值以阻止更多的事件被推入poll。
I/O callbacks阶段
这个阶段用于执行一些系统操作的回调,比如TCP错误。举个例子,当一个TCP socket 在尝试连接时接收到ECONNREFUSED
的错误,一些*nix系统会想要得到这些错误的报告,而这都会被推到 I/O callbacks中执行。
poll阶段
poll阶段有两个功能:
- 执行已经达到阀值的定时器脚本
- 处理在poll队列中的事件
当event loop进入到poll阶段且此代码中为设定定时器,将会发生下面情况:
- 如果poll队列非空,event loop会遍历执行队列中的回调函数直到队列为空或达到系统上限
如果poll队列是空的,将会发生下面情况:
- 如果脚本中存在对
setImmediate()
的调用,event loop将会结束poll阶段进入check阶段并执行这些已被调度的代码 - 如果脚本中不存在对
setImmediate()
的调用,那么event loop将阻塞在这里直到有回调被添加进来,新加的回调将会被立即执行
- 如果脚本中存在对
一旦poll队列为空,event loop就会检查是否有定时器达到阀值,如果有1个或多个定时器符合要求,event loop将将会回到timers阶段并执行改阶段的回调.
check阶段
一旦poll阶段完成,本阶段的回调将被立即执行。如果poll阶段处于空闲状态并且脚本中有执行了setImmediate()
,那么event loop会跳过poll阶段的等待进入本阶段。
实际上setImmediate()
是一个特殊的定时器,它在事件循环的一个单独阶段运行,它使用libuv API来调度执行回调。
通常而言,随着代码的执行,event loop最终会进入poll阶段并在这里等待新事件的到来(例如新的连接和请求等等)。但是,如果存在setImmediate()
的回调并且poll阶段是空闲的,那么event loop就会停止在poll阶段漫无目的的等等直接进入check阶段。
close callbacks阶段
如果一个socket或者handle突然关闭(比如:socket.destory()
),close
事件就会被提交到这个阶段。否则它将会通过process.nextTick()
触发
setImmediate() 和 setTimeout()
setImmediate
和setTimeout()
看起来是比较相似,但它们有不同的行为,这取决于它们什么时候被调用。
setImmediate()
被设计成一旦完成poll阶段就会被立即调用setTimeout()
则是在达到最小阀值是才会被触发执行
其二者的调用顺序取决于它们的执行上下文。如果两者都在主模块被调用,那么其回调被执行的时间点就取决于处理过程的性能(这可能被运行在同一台机器上的其他应用影响)
比如说,如果下列脚本不是在I/O循环中运行,这两种定时器运行的顺序是不一定的(==这是为什么?==),这取决于处理过程的性能:
// timeout_vs_immediate.js
setTimeout(function timeout() {
console.log('timeout');
}, 0);
setImmediate(function immediate() {
console.log('immediate');
});
$ node timeout_vs_immediate.js
timeout
immediate
$ node timeout_vs_immediate.js
immediate
timeout
但是如果你把上面的代码置于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
使用setImmediate()
而不是setTimeout()
的主要好处是:如果代码是在I/O循环中调用,那么setImmediate()
总是优先于其他定时器(无论有多少定时器存在)
process.nextTick()
理解process.nextTick()
你可能已经注意到process.nextTick()
不在上面的图表中,即使它也是异步api。这是因为严格意义上来说process.nextTick()
不属于event loop中的一部分,它会忽略event loop当前正在执行的阶段,而直接处理nextTickQueue
中的内容。
回过头看一下图表,你在任何给定阶段调用process.nextTick()
,在继续event loop之前,所有传入process.nextTick()
的回调都会被执行。这可能会导致一些不好的情况,因为它允许你递归调用process.nextTick()
从而使得event loop无法进入poll阶段,导致无法接收到新的 I/O事件
为什么这会被允许?
那为什么像这样的东西会被囊括在Node.js?部分由于Node.js的设计理念:API应该始终是异步的即使有些地方是没必要的。举个例子:
function apiCall(arg, callback) {
if (typeof arg !== 'string')
return process.nextTick(callback,
new TypeError('argument should be string'));
}
这是一段用于参数校验的代码,如果参数不正确就会把错误信息传递到回调。最近process.nextTick()
有进行一些更新,使得我们可以传递多个参数到回调中而不用嵌套多个函数。
我们(在这个例子)所做的是在保证了其余(同步)代码的执行完成后把错误传递给用户。通过使用process.nextTick()
我们可以确保apiCall()
的回调总是在其他(同步)代码运行完成后event loop
开始前调用的。为了实现这一点,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()
(函数名可以看出),但实际上操作是同步的。当它被调用时,其回调也在event loop中的同一阶段被调用了,因为someAsyncApiCall()
实际上并没有任何异步动作。结果,在(同步)代码还没有全部执行的时候,回调就尝试去访问变量bar
。
通过把回调置于process.nextTick()
,脚本就能完整运行(同步代码全部执行完毕),这就使得变量、函数等可以先于回调执行。同时它也有阻止event loop继续执行的好处。有时候我们可能希望在event loop继续执行前抛出一个错误,这种情况下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', () => {});
当只有一个端口作为参数传入,端口会被立即绑定。所以监听回调可能被立即调用。问题是:on('listening')
回调在那时还没被注册。
为了解决这个问题,把listening
事件加入到nextTick()
队列中以允许脚本先执行完(同步代码)。这允许用户(在同步代码中)设置任何他们需要的事件处理函数。
process.nextTick() 和 setImmediate()
对于用户而言,这两种叫法是很相似的但它们的名字又让人琢磨不透。
process.nextTick()
会在同一个阶段执行setImmediate()
会在随后的迭代中执行
本质上,这两个的名字应该互换一下,process.nextTick()
比setImmediate()
更接近于立即,但是由于历史原因这不太可能去改变。名字互换可能影响大部分的npm包,每天都有大量的包在提交,这意味这越到后面,互换造成的破坏越大。所以即使它们的名字让人困惑也不可能被改变。
我们建议开发者在所有情况中使用setImmediate()
,因为这可以让你的代码兼容更多的环境比如浏览器。
为什么要使用process.nextTick()?
这里又两个主要的原因:
- 让开发者处理错误、清除无用的资源或者在event loop继续之前再次尝试重新请求资源
- 有时需要允许回调在调用栈展开之后但在事件循环继续之前运行
下面这个例子会满足我们的期望:
const server = net.createServer();
server.on('connection', function(conn) { });
server.listen(8080);
server.on('listening', function() { });
假设listen()
是在event loop开始前运行,但是监听回调是包裹在setImmediate
中,除非指定hostname参数否则端口将被立即绑定(listening
回调被触发),event loop必须要执行到poll阶段才会去处理,这意味着存在一种可能:在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', function() {
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(function() {
this.emit('event');
}.bind(this));
}
util.inherits(MyEmitter, EventEmitter);
const myEmitter = new MyEmitter();
myEmitter.on('event', function() {
console.log('an event occurred!');
});
译者注(Q&A)
翻译完本文,笔者给自己提了几个问题?
- poll阶段什么时候会被阻塞?
- 为什么在非I/O循环中,
setTimeout
和setImmediate
的执行顺序是不一定的? - JS调用栈展开是什么意思?
- 为什么
process.nextTick()
可以被递归调用?
笔者将在之后的文章[《Q&A之理解NodeJs中的Event Loop、Timers以及process.nextTick()》]()探讨这些问题,有兴趣的同学可以关注笔者的公众号: 前端情报局-NodeJs系列获取最新情报
来源:https://segmentfault.com/a/1190000017920493
[NodeJs系列][译]理解NodeJs中的Event Loop、Timers以及process.nextTick()的更多相关文章
- The Node.js Event Loop, Timers, and process.nextTick() Node.js事件循环,定时器和process.nextTick()
个人翻译 原文:https://nodejs.org/en/docs/guides/event-loop-timers-and-nexttick/ The Node.js Event Loop, Ti ...
- The Node.js Event Loop, Timers, and process.nextTick()
The Node.js Event Loop, Timers, and process.nextTick() | Node.js https://nodejs.org/uk/docs/guides/e ...
- Node.js Event Loop 的理解 Timers,process.nextTick()
写这篇文章的目的是将自己对该文章的理解做一个记录,官方文档链接The Node.js Event Loop, Timers, and process.nextTick() 文章内容可能有错误理解的地方 ...
- 不要在nodejs中阻塞event loop
目录 简介 event loop和worker pool event loop和worker pool中的queue 阻塞event loop event loop的时间复杂度 Event Loop中 ...
- node.js中对Event Loop事件循环的理解
javascript是单线程的,所以任务的执行都需要排队,任务分为两种,一种是同步任务,一种是异步任务. 同步任务是进入主线程上排队执行的任务,上一个任务执行完了,下一个任务才会执行. 异步任务是不进 ...
- 详解JavaScript中的Event Loop(事件循环)机制
前言 我们都知道,javascript从诞生之日起就是一门单线程的非阻塞的脚本语言.这是由其最初的用途来决定的:与浏览器交互. 单线程意味着,javascript代码在执行的任何时候,都只有一个主线程 ...
- 深入理解Javascript单线程谈Event Loop
假如面试回答js的运行机制时,你可能说出这么一段话:"Javascript的事件分同步任务和异步任务,遇到同步任务就放在执行栈中执行,而碰到异步任务就放到任务队列之中,等到执行栈执行完毕之后 ...
- 为什么JS是单线程?JS中的Event Loop(事件循环)?JS如何实现异步?setimeout?
https://segmentfault.com/a/1190000012806637 https://www.jianshu.com/p/93d756db8c81 首先,请牢记2点: (1) JS是 ...
- 浏览器和Node 中的Event Loop
前言 js与生俱来的就是单线程无阻塞的脚本语言. 作为单线程语言,js代码执行时都只有一个主线程执行任务. 无阻塞的实现依赖于我们要谈的事件循环.eventloop的规范是真的苦涩难懂,仅仅要理解的话 ...
随机推荐
- sass文件处理
sass注释方式有两种: 1.标准的css注释/**/: 2.//双斜杠形式的单行注释(不会被转译): 标准的css注释 双斜杆单行注释 sass文件后缀名有两种: 1.后缀名为sass,不适用用大括 ...
- 五款app原型设计工具对比
五款app原型设计工具对比 Proto.io, Pixate, Origami, Framer & Form 本文由Panblack 翻译,原文作者 Tes Mat 我用五款“高保真”原型设计 ...
- Centos6安装oracle10g
刚刚开始学OCP.第一关,安装,被折腾得稀碎...查询了大量资料,多次失败后终于总结出一些经验,简单整理如下.[2014-12-11 重新整理了一下顺序,修改了脚本!] 需要注意的是:如果想尝试我提供 ...
- 16_Queue_利用wait()和notify()编写一个阻塞队列
[线程间通信概念] 线程是操作系统中独立的个体,但这些个体如果不经过特殊的处理就不能成为一个整体,线程间的通信就成为整体必用方式之一.当线程存在通信指挥,线程间的交互性会更强大,在提高CPU利用率的同 ...
- 管理uWSGI服务器
管理uWSGI服务器 官网参考 如果您正在管理多个应用程序或高容量站点,请查看 uwsgi皇帝-多应用程序部署 虫族模式 UWSGI订阅式服务器 启动uwsgi服务器 以系统管理员身份启动 uwsgi ...
- js原形对象
function clock(hour,minute,second){ this.constructor = clock ;//默认实现 /**/ } clock.prototype={ constr ...
- sudo cat > EOF权限问题
sudo bash -c 'cat << EOF > /etc/yum.repos.d/some-name.repo line1 line2 line3 EOF'
- 推荐一个很好用的脚本session snapper
源网址http://tech.e2sn.com/oracle-scripts-and-tools/session-snapper 内容: If you want to just download Sn ...
- 解决SQLite打开已有路径下的db问题
最近遇到的需要加载已有路径下(sd card下)db的问题,找了一下资料,以下是解决的方法,仅供参考(转载自eoe): SQLiteOpenHelper 是Android框架为我们提供的一个非常好的数 ...
- Matlab Colour Theme
[转]http://blog.csdn.net/df865017/article/details/48164429 使用MATLAB进行编码时, 长时间面对白底黑字的屏幕, 眼睛会疼! 因此, 选择一 ...