杰克-逊の黑豹,恰饭了啦 []( ̄▽ ̄)
前言
无数次,nodejs事件循环都在折磨我,尽管它并不影响我使用nodejs。但是,面试总会被问到,感觉每次回答都有点心虚 ,回去查漏补缺吧,效果也不显著。到头来,我似乎知道事件循环,又不知道事件循环。倒不是说,为了应付面试,我必须把它搞懂。而是说,这东西勾起我的探索欲,不把它搞清楚,我就心里难受。
原因何在呢?
我对事件循环的理解都是隔着黑盒。
要么看nodejs官方文档介绍;
要么看别人的总结;
要么透过代码输出的次序,揣摩事件循环。
nodejs官方文档介绍得很好,可令人抓狂。那感觉就像,好不容易说到故事高潮了,然后不讲了。你读后,从理论上似乎明白事件循环是怎么一回事儿,追问到细节时,又一无所知。唉,这个感觉太让人煎熬了😭。
一年前,我下载nodejs源码,想通过阅读代码的方式搞懂,可没有坚持一星期就白白了。
后来,我尝试通过调试搞懂它,又不知道怎么调试node源码,被难住了,又白白了。
最近,我痛定思痛,拒绝做行动上的矮子,逼迫自己去调试,有了非常开心的结果。带着很激动的心情,写下此文。诚实地说,我对nodejs事件循环的理解更近一步,而非精通掌握。希望这篇文章可以帮助更多被nodejs事件循环困扰无数次的朋友。
如何调试node源码
我在网上搜索很多,没有找到合适的文章告知调试方法。无奈之下,我让自己镇定下来,硬着头皮看看node源码文档,从README.md,找到了调试方法,方法就记录在node源码的BUILDING.md文档中。
调试的思路非常简单,仅仅需要两步。
第一步,按照BUILDING.md,手动编译node。作为阉割版M1 Pro芯片的MacBook Pro14用户,编译node的时候,第一次听到风扇嗡嗡狂转。编译好的node二进制文件位于out/Debug/node
。
sh
$ ./configure --debug
$ make -j4
第二步,使用 lldb 开始调试。test.js
是我用来测试、随意编写的一个文件。在Mac环境下,我用的是lldb工具,可能你的情况不同,要用gdb工具。
sh
$ lldb ./out/Debug/node test.js
test.js
内容参考:
js
console.log('A');
process.nextTick(() => {
console.log('B');
});
process.nextTick(async () => {
console.log('C');
});
Promise.resolve().then(() => {
console.log('D');
});
setTimeout(() => {
console.log('E');
}, 0);
setImmediate(() => {
console.log('F');
});
来到lldb调试界面,就是lldb如何使用的范畴了,不是本文重点,但是为了读者自己实践方便,还是给出一些基本操作:
- 执行
b main
,再执行r
, node程序就会运行,在入口函数 main 停下。lldb给出的调试信息,会告知main函数所在的文件名、行位置、列位置。 - 执行
n
,就是step over单步调试。 - 执行
s
,就是step into单步调试。 - 执行
c
,就是程序继续执行,遇到下一个断点停下。 - 执行
list 10
, 终端会输出当前被调试文件第10行开头的一部分代码。 - 执行
p env
,可查看源码里的变量env的值。 - 执行
frame info
,可查看当前提顿在哪一行代码。 - 执行
variable
,可查看当前所有栈变量。
这个方式可以看到node启动后都做了什么,让你亲眼看到事件循环。
省略源码的一些无关代码,只需要看带注释的代码即可。
cpp
// src/node_main_instance.cc
int NodeMainInstance::Run(const EnvSerializeInfo* env_info) {
// ...
int exit_code = 0;
DeleteFnPtr<Environment, FreeEnvironment> env = CreateMainEnvironment(&exit_code, env_info);
CHECK_NOT_NULL(env);
{
Context::Scope context_scope(env->context());
if (exit_code == 0) {
// 执行js脚本
LoadEnvironment(env.get(), StartExecutionCallback{});
// 进入事件循环
exit_code = SpinEventLoop(env.get()).FromMaybe(1);
}
ResetStdio();
}
// ...
return exit_code;
}
cpp
// src/api/embed_helpers.cc
Maybe<int> SpinEventLoop(Environment* env) {
CHECK_NOT_NULL(env);
MultiIsolatePlatform* platform = GetMultiIsolatePlatform(env);
CHECK_NOT_NULL(platform);
Isolate* isolate = env->isolate();
HandleScope handle_scope(isolate);
Context::Scope context_scope(env->context());
SealHandleScope seal(isolate);
if (env->is_stopping()) return Nothing<int>();
env->set_trace_sync_io(env->options()->trace_sync_io);
{
bool more;
env->performance_state()->Mark(
node::performance::NODE_PERFORMANCE_MILESTONE_LOOP_START);
do {
if (env->is_stopping()) break;
// nodjs官方文档里说的 timers pending idle&prepare poll
// check close 等 phase,都在 uv_run 函数中!
uv_run(env->event_loop(), UV_RUN_DEFAULT);
if (env->is_stopping()) break;
platform->DrainTasks(isolate);
more = uv_loop_alive(env->event_loop());
if (more && !env->is_stopping()) continue;
if (EmitProcessBeforeExit(env).IsNothing()) break;
more = uv_loop_alive(env->event_loop());
} while (more == true && !env->is_stopping());
env->performance_state()->Mark(node::performance::NODE_PERFORMANCE_MILESTONE_LOOP_EXIT);
}
if (env->is_stopping()) return Nothing<int>();
env->set_trace_sync_io(false);
env->PrintInfoForSnapshotIfDebug();
env->VerifyNoStrongBaseObjects();
return EmitProcessExit(env);
}
c
// deps/uv/src/unix/core.c
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);
// timers phase
uv__run_timers(loop);
// pending phase
ran_pending = uv__run_pending(loop);
// idle phase
uv__run_idle(loop);
// prepare phase
uv__run_prepare(loop);
timeout = 0;
if ((mode == UV_RUN_ONCE && !ran_pending) || mode == UV_RUN_DEFAULT)
timeout = uv_backend_timeout(loop);
// poll phase
uv__io_poll(loop, timeout);
uv__metrics_update_idle_time(loop);
// check phase
uv__run_check(loop);
// close phase
uv__run_closing_handles(loop);
if (mode == UV_RUN_ONCE) {
uv__update_time(loop);
uv__run_timers(loop);
}
r = uv__loop_alive(loop);
if (mode == UV_RUN_ONCE || mode == UV_RUN_NOWAIT)
break;
}
if (loop->stop_flag != 0)
loop->stop_flag = 0;
return r;
}
调试node源码,能让你理解node执行的骨架,真真正正看到事件循环,但无法让你看到宏任务和微任务是怎么加入到队列,又是如何从队列取出执行。想要知道这个,还需要从调试js文件入手。接下来,就以上文的test.js
为例,说一下调试思路。
调试js文件
在vscode中,往.vscode/launch.json
加入如下配置:
json
{
"version": "0.2.0",
"configurations": [
{
"type": "node",
"request": "launch",
"name": "debug node js with internals",
"program": "${file}",
"skipFiles": []
}
]
}
给process.nextTick
Promise.resolve
setTimeout
setImmediate
所在行打上断点,然后开启vscode的调试即可。对函数调用,使用 step in 调试,从setImmediate函数返回后,不要使用continue,继续采用单步调试,你会发现大宝藏。调试过程,非常无脑,非常简单,读者可以尝试尝试。
js
// test.js
console.log('A');
process.nextTick(() => {
console.log('B');
});
process.nextTick(async () => {
console.log('C');
});
Promise.resolve().then(() => {
console.log('D');
});
setTimeout(() => {
console.log('E');
}, 0);
setImmediate(() => {
console.log('F');
});
接下来,说说调试后的结论。
当执行node test.js
时,入口文件不是test.js
,而是nodejs源码的lib/internal/main/run_main_module.js
。
然后是lib/internal/modules/run_main.js
。
然后是lib/internal/modules/cjs/loader.js
。该文件中,会require我们的test.js
,test.js
因此得以执行。终端打印出A
。
执行之后,会跳转到processTicksAndRejections
函数。这个函数位于lib/internal/process/task_queues.js
。这个时候,process.nextTick
注册的回调函数执行,之后执行Promise.resolve().then
注册的回调函数。
之后,进入事件循环。
接下来,会跳转到processTimers
函数,setTimeout注册的回调函数执行。
执行后,跳转到processImmediate
函数,setImmediate注册的回调函数执行。
processTimers
和processImmediate
函数都定义在lib/internal/timers.js
。
最终,程序结束。
这部分讲述起来非常复杂,一篇文章说不清楚,放在以后的一个文章里细聊。接下来,说一个补充的要点,给下一篇文章热热身。
js函数和事件循环运行时
在lib/internal/
下面的js文件,有些函数被完整定义,有些函数使用internalBinding
函数引入,而不是require
引入。比如:
js
// lib/internal/process/task_queues.js
const {
tickInfo,
runMicrotasks,
setTickCallback,
enqueueMicrotask
} = internalBinding('task_queue');
为什么会这样呢?
因为像runMicrotasks
,是在cpp端定义,js端跨语言引用。
如果你开发过node addon的话,你就会很熟悉。开发node addon的时候,函数使用cpp或者Rust语言编写的,生成.node文件后,nodejs引入它,然后在js文件里调用这些函数。
V8引擎提供了这样的工具,帮助我们实现在cpp端定义的函数,可以被js端直接使用。但这属于具体技术实现细节,究竟怎么实现,并不影响我们理解nodejs事件循环。我们只需知道,js端调用这些函数的时候,就会陷入到cpp的一个函数里执行。
这种机制非常重要。因为事件循环使用cpp开发,相当于一个运行时,js端需要与之交互,才能去做宏任务、微任务的执行,而且像setTimeout也需要从运行时获取时间参数,计算任务的超时时刻。这个机制,会在下一篇相关的文章用到,是理解下一篇文章的基础。
最后
你是如何学习nodejs事件循环的,有没有哪些困惑?欢迎留言分享。