nodejs 事件循环

reason

这两天系统地学习了一下 nodejs 中的时间循环机制。这篇 Post 将把其基本内容以及我当时遇到的问题都记录下来。__Note__:为避免理解冲突,将在官方文档的例子上进行理解。

基础 knowledge

nodejs 的 event loop 是 javascript 实现非阻塞 IO 的手段。

node 的整体结构

借用一张图:
nodejs

nodejs 由 c/c++ 库(主要为 libuv 依赖、v8 实现部分和其它)和 js 实现的核心库。

node 的事件循环

既然本篇主要讨论事件循环,基本理解一下 node 中事件循环与 js 代码的对应关系:
对应关系
这里只补充一下 pending IO callback 这一 phase。这个阶段主要处理一些 IO 操作的回调,比如读文件的回调,网络请求完成的回调等(排除任何 close 回调)。

每个 phase 有一个类似 FIFO 的堆栈。当运行到这个 phase 时,如果有回调的话,会从这个回调堆栈中取出所有回调来执行,或者是到达了该 phase 的最大执行数限制,接着进入下一 phase。

nodejs 代码运行流程

nodejs工作流
从 main.js 开始运行主程序代码,接着判断是否 event loop 结束(如果事件循环一轮都没有任何回调了,说明可以终止进程了)。有的话,会从 timer phase 开始进行 event loop。

nodejs event loop 基础

nodejs event loog
上图是 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
function someAsyncOperation(callback) {
// 假设 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的值。
}, 100);

someAsyncOperation(() => {
const startCallback = Date.now();

// 手动把cpu给hang住10ms
while (Date.now() - startCallback < 10) {
// do nothing
}
});

整个执行流程(最后的粗体数值表示启动程序后的相对毫秒数):

  1. 定义一个 setTimeout 100ms 的回调;0ms
  2. 执行 someAsyncOperation 异步读文件操作;0ms
  3. 主程序执行完毕,进入 event loop;0ms
  4. 大概 95ms 后,在 poll 阶段收到读文件的添加回调,并执行,导致花费 10ms;105ms
  5. 在 poll 的队列空后,检查 timer,存在,执行。105ms

因此,虽然定义的 100ms 后的回调,但仍然可能在大于这个值后才执行。

Note: 为了防止 poll phase 一直阻塞 event loop,libuv 对有 poll 接受更多事件的限制。

pending callbacks

用于执行一些系统操作,比如 tcp 错误等,或者执行一些 IO 回调,比如读文件完成、网络请求返回等。

poll

主要有两个作用:

  1. 计算 block 住 IO 多久;
  2. 处理 poll 队列中的事件。

当 timer 队列为空时,会:

  1. 如果 poll queue 不为空,则同步执行全部或到达最大限制;
  2. 为空,则会:
    1. 有 setImmediate 的回调,则结束 poll phase,进入 check phase;
    2. 没有 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
2
3
4
5
6
setTimeout(function(){
console.log("SETTIMEOUT");
});
setImmediate(function(){
console.log("SETIMMEDIATE");
});

那么输出就不一定是哪个先。为什么呢?分析一下:

  1. 主程序添加 timer;
  2. 主程序添加 setImmediate;
  3. 进入 event loop;
  4. 进入 timer phase。得到系统时间,对比一下该 timer 延迟毫秒数 ->0,系统运行所经历的毫秒数:
    1. 如果 = 0(PS:使用 Date.now () 获取的值为格林时间毫秒数,意思是精确到毫秒级,不能再往下了),nodejs 判断是否到达调用时间,不会按照是否等于毫秒数,而是是否大于该毫秒数,大于则执行,否则不执行。所以不执行该 timer;
    2. 如果 > 0 (可能系统资源被其它进程占用太多,导致 cpu 调度比较晚),那么该 timer 执行。

由以上分析,能看出在非 IO 中为什么执行顺序不一致了。如果放在 IO 中:

1
2
3
4
require('fs').readFile('file.txt', () => {
setTimeout(() => console.log(1));
setImmediate(() => console.log(2));
});

那么不管 setImmediate 和 setTimeout 是什么顺序书写,都会是 setImmediate 先执行:

  1. 主程序执行读文件;
  2. 进入 event loop;
  3. 进入 pending phase,文件读完,执行匿名回调–添加一个 timer 和 setImmediate;
  4. 由于 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 其实应该互换名称,只是考虑到现在基于这个命名的应用太多,根本不可能改。