January 1, 2024 by 严肃游戏
从实现看 JavaScript 的事件循环
JS Engine EventLoop 实现一瞥
事件循环这个高雅的词我从出生到现在已经听过无数遍了,关于 Node.JS 的 EventLoop 那张传世经典流程图也见过无数次,基本上所有讲 Node.JS EventLoop 的文章都会引用到这个流程图:
┌───────────────────────────┐
┌─>│ timers │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ pending callbacks │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ idle, prepare │
│ └─────────────┬─────────────┘ ┌───────────────┐
│ ┌─────────────┴─────────────┐ │ incoming: │
│ │ poll │<─────┤ connections, │
│ └─────────────┬─────────────┘ │ data, etc. │
│ ┌─────────────┴─────────────┐ └───────────────┘
│ │ check │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
└──┤ close callbacks │
└───────────────────────────┘
这张图,乍看一下有非常多的流程,既有宏任务,又有微任务的各个过程,非常复杂和繁琐,我在相当长的一段时间里都无法完全理解,仅从图上看,我无法想象那么复杂的流程应该怎么理解和记忆,底层的实现又该多复杂,直到有一次机缘巧合,看到了某位前辈的一篇关于「基于 QuickJS 制作 JS Runtime 」的文章,我终于一瞥 EventLoop 的真实实现,他真的就只是一个无限循环的 Loop,只是这样:
// https://vscode.dev/github/bellard/quickjs/blob/master/quickjs-libc.c#L3941-L3942
/* main loop which calls the user JS callbacks */
void js_std_loop(JSContext *ctx)
{
JSContext *ctx1;
int err;
for(;;) {
/* execute the pending jobs */
for(;;) {
err = JS_ExecutePendingJob(JS_GetRuntime(ctx), &ctx1);
if (err <= 0) {
if (err < 0) {
js_std_dump_error(ctx1);
}
break;
}
}
if (!os_poll_func || os_poll_func(ctx))
break;
}
}
它的精髓就在于把复杂的异步过程简化为一个单线程循环的过程,这个无限循环的过程就是我们常说的 EventLoop,这个 EventLoop 里面会不断的执行 JS 代码,当遇到异步操作时,会把异步操作的回调函数放到一个队列里面,异步操作由引擎放到其他线程/进程执行,等到 JS 代码执行完毕后,再去执行这个队列里面的回调函数把执行结果返回给调用方。这样一来,就不需要再考虑什么多线程同步和各种锁,也无需考虑什么多进程通信,统一都丢到 Eventloop,使用回调/Promise/async/await,静静等待执行完成后触发回调拿到结果即可。
将异步的复杂流程都丢给 JS Engine,JS 代码只需要考虑触发和回调逻辑,极大地降低了语言的复杂度。这大概也是 JS 看起来这么像玩具语言的原因,它的所有功能基本上都是由宿主/引擎提供的,复杂度都被隐藏在宿主之下,使用 JS 只需要考虑调用和等待返回结果,没有那么多弯弯绕绕,所以才会如此受欢迎。现代语言的趋势大概都如此,复杂度由底层实现,上层只需要调用即可,这样才能让开发者更专注于业务逻辑,而不是底层实现。
当然这里 QuickJS 的实现是比较简化了,但是既然都符合 ECMAScript 的规范,那么可以认为他们是等价的,这里我们就以 QuickJS 的实现为引子,来挖掘下各个 JS Engine 的 EventLoop 实现。
未完待续