Nodejs 中的 Active Handle 与 Timer 优化(上)
alienzhou opened this issue · 0 comments
本文源码基于当前(2021.07.18)的 LTS 版(v14.17.3)。其主要逻辑与概念在近四个大版本(v10/12/14/16)上基本一致。
1. 引言
近期 KNode 在做 Active Handles/Requests 信息的按需采集功能。在 Active Handles 中包含了 timer 的信息采集,这里的 timer 就是指的通过 setTimeout
/setInterval
设置的定时器,以及 Nodejs 内置 JavaScript 模块中创建的定时器等。而 Nodejs 在 timer 实现上与其他异步资源的代码结构不太一致,做了特定的优化。
由于部分同学可能对 Nodejs 中的 Handles 和 Requests 不太熟悉,所以本文会分为上下两篇:
-
上篇先介绍 Active Handles & Requests 和的基本概念作为铺垫,不会涉及 timer 部分。
-
下篇会介绍 timer “与众不同”的地方,以及 Nodejs 为其定制的优化。
2. 什么是 Handles 和 Requests?
提到 Nodejs 中的 Handles 和 Requests 时,我们指的就是 libuv 中的 Handles 和 Requests 概念。
可以大致理解为 Handle 会”指代“一系列 I/O 操作或 timer 等,例如用于 TCP 的 uv_tcp_t
,用于定时器的 uv_timer_t
。libuv 中还有一个与其类似的概念是的 Request,它与 Handle 的主要区别就是生命周期的长短。
Handles represent long-lived objects capable of performing certain operations while active.
—— libuv design overview
例如我们最常见的 DNS 查询(uv_getaddrinfo_t
)和文件读写(uv_fs_t
)就都是 Request。
Nodejs 中的一个核心依赖就是 libuv。它通过 libuv 来实现基于 event loop 模型的异步 I/O,同时抹平了系统间的差异。在之前司内的「Nodejs监控与排障实践」分享上,也简单提到了由于 Handle 会“指代”某个工作,因此通过 Handle(与 Request)的信息也可以一定程度上反应出 Nodejs 目前在忙些什么事儿。
我们在 userland 里做的各种 I/O 操作,最后很多都会直接对应到 libuv 中的 Handle。
上面是从 Handle 角度来看,我们使用 Nodejs 时的大致情况。
日常工作中我们大多都是在 userland 写代码(包括引入的 npm 包)。这些代码会引用(require)各个 Nodejs 内置模块,也就是图中的 internal javascript module。然后最底层是通过 libuv 的 public API 来创建 Handle 并进行异步 I/O。为了能让 js 层使用到最底层的 uv 库,Nodejs 中有一部分代码会负责做 Handle 的包装,加上一些简单的处理逻辑,再通过 v8 binding 机制暴露给 Nodejs 的内部 js 模块,也就是图中 Handle Wrap 和 v8 bindings 这部分。
Handle Wrap 在 node-core 中就是指的 src 目录下实现的 ProcessWrap / FSEventWrap 这些,它们是对 libuv 中 handle 的封装,都继承自 HandleWrap 抽象类。下图是 HandleWrap 和 ReqWrap(libuv Request 的 Wrap)类相关的类继承关系。
其中浅蓝色部分的 AsyncWrap 是对 Nodejs 中异步操作的抽象,会用来做异步的追踪,例如在 userland 中使用的 async_hooks 中的一些功能就会依赖到这部分。绿色部分的 BaseObject 和 MemoryRetainer 则是用于那些在 JavaScript 层和 C/C++ 层有对应关系的对象时,帮助管理对象的生命周期。
3. 什么是 Active 状态?
上面介绍了 Handles 和 Requests。下面说说 Active 的概念。
Active 是 libuv handle 的一种状态。一般某种 handle 都会存在对应的 uv_xxx_start()
和 uv_xxx_stop()
方法。在调用 uv_xxx_start()
后 handle 就会进入 active 状态;而在调用 uv_xxx_stop
方法后,handle 就会被 deactivate。因此,通过找到所有的 Active Handles,可以一定程度上了解目前 Nodejs 进程正在或将要干什么事儿。
而在 Nodejs 进程运行时,会通过调用 uv_loop_alive 来检查是否存在 active handles & requests,从而判断进程是否需要退出。
do {
uv_run(env->event_loop(), UV_RUN_DEFAULT);
per_process::v8_platform.DrainVMTasks(isolate_);
more = uv_loop_alive(env->event_loop());
if (more && !env->is_stopping()) continue;
if (!uv_loop_alive(env->event_loop())) {
EmitBeforeExit(env.get());
}
more = uv_loop_alive(env->event_loop());
} while (more == true && !env->is_stopping());
上面代码中 uv_run
和 uv_loop_alive
两个方法都与是否存在 active handle 有关。Nodejs 进程是否退出也与它们的执行情况有关。 uv_loop_alive
方法会判断是否有 active 状态的 handles 和 requests。
uv_run
则是会不断循环从各个“队列”中取任务执行,在 UV_RUN_DEFAULT
模式下,只有在没有 active handle/request 时,该方法才会跳出循环并返回:
int uv_run(uv_loop_t* loop, uv_run_mode mode) {
...
while (r != 0 && loop->stop_flag == 0) {
...
r = uv__loop_alive(loop);
if (mode == UV_RUN_ONCE || mode == UV_RUN_NOWAIT)
break;
}
...
return r;
}
从那个上面代码可以看到,DEFAULT 模式下 uv__loop_alive
的返回值将决定 event loop 是否持续。而 uv_loop_alive
内部实际调用的也是 uv__loop_alive
。
这就是为什么下面这段代码在运行后,Nodejs 进程不会退出
const http = require('http');
const app = http.createServer(() => {})
app.listen(8088);
因为在 Nodejs 进程中存在一个 TCP 的 active handle。
4. unref handles
但 libuv 中有一个特殊的操作叫 unref(解引用),可以让实际 “active” 的 handle 不会在 uv_loop_alive
中被统计到。
每个 event loop 中会维持一个 active_handles 属性用于计数(requests 也类似),在 uv_loop_alive()
中会通过 uv__has_active_handles
宏来判断是否大于 0。
#define uv__has_active_handles(loop) \
((loop)->active_handles > 0)
而使用 uv_unref()
来解引用,最后就会通过 uv__active_handle_rm
来将该计数减一。
#define uv__active_handle_rm(h) \
do { \
(h)->loop->active_handles--; \
} \
while (0)
但这只是减少了 active_handles
计数,并不会影响 handle 的执行。所以只要进程还未退出,handle 的工作就会正常做。而 Nodejs 也将 unref 操作封装暴露到了 userland 里,基本上如果我们在 userland 中创建的对象有对应的 handle,都会在其上存在一个 .unref()
的方法。例如还是上面这段代码,当我们稍作修改
const http = require('http');
const app = http.createServer(() => {})
app.listen(8088);
+ app.unref();
这个时候如果再去运行,进程立刻就退出了。
5. 什么时候会用到 unref
?
也许你会奇怪,为什么需要 unref
呢?像上面这段代码,创建了一个 HTTP 服务监听端口,是不希望退出的。但可以考虑另一个场景:
- 我们要读取文件执行一批异步任务,任务结束后进程退出。
- 同时,希望每 5 秒上报一下环境信息。
最简版本:
// do batch tasks
const fs = require('fs');
fs.readFile('task.txt', 'utf-8', (_, content) => {
doBatchAsyncTasks(content.split(','));
});
// report
const timer = setInterval(reportEnvStat, 5000);
但这个版本的一大问题就是,任务结束后进程并不会退出,因为进程中有一个会“永久”存活的 timer handle。处理这种问题有几个可行方式:
doBatchAsyncTasks
结束后直接调用process.exit()
来主动退出;- 在
doBatchAsyncTasks
后,通过clearInterval()
来清除定时器。
但这两种方法,都会导致设计上的耦合 —— 异步任务和定时上报需要嵌套对方的资源。
另一种更简便的方式,就是使用 .unref()
:
const fs = require('fs');
fs.readFile('task.txt', 'utf-8', (_, content) => {
doBatchAsyncTasks(content.split(','));
});
const timer = setInterval(reportEnvStat, 5000);
+ timer.unref();
这在你实现一个 library 时可能会更典型。如果你的 library 中创建了一个“永久”的 active handle,而其独立存活并无意义的话,就可能导致使用它的进程无法在预期状态下退出。而这种情况 1、2 这两种方法可能就不太合适, 使用 unref 就会好些。
6. 如何采集 Active Handles?
那我们如何获取当前进程中的 Active Handles 呢?
一个最简单的思路是,在所有 handle 创建的地方(或者统一入口),存储其信息;然后在所有 stop 的地方删除掉存储的对象。但由于 Nodejs 运行过程中 handles 的数量并不少,创建也很频繁(很多时候和 QPS 成正比),所以这种方式在运行时性能与内存占用上都有明显缺陷。
因此会借助目前 Nodejs 已有的能力。其本身已经存储了创建的 Handles 与 Requests 对象。这样只有在按需触发采集时才会有性能与内存开销,采集完毕后又恢复如初。
在 Nodejs 中有两个比较可行的「采集点」:
- libuv 提供了
uv_walk()
这个 public API 来遍历某个 event loop 中所有的 Handles,包括 active/inactive、ref/unref。 - Nodejs 也会保存其所创建的 HandleWrap 对象。通过文章之前的部分可以知道,该对象与 libuv handles 有对应关系,保存了指向 libuv handle 的指针。
对于第一种方法,可以传入一个回调函数来处理遍历到的 handles。libuv 内部会把 handles 保存在 event loop 的 handle_queue
上,通过遍历该队列 libuv 就可以拿到所有 handles。
第二种方式,则可以通过 Nodejs Environment 对象上的 handle_wrap_queue()
方法来获取 HandleWrapQueue
的指针,它是一个双向链表,用存储当前环境下所有的 HandleWrap 对象。
由于之前我们提到的,两者存在对应关系,所以基本上从这两处都可以获取到所需的 handle 详情信息。
7. 例外情况:定时器(timer)
通过上面的方式采集,有一个非常重要的例外,就是定时器(timer):
HandleWrapQueue
中是没有TimerWrap
的,所以通过这种方式,是拿不到 js 层设置的定时器的;uv_walk
中拿到的 timer handle,与 js 层设置定时器的情况也差异极大,并不能反映其情况。
所以,为什么 HandleWrapQueue
中没有 TimerWrap
?为什么 libuv 中的 timer handle 信息与 js 的 timer 对不上呢?
这两个问题会留到「Nodejs 中的 Active Handle 与 Timer 优化(下)」中继续介绍。
总结
最后总结一下本文内容:
- Handles 和 Requests 是 libuv 中的抽象概念,一种 Handle 大致指代了一种 I/O 操作。
- node-core 中对各类 Handles 与 Requests 进行了封装,并分别继承自
HandleWrap
和ReqWrap
。 - Active Handles/Requests 代表当前进程正在忙或将要忙的事儿,它也是 Nodejs 进程不退出的重要原因。
- 使用
.unref()
方法可以在不“取消” handle 工作的同时,解除该 handle 的 active 计数。 - 可以通过
uv_walk
或env->handle_queue()
来收集大多数 handle 信息。
timer 主要来源于两块:
- 来自 userland 的定时器,也就是你在代码里写的
setTimeout
、setInterval
- 来自 node-core 中的定时器,主要是 internal js 模块创建,例如用
http.request
方法发送请求时去设置的 timeout
本文主要包含两个部分:
- libvu 是如何实现定时器的?
- nodejs 中是如何优化定时器的?