本文作者:hsy
之前有一篇对于 Libuv 的学习记录 - Libuv 之 - 只看这篇是不够的,该记录集中在对 Libuv 的学习,并未涉及 Node 是如何使用 Libuv 的,本文将在其基础上,继续学习和记录 Node 是如何运行于 Libuv 之上的
我们首先调查的是 Node 的启动过程(只记录了与本次学习目的相关的环节):
NodeMainInstance::CreateMainEnvironment
其中的 setupTimers(processImmediate, processTimers) 注册了 processImmediate
和 processTimers
仅把各式的 handle 初始化,仅通过 uv_check_start
启动了 immediate_check_handle()
,其对应的回调是 Environment::CheckImmediate
根据不同的启动方式(比如 REPL 或 worker thread)选择不同的入口脚本,我们 node your_script.js
的方式,将会进入到分支 node.cc#L500
main_script_id
的实参是 internal/main/run_main_module
运行应用脚本 internal/main/run_main_module.js
即命令 node your_script.js
中的 your_script.js
,此时 event_loop 还未运行
uv_run event_loop 开始运行
上面的启动环节中,Environment::InitializeLibuv 初始化了下面的 handles:
timer_handle()
immediate_check_handle()
immediate_idle_handle()
task_queues_async_
启动的过程可以粗略的概括成这样:
进入事件循环之后,上面提到的 handles 就成了对 Node 后续流程解读的关键点,因此接下来会每个 handle 的作用进行介绍
immediate_check_handle()
有几点值得关注:
它在 env.cc#L496 处被启动
它对应的回调是 Environment::CheckImmediate
Environment::CheckImmediate
实际执行两块内容:
通过 env->RunAndClearNativeImmediates() 来执行那些在 cpp 侧、使用 Environment::SetImmediate 或者 Environment::SetImmediateThreadsafe 注册的回调
通过 MakeCallback(env->immediate_callback_function()) 执行那些在 js 侧、使用 setImmediate 注册的回调 。其中的 immediate_callback_function()
就是上面启动步骤中 setupTimers
所注册的 processImmediate
immediate_idle_handle()
并没有在初始化之后随即被启动,而是需要后续通过 Environment::ToggleImmediateRef 来启动或停止
按照源码的注释,immediate_idle_handle()
的作用就是防止事件循环在 io-polling 阶段进入阻塞模式:
Idle handle is needed only to stop the event loop from blocking in poll.
那么这里就有几个问题:
immediate_idle_handle()
使用 uv_idle_start
就能防止 io-polling 进入阻塞模式详细的答案可以在之前的记录 io-poll timeout 处找到
这里简单回答一下上面问题:
uv_idle_start
增加了 idle 事件监听队列,结合上一条,所以可以防止 io-polling 进入阻塞模式如果我们回顾一下 libuv 的内部执行流程:
会发现有另一个疑问 - 为什么不把 Environment::CheckImmediate
直接放到 idle 阶段呢,这样岂不是可以省去单独再配置一个 immediate_idle_handle()
的环节
对于这个问题,可以观察下面这幅图,当应用启动完成后,后续应用的时间片将在 cpp 和 js 之间进行切换:
在处理 js 回调的过程中,会经过 cpp -> js 然后 js -> cpp 的过程
io-polling 会在当次循环中,依次执行符合触发条件的 js 回调,如果在回调中调用了 setImmediate
而其对应的处理又在 idle 阶段,那么会导致对应的回调需要延迟到下一次循环才会被处理,这样的行为就和 setImmediate
本身的功能定位产生了偏差
task_queues_async_
的作用是为了在多线程环境下实现 setImmediate
功能,因为 cpp 扩展很可能运行在多线程环境下,所以利用 uv_async_t 来实现在主线程外的线程、添加 immediate 并通知主线程进行处理的功能
在 Thead pool 一节中,我们已经知道 uv_async_t
在 linux 下的核心实现就是利用 epoll_create
创建虚拟的 fd 来利用 epoll 的功能,因此 uv_async_t
和未来可能接触到的 uv_work_t
其回调都是在事件循环的 io-polling 阶段被处理的
上文我们提到,Node 内部在使用 libuv 处理 js 回调的时候,会出现 cpp -> js 和 js -> cpp 这样来回切换的情况。因为 cpp 代码和 js 经过 JIT 后的代码在内存分布上相隔比较远,来回跳跃会导致程序无法有效利用 CPU 指令缓存
并且在 Node 应用中,通常会有很多的 timers 需要处理,因此 Node 中将 timers 回调移动到 js 执行环境中处理,这样就能够减少 cpp 和 js 来回切换的次数,以此提高应用整体的性能表现。在 timers.js#L548 文件开头的注释中,也有一些相关说明
我们在 timer 一节中,介绍过 timer 在实现上主要依赖的数据结构就是 min heap,现在为了将 timers 的处理移动到 js 执行环境中,Node 中使用 js 也实现了一些类似的数据结构
为了方便在 js 环境下处理 timers,Node 中将一些更细颗粒度的 API 封装到了模块 timers 中。比如,在内部 js 实现中使用 timers 模块提供的 scheduleTimer
方法,就可以设置一个 libuv 层面的定时器,因为该方法对应的 native 实现是 Environment::ScheduleTimer:
void Environment::ScheduleTimer(int64_t duration_ms) {
if (started_cleanup_) return;
uv_timer_start(timer_handle(), RunTimers, duration_ms, 0);
}
通过上面的代码,我们可以知道定时器对应的回调是方法 RunTimers
而如果我们进一步调查 RunTimers 的实现,会发现其中的主要逻辑就是调用 env->timers_callback_function()
上的 js 方法,该方法就是上文提到的启动阶段中的 setupTimers
注册的 js 方法 processTimers
setInterval
和 setTimeout
从内部实现来相差不大,前者会在一次 timer 超时后,再次添加一个新的 timer,详见 timers.js#L548
为了了解 setTimeout
内部的实现方式,文件 开头的注释 非常重要:
// ╔════ > Object Map
// ║
// ╠══
// ║ lists: { '40': { }, '320': { etc } } (keys of millisecond duration)
// ╚══ ┌────┘
// │
// ╔══ │
// ║ TimersList { _idleNext: { }, _idlePrev: (self) }
// ║ ┌────────────────┘
// ║ ╔══ │ ^
// ║ ║ { _idleNext: { }, _idlePrev: { }, _onTimeout: (callback) }
// ║ ║ ┌───────────┘
// ║ ║ │ ^
// ║ ║ { _idleNext: { etc }, _idlePrev: { }, _onTimeout: (callback) }
// ╠══ ╠══
// ║ ║
// ║ ╚════ > Actual JavaScript timeouts
// ║
// ╚════ > Linked List
通过上面的注释所描述的数据结构,我们可以知道 timers 都按它们的 timeout 被汇集到了 lists
中。其中的 key 就是每个具体的 timeout 值,每个 key 下汇集了具有相同 timeout 值的 timers
然后 js 中会使用 scheduleTimer
来设置仅一个 libuv 定时器(如果有正在运行的定时器、且该定时期的超时范围涵盖了当前要添加的定时器的话),并在其回调中尽可能处理多的 timers
当然,将 timers 都按照它们的 timeout 汇集到一起也存在一个问题:
setTimeout(() => {}, 100); // 1
setTimeout(() => {}, 100); // 2
上面我们添加了两个 timers,分别称之为 timer1 和 timer2,按照目前的规则,它们会被归纳到 lists
中的 key 为 100
的链表中,然后 scheduleTimer
会设置一个 100 毫秒的定时器(假设当前没有正在运行的定时器)
s0
表示scheduleTimer
调用的时间,FT0
表示定时器未来触发的时间s1
表示第一个 setTimeout
调用的时间,那么 timer1 的回调时间 FT1
预期在 >= s1 + 100
的时间被调用才是合理的s2
表示第二个 setTimeout
调用的时间,那么 timer2 的回调时间 FT2
预期在 >= s2 + 100
的时间被调用才是合理的根据上面的定义我们绘制出下面的时间线:
s0 在 s1 之后,是因为 scheduleTimer
是由第一次 setTimeout
调用的
通过图例,我们可以发现 FT0 的可能时间范围是大于 FT2 的,因此实际的 FT0 可能并不落在 FT2 的范围之内,若出现这样的情况,那么执行 timer2 就为时尚早。因此在 js 实现的 processTimers
中是有一部分 逻辑 来处理这个问题的。处理的方式就是,对于未到时间的 timers 当次不执行,并重新设置定时器,在未来的时间点执行它们
processTimers
和 processImmediate
中另一个关键部分是其中都会穿插执行 runNextTicks,而 runNextTicks 中会分别执行 ticks 和 Microtask Queue 中的内容
后面会有整体的图例,因此目前我们只要对细节有一些了解即可
microtasks 是 v8 为了提高 async/await
和 promise 的执行效率而引入的元素,详细的介绍可以参考 Faster async functions and promises,这里我们只借用文中的一幅图来大致理解一下内部的运行方式:
图中有两个数据结构:Execution Stack 和 Microtask Queue,我们的同步代码都在 Execution Stack 中执行,同步代码中涉及的 async/await
和 promise 都被装载到 Microtask Queue 中。引擎提供了 API 让嵌入方(比如 Node)选择性地执行 Microtask Queue 中的任务,也提供了 API 让嵌入方可以往 Microtask Queue 中追加任务
每次由 cpp -> js 的调用,都会借助 InternalCallbackScope::~InternalCallbackScope 来层层调用到 node::RunMicrotasks 以执行 Microtask Queue 中的任务
关于上述层层调用的细节:
下面这幅图提供了一个整体的视角:
虽然在源码上 processTicksAndRejections
是在内部调用的 runMicrotasks
,不过为了方便理解,在图中对它们进行了独立展示
通过上面的图,我们可以稍微小结一下,对于 Node 应用而言:
async/await
和 promise(除构造函数) 会被装载到 Microtask Queue 中文本作为 Libuv 之 - 只看这篇是不够的 的姊妹篇,对 Node 是如何运行于 Libuv 之上做了简单的学习记录,希望可以为有兴趣深入的同学抛砖引玉。同时欢迎大家对文中的纰漏进行指正,一起探索 Node 的内部机理