Node.js 事件循环

node.js event loop

last edited by Mensu on 2018-10-03

The article was initially posted on 2018-05-26.

个人浅薄和粗糙的理解,忽略了大量细节,必有疏漏,仅供参考

Node.js 与 V8、libuv 的协作

libuv 提供事件循环,并提供一些系统调用和 I/O 操作的函数(API)。这些 API 封装了相应的 I/O 操作,并可以通过多路复用等方式避免阻塞。I/O 操作完成后,事件循环就可以调用传入 API 的回调函数。

V8 负责执行 JavaScript,并提供相应的 binding API,允许 JS 层的函数与 C++ 层的函数绑定。

Node.js 的主干是 C++ 的代码,大意如下:

// 默认事件循环
auto loop = uv_default_loop();

v8Init();
// v8 实例
auto isolate = v8::Isolate::New(...);
// 上下文(global、builtin)
auto context = v8::Context::New(isolate, ...);

// 建立运行环境,例如:
// - 从命令行解析参数,保存各种选项,方便确定是要运行某个文件还是 REPL
// - 调用 V8 的 binding API 建立各种 binding,例如 global.process 对象
// - 往 libuv 默认事件循环注册各种 handle,例如注册 check 阶段的 handle 以实现 setImmediate
setupEnv(loop, isolate, context, ...);

// 编译并运行启动脚本
// 启动脚本里会通过 binding 调用 C++ 层的函数,进而可能调用 libuv 的 API,往事件循环注册 request 或 handle
auto script = v8::Script::Compile(context, source, ...).ToLocalChecked();
script->Run(context);

// 开始事件循环,调用相应 request 或 handle 的 C++ 回调函数
// 这些 C++ 回调函数一般会调用 JS 层传入的回调函数,将结果传回 JS 层
// 而 JS 回调函数可能会注册新的 request 或 handle,驱动事件循环的继续进行
uv_run(loop, ...);

cleanUp(...);

Node.js API 与事件循环

libuv 的事件循环分为 7 个阶段

  • timer
    • setTimeout
    • setInterval
    • 使用最小堆储存 handle,拿出来后就丢掉
  • pending
    • 某些被故意推迟到下一轮事件循环(下一个 tick)的回调函数,例如 socket、pipe 的某些 connect error
    • 使用队列储存 handle,拿出来后就丢掉
  • idle
    • 如果有 setImmediate 的回调函数待处理,则注册 idle handle,使得事件循环不会卡在 poll 阶段,从而能够进入接下来的 check 阶段运行 setImmediate 的回调函数
    • 如果没有 setImmediate 的回调函数待处理,则不注册 idle handle,从而允许事件循环卡在 poll 阶段
    • 使用队列储存 handle,拿出来后会塞回队尾
  • prepare
    • 开启 V8’s CPU profiler
    • 使用队列储存 handle,拿出来后会塞回队尾
  • poll
    • socket、pipe 等可以直接多路复用的
    • fs 等使用多线程通信的
    • 使用队列储存 handle
  • check
    • setImmediate
    • 关闭 V8’s CPU profiler
    • 使用队列储存 handle,拿出来后会塞回队尾
  • close
    • 大部分 handle close 的 C++ 回调函数
    • 使用栈储存 handle,拿出来后就丢掉

为了避免 handle 过多导致频繁的 C++ 层与 JS 层切换,Node.js 会在 JS 层尽量合并回调函数,实现一个 C++ handle 回调时对应调用多个 JS 回调函数。

事件循环卡在 poll 阶段是指,满足一定条件时,poll 阶段会阻塞直到最近的 timer 生效。

setTimeout / setInterval

每次调用时会在 JS 层将回调函数加入相同 timeout(多少毫秒后过期)的 TimerList。在第一个 setTimeoutsetInterval 被调用时注册一个 timer handle 到事件循环,这个 handle 的回调函数负责从优先队列中得到过期的 TimerList,调用里面过期了的回调函数,并给 timer handle 定下新的超时时间或者 unref。在 单例化 timer handle 的 PR 之前,每个 TimerList 会注册一个相应的 timer handle 到事件循环。setInterval 几乎可以看作是用 setTimeout 迭代实现的。

unref 意味着事件循环在判断是否要继续下一个 tick 和是否能卡在 poll 阶段时都不会考虑该 handle。

setImmediate

每次调用时会在 JS 层将回调函数加入链表 immediateList。在 Node.js 启动时会有一个被 unref 的 check handle 注册到事件循环,这个 handle 的回调函数负责调用 immediateList 中的 JS 回调函数。

process.nextTick

process.nextTick 并不是在下一轮事件循环调用回调函数。Node.js 一般是在 每个 handle 的 C++ 回调函数被触发,经过 JS 层的回调后回到 C++ 层,最终返回事件循环前,通过 _tickCallback 调用 tock 队列中的回调函数。

为方便解释,不妨看看下面的例子。监听了 data 事件,在回调函数中又注册了 process.nextTicksetImmediatesetTimeout

const fs = require('fs')
// highWaterMark 设置成了要触发两次 data 事件的大小
const s1 = fs.createReadStream('README.md', { highWaterMark: 600 })
const s2 = fs.createReadStream('README.md', { highWaterMark: 600 })

s1.on('data', () => callback(1))
s2.on('data', () => callback(2))

function callback(id) {
  console.log('data', id)
  process.nextTick(() => console.log('process.nextTick', id))
  setImmediate(() => console.log('setImmediate', id))
  setTimeout(() => console.log('setTimeout', id))
}

可能的输出如下(虚线不是输出的一部分)。我们关心的是虚线中间的部分。每次读完文件数据,从 poll 阶段调用 Node.js 注册的 C++ 回调函数,C++ 回调函数再调用 JS 回调函数分发 data 事件。 JS 回调函数返回到 C++ 层之后,Node.js 就会运行 _tickCallback 调用 tock 队列中的回调函数。

data 1
process.nextTick 1
setImmediate 1
setTimeout 1
---------------------
data 2
process.nextTick 2
data 1
process.nextTick 1
setImmediate 2
setImmediate 1
---------------------
data 2
process.nextTick 2
setImmediate 2
setTimeout 2
setTimeout 1
setTimeout 2

由于两次读文件是两个不同的 handle 回调(更准确来说是 request),所以虚线中间表现出来的就是 handle 回调 -> nextTick -> handle 回调 -> nextTick 的顺序。而后面的两个 setImmediate 证明了上面两个 handle 回调 是在同一个 poll 阶段发生的。

这里想说的是,process.nextTick 不是在每个阶段结束时才执行回调函数的,而是在每个 handle 回调时。这包括 setTimeoutsetImmediate、I/O 等的 handle。

当然,Node.js 还会在启动脚本加载完主模块等时机执行 _tickCallback

微任务

微任务主要是指 Promisethencatch 的回调函数,一般是在 resolvereject 时由 V8 将微任务塞入微任务队列。

_tickCallback 清空完 tock 队列后,就会调用 V8 的接口执行微任务队列,然后循环这两步直到 tock 队列清空。也就是说微任务的执行其实也是 Node.js 控制的。

推论与应用

setTimeout 顺序问题

下面的代码 0、1、2 的输出顺序是什么?

setTimeout(() => console.log(2), 2)  // [2]
setTimeout(() => console.log(1), 1)  // [1]
setTimeout(() => console.log(0), 0)  // [0]

在浏览器上的输出我不敢下定论,但在 Node.js 中,输出可能是 2 1 01 2 01 0 2。但 1 0 的顺序绝对是固定的。为什么?

首先,要知道这几点:

  • setTimeout(..., 0) 等价于 setTimeout(..., 1)
  • setTimeout 会把 expire = libuvNow + timeout 作为超时的标准,如果 expire > libuvNow,则认为超时
  • 每次 setTimeout 都会执行 env->GetNow(),这会先更新一下 libuvNow
    • 就是说每个 setTimeout 拿到的 libuvNow 可能不同
  • timer handle 是用最小堆储存的,每次拿出来的都是最小 expire 的 handle。expire 相同则先加入堆的先被拿出

接着,分情况讨论。假设 [2] 执行时的 libuvNow 是 100,那么就会有如下三种情况

  • [2] now = 100; [1] now = 100; [0] now = 100
  • [2] now = 100; [1] now = 100; [0] now = 101
  • [2] now = 100; [1] now = 101; [0] now = 101

每一种算得的 expire 和最终输出如下

  • [2] now = 100; [1] now = 100; [0] now = 100
    • [2] expire = 102; [1] expire = 101; [0] expire = 101
    • 输出 1 0 2
  • [2] now = 100; [1] now = 100; [0] now = 101
    • [2] expire = 102; [1] expire = 101; [0] expire = 102
    • 输出 1 2 0
  • [2] now = 100; [1] now = 101; [0] now = 101
    • [2] expire = 102; [1] expire = 102; [0] expire = 102
    • 输出 2 1 0

参考资料