The hard ways

Libuv 之上的 Node

图片来源:The Node.js Architecture

本文作者:hsy

之前有一篇对于 Libuv 的学习记录 - Libuv 之 - 只看这篇是不够的,该记录集中在对 Libuv 的学习,并未涉及 Node 是如何使用 Libuv 的,本文将在其基础上,继续学习和记录 Node 是如何运行于 Libuv 之上的

Node 的启动过程

我们首先调查的是 Node 的启动过程(只记录了与本次学习目的相关的环节):

上面的启动环节中,Environment::InitializeLibuv 初始化了下面的 handles:

  • timer_handle()
  • immediate_check_handle()
  • immediate_idle_handle()
  • task_queues_async_

启动的过程可以粗略的概括成这样:

  • 初始化环境(一些内置的前置变量和函数),方便执行后续的用户脚本
  • 执行用户脚本
  • 进入事件循环

进入事件循环之后,上面提到的 handles 就成了对 Node 后续流程解读的关键点,因此接下来会每个 handle 的作用进行介绍

immediate_check_handle()

immediate_check_handle() 有几点值得关注:

immediate_idle_handle()

immediate_idle_handle() 并没有在初始化之后随即被启动,而是需要后续通过 Environment::ToggleImmediateRef 来启动或停止

按照源码的注释,immediate_idle_handle() 的作用就是防止事件循环在 io-polling 阶段进入阻塞模式:

Idle handle is needed only to stop the event loop from blocking in poll.

那么这里就有几个问题:

  • io-polling 阶段在什么情况下会进入阻塞模式
  • 为什么通过对 immediate_idle_handle() 使用 uv_idle_start 就能防止 io-polling 进入阻塞模式
  • 为什么要防止 io-polling 进入阻塞模式

详细的答案可以在之前的记录 io-poll timeout 处找到

这里简单回答一下上面问题:

  • 当除了 io 事件监听队列外,没有其他待处理的事件监听队列
  • 因为 uv_idle_start 增加了 idle 事件监听队列,结合上一条,所以可以防止 io-polling 进入阻塞模式
  • 因为 libuv 需尽可能的为各个事件监听队列都分配到 CPU 时间,所以不能让某类任务占据的时间过长

如果我们回顾一下 libuv 的内部执行流程:

会发现有另一个疑问 - 为什么不把 Environment::CheckImmediate 直接放到 idle 阶段呢,这样岂不是可以省去单独再配置一个 immediate_idle_handle() 的环节

对于这个问题,可以观察下面这幅图,当应用启动完成后,后续应用的时间片将在 cpp 和 js 之间进行切换:

在处理 js 回调的过程中,会经过 cpp -> js 然后 js -> cpp 的过程

io-polling 会在当次循环中,依次执行符合触发条件的 js 回调,如果在回调中调用了 setImmediate 而其对应的处理又在 idle 阶段,那么会导致对应的回调需要延迟到下一次循环才会被处理,这样的行为就和 setImmediate 本身的功能定位产生了偏差

task_queues_async_

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 阶段被处理的

timer_handle()

上文我们提到,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

setTimeout, setInterval

setIntervalsetTimeout 从内部实现来相差不大,前者会在一次 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 当次不执行,并重新设置定时器,在未来的时间点执行它们

processTimersprocessImmediate 中另一个关键部分是其中都会穿插执行 runNextTicks,而 runNextTicks 中会分别执行 ticks 和 Microtask Queue 中的内容

后面会有整体的图例,因此目前我们只要对细节有一些了解即可

microtasks

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 应用而言:

  • 其时间片被分割到不同的回调函数中
  • 回调会由 libuv 的事件触发,发生由 cpp -> js 的调用
  • js 回调执行完毕后,都会对 ticks 和 Microtask Queue 中积累的内容进行一次处理
  • async/await 和 promise(除构造函数) 会被装载到 Microtask Queue 中

小结

文本作为 Libuv 之 - 只看这篇是不够的 的姊妹篇,对 Node 是如何运行于 Libuv 之上做了简单的学习记录,希望可以为有兴趣深入的同学抛砖引玉。同时欢迎大家对文中的纰漏进行指正,一起探索 Node 的内部机理

Made with gadget