nodejs 事件循环
reason
这两天系统地学习了一下 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 中满足毫秒数的回调,依次执行。
这里官网举了一个读文件的例子,我就不重复说了,说个大概:
1 | function someAsyncOperation(callback) { |
整个执行流程(最后的粗体数值表示启动程序后的相对毫秒数):
- 定义一个 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)?
要看场景。如果是这么调用的:
1 | setTimeout(function(){ |
那么输出就不一定是哪个先。为什么呢?分析一下:
- 主程序添加 timer;
- 主程序添加 setImmediate;
- 进入 event loop;
- 进入 timer phase。得到系统时间,对比一下该 timer 延迟毫秒数 ->0,系统运行所经历的毫秒数:
- 如果 = 0(PS:使用 Date.now () 获取的值为格林时间毫秒数,意思是精确到毫秒级,不能再往下了),nodejs 判断是否到达调用时间,不会按照是否等于毫秒数,而是是否大于该毫秒数,大于则执行,否则不执行。所以不执行该 timer;
- 如果 > 0 (可能系统资源被其它进程占用太多,导致 cpu 调度比较晚),那么该 timer 执行。
由以上分析,能看出在非 IO 中为什么执行顺序不一致了。如果放在 IO 中:
1 | require('fs').readFile('file.txt', () => { |
那么不管 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 其实应该互换名称,只是考虑到现在基于这个命名的应用太多,根本不可能改。