从 unref 看事件循环

描述

引用 nodejs.org 的描述

When called, the active Timeout object will not require the Node.js event loop to remain active. If there is no other activity keeping the event loop running, the process may exit before the Timeout object’s callback is invoked. Calling timeout.unref() multiple times will have no effect.

简单的可以理解为:timer.unref 标记句柄,当进程中再无存活的事件,此时的 timer 句柄不会阻止进程退出。

示例

先看看没有使用 unref 标记的定时器实现:

1
2
3
4
5
6
7
console.log('a');

const timeout = setTimeout(() => {
console.log('c');
}, 3000);

console.log('b');

执行上面代码,依次打印 a, b 后等待 3s 左右,打印 c 之后没有新的事件进程才会退出。使用 unref 标记后:

1
2
3
4
5
6
7
8
console.log('a');

const timeout = setTimeout(() => {
console.log('c');
}, 3000);
timeout.unref();

console.log('b');

依次打印 a, b 后进程直接退出,timeout 句柄并没有阻止进程的退出。

引用计数

上面的例子 timeout 回调函数其实是已经推进了事件循环当中,不过为什么不会等待事件循环执行结束才退出进程呢?这时候引入了一个 引用计数 的概念,可以简单理解为当一个新的事件加入事件循环的队列中时,引用计数 就会 +1 ,当执行完事件后,引用计数 就会 -1,直到没有新的事件进来,引用计数 等于 0 的时候进程退出。unref 调用后,引用计数-1

使用上面的规则对 示例 里的例子进行解析:

unref

当执行到 setTimeout 时,将回调函数推进事件循环,引用计数 +1,执行 timeout.unref()引用计数 -1引用计数0,后面的代码也没有新的事件加入循环,执行文件末尾后进程退出。

node 包装层

基于 Node.js v14.0.0-pre 版本

node.js 里,setTimeout 函数被挂载到运行时上下文中的 global 对象上,具体的实现代码在 node/lib/timer.js 中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

const {
Timeout,
// ...
} = require('internal/timers');

// ...

function setTimeout(callback, after, arg1, arg2, arg3) {
if (typeof callback !== 'function') {
throw new ERR_INVALID_CALLBACK(callback);
}

// ...

const timeout = new Timeout(callback, after, args, false, true);
insert(timeout, timeout._idleTimeout);

return timeout;
}

可以看到执行 setTimeout 是返回了 Timeout 的实例,Timeout 里的实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40

const {
toggleTimerRef,
} = internalBinding('timers');

let refCount = 0;

function incRefCount() {
// 2. 引用计数 + 1, 如果是从 0 位开始 + 1,调用 c++ 层函数 toggleTimerRef 传值 true
if (refCount++ === 0)
toggleTimerRef(true);
}

function decRefCount() {
// 4. 引用计数 - 1, 如果是相减的结果为 0,调用 c++ 层函数 toggleTimerRef 传值 false
if (--refCount === 0)
toggleTimerRef(false);
}

function Timeout(callback, after, args, isRepeat, isRefed) {

// ...

this._destroyed = false;

// 1. 是否标记引用, 调用引用计数 + 1
if (isRefed)
incRefCount();
this[kRefed] = isRefed;
}

Timeout.prototype.unref = function() {
// 3. 已标记引用且未销毁实例,调用引用计数 - 1
if (this[kRefed]) {
this[kRefed] = false;
if (!this._destroyed)
decRefCount();
}
return this;
};

实例化 Timeout 时标记引用,全局的引用计数 + 1,并调用 c++ 层函数 toggleTimerRef 传值 true,再看看 c++ 层的封装:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// src/timer.cc
#include "env-inl.h"

// ...

void ToggleTimerRef(const FunctionCallbackInfo<Value>& args) {
Environment::GetCurrent(args)->ToggleTimerRef(args[0]->IsTrue());
}

// src/env-inl.h

#include "env.h"

inline Environment* Environment::GetCurrent(const v8::FunctionCallbackInfo<v8::Value>& info) {
return GetFromCallbackData(info.Data());
}

inline Environment* Environment::GetFromCallbackData(v8::Local<v8::Value> val) {
// ...
Environment* env = static_cast<Environment*>(obj->GetAlignedPointerFromInternalField(0));
// ...
return env;
}

结合两个文件, Environment 类调用内联函数 GetCurrent 返回 Environment 实例,最终调用到 env.cc 文件里的 ToggleTimerRef 函数:

1
2
3
4
5
6
7
8
9
10

void Environment::ToggleTimerRef(bool ref) {
// ...

if (ref) {
uv_ref(reinterpret_cast<uv_handle_t*>(timer_handle()));
} else {
uv_unref(reinterpret_cast<uv_handle_t*>(timer_handle()));
}
}

最终的逻辑走到了 libuv 的两个函数 uv_refuv_unref 上。

libuv 实现层

基于Libuv v1.35.0 版本

函数实现在 libuv/src/uv-common.c ,逻辑比较简单的两个函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
// uv-common.c
void uv_ref(uv_handle_t* handle) {
uv__handle_ref(handle);
}


void uv_unref(uv_handle_t* handle) {
uv__handle_unref(handle);
}

// uv-common.h

#define uv__handle_ref(h) \
do { \
if (((h)->flags & UV_HANDLE_REF) != 0) break; \
(h)->flags |= UV_HANDLE_REF; \
if (((h)->flags & UV_HANDLE_CLOSING) != 0) break; \
if (((h)->flags & UV_HANDLE_ACTIVE) != 0) uv__active_handle_add(h); \
} \
while (0)

#define uv__handle_unref(h) \
do { \
if (((h)->flags & UV_HANDLE_REF) == 0) break; \
(h)->flags &= ~UV_HANDLE_REF; \
if (((h)->flags & UV_HANDLE_CLOSING) != 0) break; \
if (((h)->flags & UV_HANDLE_ACTIVE) != 0) uv__active_handle_rm(h); \
} \
while (0)

#define uv__active_handle_add(h) \
do { \
(h)->loop->active_handles++; \
} \
while (0)

#define uv__active_handle_rm(h) \
do { \
(h)->loop->active_handles--; \
} \
while (0)

uv_refuv_unref 函数指向了头文件定义的两个宏函数,当句柄仍在活动状态时,标记引用或取消引用标记,并在事件循环的全局活动句柄计数器上 +1 或者 -1

运行时

上面描述了初始化 Timeout 实例和使用 unref 函数 最终会在 libuv 层将事件循环的全局句柄活动计数器 active_handles±1。这个计数器是如何个用途,回到 node 层,当我们执行 node xxx.js 时最开始走到了 node_main.cc 文件里的 main 函数,流程走到了 node_main_instance.cc 里:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31

int NodeMainInstance::Run() {

// ...

{
bool more;
do {
// 默认模式启动 libuv 循环
uv_run(env->event_loop(), UV_RUN_DEFAULT);

// 检查事件循环是否有存活的句柄
more = uv_loop_alive(env->event_loop());
if (more && !env->is_stopping()) continue;

if (!uv_loop_alive(env->event_loop())) {
EmitBeforeExit(env.get());
}

// Emit `beforeExit` if the loop became alive either after emitting
// event, or after running some callbacks.
more = uv_loop_alive(env->event_loop());
// 无存活的句柄,退出循环,进程退出
} while (more == true && !env->is_stopping());
}

exit_code = EmitExit(env.get());
}

// ...
}

我删减了一些代码,这样看得主逻辑更清晰一点,while 里检查事件循环是否还有存活的句柄,如果没有则退出循环、退出进程。这里有使用了 libuv 的函数
uv_runuv_loop_alive ,看看逻辑实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
// libuv/src/unix/core.c

static int uv__loop_alive(const uv_loop_t* loop) {
return uv__has_active_handles(loop) ||
uv__has_active_reqs(loop) ||
loop->closing_handles != NULL;
}


int uv_loop_alive(const uv_loop_t* loop) {
return uv__loop_alive(loop);
}


int uv_run(uv_loop_t* loop, uv_run_mode mode) {
int timeout;
int r;
int ran_pending;

r = uv__loop_alive(loop);
if (!r)
uv__update_time(loop);

while (r != 0 && loop->stop_flag == 0) {
uv__update_time(loop);
// 1. timers 运行的定时器,并更新全局句柄活动计数器 `active_handles`,-1
uv__run_timers(loop);
// 2. I/O callbacks
ran_pending = uv__run_pending(loop);
// 3. idle、prepare
uv__run_idle(loop);
uv__run_prepare(loop);

timeout = 0;
if ((mode == UV_RUN_ONCE && !ran_pending) || mode == UV_RUN_DEFAULT)
timeout = uv_backend_timeout(loop);

// 4. poll
uv__io_poll(loop, timeout);
// 5. check
uv__run_check(loop);
// 6. close
uv__run_closing_handles(loop);

// ...

r = uv__loop_alive(loop);

// ...
}

// ...

return r;
}

// libuv/src/uv-common.h

#define uv__has_active_handles(loop) \
((loop)->active_handles > 0)

#define uv__handle_stop(h) \
do { \
if (((h)->flags & UV_HANDLE_ACTIVE) == 0) break; \
(h)->flags &= ~UV_HANDLE_ACTIVE; \
if (((h)->flags & UV_HANDLE_REF) != 0) uv__active_handle_rm(h); \
} \
while (0)

// libuv/src/timer.c

void uv__run_timers(uv_loop_t* loop) {
// ...
for (;;) {
// ...
uv_timer_stop(handle);
handle->timer_cb(handle);
// ...
}
}

int uv_timer_stop(uv_timer_t* handle) {
// ...
// 调用 uv-common.h 定义的宏函数 active_handles - 1
uv__handle_stop(handle);

return 0;
}

uv_run 函数里就 while 是大家都听过无数遍的事件循环, 其中在第一步的 timers 阶段,执行到期的定时器的同时,还把上面 ref 标记的全局句柄活动计数器 active_handles 进行 -1 操作。流程走到这里就清晰了,再回到最初的地方:

  1. 没有使用 unref
1
2
3
4
5
6
7
8
9
console.log('a');

const timeout = setTimeout(() => {
console.log('c');
}, 3000);

console.log('b');

// a -> b -> c

代码执行到 setTimeout, 回调函数进入事件循环 active_handles + 1 = 1,因为代码没有其他的 I/O 事件,事件循环一直循环定时期定时的时间到了后,执行 timers 阶段的事务,执行回调函数,并对活动句柄计数器减一操作 active_handles - 1 = 0,这时候 while (r != 0 && loop->stop_flag == 0) 条件不满足,退出循环,node 层退出循环后进程退出。

  1. 使用 unref
1
2
3
4
5
6
7
8
9
10
console.log('a');

const timeout = setTimeout(() => {
console.log('c');
}, 3000);
timeout.unref();

console.log('b');

// a -> b

代码执行到 setTimeout, 回调函数进入事件循环 active_handles + 1 = 1,随后执行 unref 操作 active_handles - 1 = 0,事件循环条件 while (r != 0 && loop->stop_flag == 0) 条件不满足,退出循环,node 层退出循环后进程退出。

最后以一层流程图结束本文:

timer-unref

分享到:
Disqus 加载中...

如果长时间无法加载,请针对 disq.us | disquscdn.com | disqus.com 启用代理