这两天系统地学习了一下nodejs中的时间循环机制。这篇Post将把其基本内容以及我当时遇到的问题都记录下来。Note:为避免理解冲突,将在官方文档的例子上进行理解。
基础knowledge
nodejs的event loop是javascript实现非阻塞IO的手段。
node的整体结构
借用一张图:
nodejs由c/c++库(主要为libuv依赖、v8实现部分和其它)和js实现的核心库。
node的事件循环
既然本篇主要讨论事件循环,基本理解一下node中事件循环与js代码的对应关系:
这里只补充一下pending IO callback 这一phase。这个阶段主要处理一些IO操作的回调,比如读文件的回调,网络请求完成的回调等(排除任何close回调)。
每个phase有一个类似FIFO的堆栈。当运行到这个phase时,如果有回调的话,会从这个回调堆栈中取出所有回调来执行,或者是到达了该phase的最大执行数限制,接着进入下一phase。
nodejs代码运行流程
从main.js开始运行主程序代码,接着判断是否event loop结束(如果事件循环一轮都没有任何回调了,说明可以终止进程了)。有的话,会从timer phase开始进行event loop。
nodejs event loop基础
上图是nodejs官网的图。表明event loop由以上phase组成:
- timer:定时器,用于执行setTimeout和setInterval定义的回调函数。从技术上来讲,该阶段通常由poll阶段控制。(注意这句话,后面有用)
- pending callbacks:上面已经说过了,用于执行一些IO回调。
- idle, prepare:内部使用,不讨论。
- poll:这是一个灰常重要的phase。这个阶段可能会得到新的IO事件,执行IO相关的回调,以及timer、setImmediate定义的回调等。总之功能多多。nodejs可能会在这个phase blocking住进程。
- check:这个phase用于检查是否有setImmediate定义的回调,并一次性清空整个栈。
- close callbacks:用于执行各种 close 事件。
event loop detail
timer
首先要搞清楚的是,定义的setTimeout的函数,并不一定在所定义的毫秒数到来时被准时执行(严格意义来讲,都是晚于该毫秒数)。为什么呢?对于timer phase来说,其工作流程大致如下:主程序执行完毕,进入event loop的timer phase。生成一个当前系统时间的格林时间毫秒数,再检查是否有timer,如果有,筛选出所有timer中满足毫秒数的回调,依次执行。
这里官网举了一个读文件的例子,我就不重复说了,说个大概:
function (callback) { 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的值。}, 100);someAsyncOperation(() => { const startCallback = Date.now(); // 手动把cpu给hang住10ms while (Date.now() - startCallback < 10) { // do nothing }}); |
整个执行流程(最后的粗体数值表示启动程序后的相对毫秒数):
- 定义一个setTimeout 100ms的回调;0ms
- 执行someAsyncOperation异步读文件操作;0ms
- 主程序执行完毕,进入event loop;0ms;
- 大概95ms后,在poll阶段收到读文件的添加回调,并执行,导致花费10ms;105ms
- 在poll的队列空后,检查timer,存在,执行。105ms
因此,虽然定义的100ms后的回调,但仍然可能在大于这个值后才执行。
Note: 为了防止poll phase一直阻塞event loop,libuv对有poll接受更多事件的限制。
pending callbacks
用于执行一些系统操作,比如tcp错误等,或者执行一些IO回调,比如读文件完成、网络请求返回等。
poll
主要有两个作用:
- 计算block住IO多久;
- 处理poll队列中的事件。
当timer队列为空时,会:
- 如果poll queue不为空,则同步执行全部或到达最大限制;
- 为空,则会:
- 有setImmediate的回调,则结束poll phase,进入check phase;
- 没有setImmediate的回调,则等待回调被添加,然后执行。
一旦poll queue为空了(可能是一进入就为空,或者全部执行了queue而为空),会检查timer是否有到时间的回调。如果有,就回退到timer phase去执行。
check
在poll阶段,当有setImmediate的回调,并且poll变的空闲时,会结束poll,进入check,来执行这些setImmediate定义的回调。
close callbacks
当一个socket或者其它事件处理异常关闭时,close event会在这个phase触发。否则它将通过process.nextTick发出。
一些关注点
setImmediate() runs before setTimeout(fn, 0)?
要看场景。如果是这么调用的:
setTimeout(function(){ console.log("SETTIMEOUT");});setImmediate(function(){ console.log("SETIMMEDIATE");}); |
那么输出就不一定是哪个先。为什么呢?分析一下:
- 主程序添加timer;
- 主程序添加setImmediate;
- 进入event loop;
- 进入timer phase。得到系统时间,对比一下该timer延迟毫秒数->0,系统运行所经历的毫秒数:
- 如果 = 0(PS:使用Date.now()获取的值为格林时间毫秒数,意思是精确到毫秒级,不能再往下了),nodejs判断是否到达调用时间,不会按照是否等于毫秒数,而是是否大于该毫秒数,大于则执行,否则不执行。所以不执行该timer;
- 如果 > 0 (可能系统资源被其它进程占用太多,导致cpu调度比较晚),那么该timer执行。
由以上分析,能看出在非IO中为什么执行顺序不一致了。如果放在IO中:
require('fs').readFile('file.txt', () => { setTimeout(() => console.log(1)); setImmediate(() => console.log(2));}); |
那么不管setImmediate和setTimeout是什么顺序书写,都会是setImmediate先执行:
- 主程序执行读文件;
- 进入event loop;
- 进入pending phase,文件读完,执行匿名回调–添加一个timer和setImmediate;
- 由于check phase在timer phase前面(以步骤3所在的pending phase为开始),所以天然的setImmediate先执行。
能用setTimeout(…, 0) 代替 setImmediate?
不考虑性能的话,可以。但实际上,timer维护了一个队列,添加、执行timer都设计到队列的维护;而setImmediate只是简单地将队列清空。(timer貌似实现上是二叉树,性能消耗也不小)
process.nextTick()是啥?
event loop中没有对nextTick作说明。根据文档:nextTickQueue will be processed after the current operation completes, regardless of the current phase of the event loop。类似setImmediate,它也有个队列,也会一次性清空队列,但它的执行时机是在当前phase都执行完毕后,在进入下一phase之前。官网的说法:process.nextTick和setImmediate其实应该互换名称,只是考虑到现在基于这个命名的应用太多,根本不可能改。
原文:大专栏 nodejs事件循环