Electron 集成Node event loop 和Chromium message loop 事件循环原理探究
前言
最近一直在琢磨为啥 Electron 可以在渲染进程直接访问到 Nodejs 的 api,仿佛就是浏览器环境与 Node 环境融合到一起了似的。
比如当你用如下方式创建一个 Electron 的窗口:
const mainWindow = new BrowserWindow({
width: 800,
height: 600,
webPreferences: {
contextIsolation: false,
nodeIntegration: true,
}
})
那么,你就可以调用挂载在 window 上的 Node API:

那么底层是怎么实现的呢,笔者最近做了相关的研究,将相关的探究结果总结如下,希望对 Electron 的架构有更深的认识。
问题探究
众所周知,Electron 是一款可以用 web 端技术开发跨桌面应用的框架,由于内部集成了 Chromium、GUI、Nodejs,从而打通了整个前端全栈的生态,因此本章先对其重要组成部分 Chromium 、Nodejs 进行介绍,然后结合官网对事件循环集成的相关介绍进行拓展分析。
Nodejs
早在 Electron 和 NW.js 这些跨平台框架诞生之前,2009 年 Ryan Dahl 在柏林的 jsConf 上发布了 Nodejs。相比于其他语言,Nodejs 提供了一种不同的方式来编写 server 端的代码,得益于此,现如今 Nodejs 已经拥有了海量的生态库,并诞生了大量以 Node 为基础的框架:Express、Webpack、Electron、NW.js 等等。
Node 表面基于 V8 提供了一个 JavaScript 的执行环境,底层则基于 libuv 实现了 event loop。
Node 允许我们以一种异步的方式执行代码,而不阻塞其后代码的执行,其中 libuv 在背后提供了事件异步处理机制的关键能力,实际上,libuv 是一个跨平台 C 语言库,并基于 event loop 提供 异步 I/O。
libuv 看起来是一个基础的 C 库,很多工具,甚至嵌入式的 IoT 都有用到它。
而 V8 和 Node Bindings 则提供了 js 调用环境、接口。之所以我们能够利用 JavaScript 实现基本程序计算,并调用 libuv 这样的 C 库(让 js 看起来不那么像一个脚本语言了),V8 引擎、Node Bindings 在背后起了关键作用。一方面 V8 提供了 JavaScript 的运行环境,也就是解析 js 代码,让系统读懂我们写的代码。一方面为了保证性能,Nodejs 将一些底层 IO 操作交给 C 去做,并抽象为一个个的接口,提供给 JavaScript 代码调用,例如 Node Bindings 为 JavaScript 提供众多如 fs、os、process、http 等系统层级操作接口。
关于 Node bindings 的介绍,参考下文补充参考
所以,Nodejs 的整体架构可以用下面这张图总结:

跨平台桌面框架的难题
Nodejs 出现之后,很多开发人员都在思考如何将跨平台的 Nodejs 与前端技术结合起来开发 APP,基于此,不仅可以完成跨平台的桌面应用,还能打通 Nodejs 和浏览器端的生态。
如 Electron、NW.js 这样的框架便孕育而生,并对前端生态产生了重要影响:
项目意义上,Electron、NW.js都为大前端发展做了巨大的贡献,让前端开发能够搞客户端开发,诸如 Atom、VSCode、Github Desktop 等等一些桌面端的应用,都或多或少有 Electron 的身影。
技术意义上,Electron 等跨平台框架做的最具意义的事情就是将 Nodejs 、原生GUI 和 Chromium 结合,开发人员可以基于丰富的前端生态,开发出跨平台的桌面应用。
浏览器端逐渐被 Chrome 统一,那么对应开源的 Chromium 便是前端技术的代表,所以目标就变成为了:如何将跨平台的 Nodejs 与 Chromium 结合起来开发 APP。
想要实现这一目标,首先需要考虑的就是;如何结合 Nodejs 和 Chromium 的事件循环。这里不得不提一下 NW.js,作为想和 Electron 做同样事情的“竞品”,NW.js 也实现了将 Node 和 Chromium 的结合,其中比较关键的一个点是如何调协两者的事件循环,如在《cross-platform desktop applications using NW.js and electron》一文中的 6.1 章提到:
但是将 Nodejs 和 Chromium 结合一起工作比较难办,这里有三件必须要解决的事情:
- 让 Nodejs 和 Chromium 使用同一个 V8 实例
- 集成主线程的事件循环
- 桥接 Node 和 Chromium 之间的 context
表面上,NW.js 使用 Chromium 的一个 forked 版本并基于其修改让其与 Nodejs 结合起来。而 Electron 使用 Chromium 和 Node.js 但是没有对它们做修改,这让 Electron 可以随时跟上 chromium 和 Node 的最新版本。
具体实现上,NW.js 将 Nodejs 的事件循环与 Chromium 的 message loop 直接融合,自定义了一套事件循环机制,这也是为什么要 fork 并修改人家源码,具体架构图可以总结为:
图来自——《cross-platform desktop applications using NW.js and electron》 的 6.1.2 章
那么, Electron 为实现 Chromium 和 Nodejs 事件循环结合,它又做了哪些事情呢?接下来本文将就此进行探究。
chromium 的多进程架构
研究如何结合之前,我们需要对 Chromium 有所了解,因此实际上 Electron 的架构整体上还是沿用 Chromium 的,所以先对 Chromium 的架构进行浅析是有必要的,首先看其进程架构。

参考这个经典的架构图,我们需要注意如下几点:
- Chromium 分为 Browser 主进程和若干个 Render 进程,一个个 Render 进程对应着我们浏览器中的一个个 tab,而每个 render 进程通过分配的端口号与远程的 server 建立连接。
进程的理解
计算机系统角度。进程是资源分配的最小单位,进程拥有独立的上下文。
计算机网络角度。网络通信宏观上是两个可计算的终端设备之间的通信,微观上是两个进程之间的通信,在 tcp/ip 的通信协议下,tcp 协议中两个设备需要提供 ip:port 来建立一个 socket 连接,所以每个进程都应有分配的端口号,以让其建立网络通信。
- 进程之间通过 IPC 通信。这里主要是 Browser 进程和 Render 进程之间通信,主进程包含了 ui主线程、I/O 线程、File 线程等,Render 进程要想进行 io 操作就需要通过 IPC 通信。
IPC vs RPC
inter-process communication 和 remote-process communication,二者区别在于是否是在机器内部建立进程间通信,而我们只需要提供一个 ip:port 就可以建立 tcp 连接。
- I/O 线程主要用来完成进程间通信(IPC)、网络通信。
这里就比较好理解了,IPC 和 RPC 的区别就是是否在同一机器,而这里 I/O 线程便统一处理了这类事情:不仅提供对本机的进程间通信,还能提供对远程机器上进程的通信。
- 那么这样看来,Render 进程只用处理 IPC 通信 。由于 I/O 线程负责 IPC 通信,所以我们经常将 I/O 和浏览器的 message loop 放在一起讲,因为 Render 进程中的回调场景也就:timers、I/O 这两种。
Chromium 的多线程、Message Loop
Chromium 中与线程相关的类有两个:Thread 和 PlatformThread,其中 Thread 底层调用 PlatformThread 创建线程。
Thread是一个用来创建带消息循环的线程。当我们创建一个 Thread 后,调用它的成员函数 Start 或者StartWithOptions 就可以启动一个带消息循环的线程,也就是说:每一个通过base::Thread创建出来的线程都拥有一个MessageLoop。
Message Loop与Message Pump的关系:MessageLoop 类内部通过成员变量 run_loop_ 指向的一个RunLoop对象和成员变量 pump_ 指向的一个MessagePump对象来描述一个线程的消息循环。
而 MessagePumps 负责处理原生的消息,并且将它们喂给循环,MessagePumps 负责将原生消息和对应的回调结合,以防止其他类型的事件循环饿死。
有如下几种不同的 MessagePumpTypes:
- DEFAULT: 仅支持 tasks 和 timers。
- UI: 支持原生 ui 事件(例如窗口消息)。
- IO: 支持异步 IO(而非文件 I/O)。
- CUSTOM: 用户提供对 MessagePump 接口的自定义实现。
不同的平台,它们对应有不同的 MessagePump 的子类,这些子类被包含在 MessageLoopForUI 和 MessageLoopForIO 类中。
在 UI 线程内置了 message loop ,并且针对不同的平台,其 message loop 的实现也有所不同,如下图:

关于多线程的具体实现原理和 Message Loop 的实现原理,可以参考:
此处我们仅需要知道在 chromium 中,针对不同的平台,Chromium 在 UI 线程实现了各自不同的 message loop。
Electron 如何将 Node 与 Chromium 的事件循环统一
Chromium 是多进程的架构,主进程负责 GUI 例如创建窗口等等,而渲染进程处理 web 页面的运行和渲染,所以如果想将 nodejs 与 Chromium 集成,需要考虑如何将 Nodejs 与 Chromium 的主进程、渲染进程事件循环机制结合在一起。
Node的事件循环基于libuv实现,Chromium基于message bump实现,然而,主线程只能同时运行一个事件循环,因此需要将两个完全不同的事件循环整合起来。
Electron 首先采用的是,用 libuv 代替 chromium 中的 message loop 的方案。
按照分析,渲染进程比较好处理,按照我们前面分析,因为消息循环只用 IPC 和一些 timers,所以只需要用 libuv 便可实现这些接口。
然而,比较难处理的是主进程的 GUI,Chromium 中每个平台都有自己的 GUI message loop 机制,例如 macos 的 chromium 使用 NSRunLoop,而 Linux 使用 glib,Electron 试了大量的方法来提取原生 GUI 消息循环之外所隐藏的文件 descriptor,然后将其反馈给 libuv 循环,但是在一些边界案例中,仍然有一些难以解决的问题。
最终,Electron 尝试用一个小间隔的定时器来轮训 GUI 的事件 loop,结果是进程会消耗大量的 CPU 资源,并且一些操作会有很大延时。
所以这种方案不太合适。但是伴随着 libuv 的成熟, Electron 又想是否可以采用 Chromium 的 message loop 为主要的循环,让 libuv 集成到 message loop 呢?
随着 backend fd 被引入到了 libuv 中,它就可以作为一个 在 event loop 中 libuv 轮训的文件描述符(或者handle)而存在,因此通过轮序这个 backend fd,便可使在libuv 中发生新事件时,及时通知 Chromium message loop 处理成为可能。 所以整个流程可以用图表示为:
此外,需要注意的是,Electron 是另起一个 embed_thread_ 线程监控 libuv 的 uv_backend_fd 文件修饰符,而不是直接调用 libuv 的 api 以运行 libuv 的事件循环,这样做是为了保证线程安全。仔细一想,如果仅依靠 libuv 的调用 api 还是很难监控到 libuv 事件,从而引发两个事件循环机制不统一,出现线程安全问题。
总结来说:Electron 是将 libuv 集成到 Chromium 的 message loop 中,但是,相比于直接运行 libuv 事件循环,Electron 用更加底层的 uv_backend_fd 作为替代,去监听 libuv 的 events,这样使 Node 的事件在 Chromium 架构下能够得到高效、安全处理。
Electron Node_bindings 源码分析
上一节的分析还是流于表面的分析,源码在 electron/shell/common/node_bindings.cc 实现,本文对其做简要分析。
入口
Electron 能够访问 Nodejs api 的地方分为三种场景:Render 进程、主进程、worker 线程,其调用入口分别在如下三个地方:

但是调用的流程大概一致:
- node_bindings_->PrepareEmbedThread
- node_bindings_->createEnvironment 获取 env
- electron_bindings_->BindTo
- node_bindings_->LoadEnvironment(env)
- node_bindings_->StartPolling();
PrepareEmbedThread
如其名,准备集成线程。
参考上一节的内容,Electron 是另起一个 embed_thread_ 线程监控 libuv 的 uv_backend_fd 文件修饰符,所以在最开始就通过 node_bindings 创建一个单独线程,核心代码:
void NodeBindings::PrepareEmbedThread() {
// 开启一个 worker 线程,当有 uv 事件时,会打断主 loop。
uv_sem_init(&embed_sem_, 0);
uv_thread_create(&embed_thread_, EmbedThreadRunner, this);
}
其中 EmbedThreadRunner 的代码就是 Electron 如何轮训的具体实现方式:
// static
void NodeBindings::EmbedThreadRunner(void* arg) {
auto* self = static_cast<NodeBindings*>(arg);
while (true) {
// Wait for the main loop to deal with events.
// 线程锁,等待可以操作时机
uv_sem_wait(&self->embed_sem_);
if (self->embed_closed_)
break;
// Wait for something to happen in uv loop.
// Note that the PollEvents() is implemented by derived classes, so when
// this class is being destructed the PollEvents() would not be available
// anymore. Because of it we must make sure we only invoke PollEvents()
// when this class is alive.
// 轮训 io,并类似 node io_poll 执行 callbacks
self->PollEvents();
if (self->embed_closed_)
break;
// 唤醒主线程处理事件
self->WakeupMainThread();
}
}
类似 node 中 uv__io_poll 实现,对于 PollEvents 在不同平台下有不同的实现,它们都先获取 uv_backend_timeout 的延时,然后调用系统级别的接口获取 I/O 事件。
然后唤醒主线程处理 I/O
// 通知主线程本轮 Node 循环执行结束,让主线程处理相关的事件
void NodeBindings::WakeupMainThread() {
DCHECK(task_runner_);
task_runner_->PostTask(FROM_HERE, base::BindOnce(&NodeBindings::UvRunOnce,
weak_factory_.GetWeakPtr()));
}
这里的 task_runner_->PostTask 就是正如该文所讲,创建任务以分发给主线程。
Environment
核心代码如下:
node::Environment* NodeBindings::CreateEnvironment(
v8::Handle<v8::Context> context,
node::MultiIsolatePlatform* platform) {
// isolate 数据获取
v8::Isolate* isolate = context->GetIsolate();
isolate_data_ = node::CreateIsolateData(isolate, uv_loop_, platform);
// 基于 isolate 数据、context等参数创建 env
env = node::CreateEnvironment(
isolate_data_, context, args, exec_args,
static_cast<node::EnvironmentFlags::Flags>(flags));
}
笔者理解环境应该与 libuv 没有太大关系,此处只是为了创建一个 JavaScript 的可执行环境,所以里边也有 v8 引擎相关的代码,其中只传入了 uv_loop_,原因还未探究清楚,猜测应该是为了环境中的一些代码解析逻辑需要这些循环信息。
环境的创建应该是为我们提供一个个可以供 js 调用的 Node 接口,即使这些 js 代码能够被解析执行。
StartPolling
线程、环境准备就绪,需要给一个开始的时机
void NodeBindings::StartPolling() {
// Avoid calling UvRunOnce if the loop is already active,
// otherwise it can lead to situations were the number of active
// threads processing on IOCP is greater than the concurrency limit.
if (initialized_)
return;
initialized_ = true;
// 主线程创建 message loop
task_runner_ = base::ThreadTaskRunnerHandle::Get();
// 执行 uv loop 一次,来让 uv__io_poll 添加所有的 io 事件
UvRunOnce();
}
其中 UvRunOnce 中执行了:
// Tell the worker thread to continue polling.
uv_sem_post(&embed_sem_);
目的是为让集成的 worker 线程继续轮训。
注意,
uv_sem_wait、uv_sem_post是用来实现线程同步。electron 在实现事件循环集成的功能时,创建了一个单独的线程来监听 backend_fd,那么 Electron 是如何实现线程同步的呢?答案是基于 uv_sem_await 和 uv_sem_post 的信号量机制实现互斥锁。libuv 中提供 信号量 api 实现线程同步,它们分别是 uv_sem_init、uv_sem_destroy、uv_sem_post、uv_sem_wait、uv_sem_trywait 这五个。
信号量作为一个比互斥锁更宽泛的资源竞争解决方案,在操作系统上也是存在的,它即可用在多线程里对于内存的竞争访问,也可以用在多进程对于同一资源的竞争访问,libuv封装的信号量API是基于内存信号量的,用于解决线程间的同步问题。
还有一种信号量叫做有名信号量,可以用在多进程同步上,libuv没有做封装。
信号量有两个操作,分别是P操作和V操作,uv_sem_wait对应P操作,uv_sem_post对应V操作。
信号量用一个数值S表示,在uv_sem_init的时候可以设置。
当执行P操作时,判断S是否大于0,如果不大于0,那么无法进入临界区,代码会阻塞,如果大于0,那么S -= 1,进入临界区。
当执行V操作时,S+=1,如果S大于0,那么就会唤醒其他进程被阻塞的代码进入临界区。
信号量的两个操作P和V都是原子性的,不可能被打断,所以可以保证S的变化是可控的。
补充参考
node 的事件循环机制及 uv_backend_timeout 的作用
参考# 深入理解NodeJS事件循环机制一文所讲,Nodejs 的事件循环分为如下几个阶段。
┌───────────────────────┐
┌─>│ timers │
│ └──────────┬────────────┘
│ ┌──────────┴────────────┐
│ │ I/O callbacks │
│ └──────────┬────────────┘
│ ┌──────────┴────────────┐
│ │ idle, prepare │
│ └──────────┬────────────┘ ┌───────────────┐
│ ┌──────────┴────────────┐ │ incoming: │
│ │ poll │<─────┤ connections, │
│ └──────────┬────────────┘ │ data, etc. │
│ ┌──────────┴────────────┐ └───────────────┘
│ │ check │
│ └──────────┬────────────┘
│ ┌──────────┴────────────┐
└──┤ close callbacks │
└───────────────────────┘
其中主要关注 timers、poll、check 阶段:
-
timers:用来执行定时器事件的回调。
-
poll:poll 中文含义
轮训,此处就是一直轮训 I/O 事件。 -
check:执行
setImmediate()设定的 callbacks。
具体可以参考该文中的一些示例
此处需要注意的是 check 阶段——执行 setImmediate() 设定的 callbacks,并结合github.com/xtx1130/blo… 一文中提到的 setImmediate 实现原理,
poll阶段是不断轮训执行 callback,所以是会阻塞的。具体的调用代码是uv__io_poll(loop, timeout);,这里的timeout就是超时时间,具体获取timeout的源代码如下:
int uv_backend_timeout(const uv_loop_t* loop) {
if (loop->stop_flag != 0)
return 0;
if (!uv__has_active_handles(loop) && !uv__has_active_reqs(loop))
return 0;
if (!QUEUE_EMPTY(&loop->idle_handles))
return 0;
if (!QUEUE_EMPTY(&loop->pending_queue))
return 0;
if (loop->closing_handles)
return 0;
return uv__next_timeout(loop);
}
上述代码可见,在一些情况下,timeout 超时时间可以是
0—— 即可以直接跨过poll阶段到达下一个check阶段,而check阶段就是setImmediate执行的阶段。这些可以跨过poll阶段的情况有:
- 使用
stop_flag直接强制跨过。- event-loop中没有活跃的handle且没有活跃的请求时。
- idle不为空的时候。
- pending_queue不为空的时候(
uv__io_init会初始化pending_queue)。- 关闭handle的时候。
而 setImmediate 正是利用了第 3 点的 idle,实现了对 poll 阶段的跨越,直接进入 check 阶段。
结合在一起可以这样理解:一般情况下,Nodejs 的程序最终都会停留在 poll(轮训 I/O) 阶段,也就是阻塞在uv__io_poll(loop, timeout); 这个函数中,但是这个阶段不能一直持续,所以有一个 timeout 的参数控制轮训持续时间,而当存在 setImmediate 调用时,这个 timeout 将会设置为 0,就是立即跳过 uv_io_poll 函数,进入 check 阶段(也就是 setImmediate 的执行阶段)。
类似的,Electron 也有相似的代码,在 window 平台下的 shell\common\node_bindings_win.cc 中,
void NodeBindingsWin::PollEvents() {
// If there are other kinds of events pending, uv_backend_timeout will
// instruct us not to wait.
// 如果这里有其他类型阻塞 events,uv_backend_timeout 会指导我们不要继续等待。
timeout = uv_backend_timeout(uv_loop_);
GetQueuedCompletionStatus(uv_loop_->iocp, &bytes, &key, &overlapped, timeout);
// Give the event back so libuv can deal with it.
if (overlapped != NULL)
PostQueuedCompletionStatus(uv_loop_->iocp, bytes, key, overlapped);
}
查阅微软 GetQueuedCompletionStatus 的文档:
Attempts to dequeue an I/O completion packet from the specified I/O completion port. If there is no completion packet queued, the function waits for a pending I/O operation associated with the completion port to complete.
尝试从指定 I/O 完成 port 下,出队一个 I/O 完成包,如果当前没有完成包在队列中,那么函数等待与 completion port 相关的 I/O 操作完成。
笔者认为,不同平台的 GUI 事件才会有兼容性的问题,而这里应该是与 window GUI 相关的事件处理,Electron 利用 Node 中 timeout 参数,让 GUI 事件在 Nodejs 的事件循环下运行。(理解可能有误,欢迎讨论指正)
应该这样理解,参考官网的说法:
原文:since I was using the system calls for polling instead of libuv APIs, it was thread safe.
译文:由于我调用系统级的方法来 poll(轮训),而不是直接调用 libuv 的 api,这样会使线程安全。
笔者认为这里的 GetQueuedCompletionStatus 应该与 uv_io_poll 作用类似。
node_bindings 的作用
如下内容参考 stackoverflow.com/questions/2… 的回答,并翻译如下:

Bindings 实际上将两种不同变成语言“绑定”的库,这样代码可以用一种语言编写,然后用另外一种语言使用。使用 Bindings ,我们不需要只因为是不同的语言,就编写所有的代码。另外一方面,性能角度考虑, C/C++ 比 JavaScript 更快。
V8 引擎是用 C++ 编写的, libuv 提供了异步 IO 操作的能力,但是 libuv 也是用 C 实现的,然而 Node 核心 api 都是以 JavaScript 形式呈现,我们编写业务代码都是用 js 编写并调用 Node 的核心 api,所以如何将两种不同语言代码之间连接起来,其中 Bindings 起了关键作用。
而 Electron 将 Chromium 配套 Nodejs 使用,但是却没有将两者的 event loop 结合一起,而是利用了 Nodejs 的 node_binds 特性,在此基础上,Chromium 和 Nodejs 部分就不需要再进行修改和后续编译,就能够轻松得以更新(更新最新的 Nodejs 和 Chromium 版本)。
小结
笔者针对 Electron 在 Render Process 如何调用 Node API 的特性,做了相关的探究,基于 Nodejs 相关知识和 Chromium 的架构,对Electron internals: Message loop integration一文所讲进行分析、拓展,并对源码做了简单分析。
由于笔者对 C++ 不太熟悉,文中如有描述不对地方,欢迎批评指正。
参考文章
- chromium多进程架
- # 从 Electron 架构出发,深究 Electron 跨端原理 | 多图详解
- NWjs 与 Electron 的区别
- Electron internals: Message loop integration
- Electron Internals: Building Chromium as a Library
- 从libuv源码学习线程池
- 整合 message loop
- 理解WebKit和Chromium: 消息循环(Message Loop)
- # Electron: The Event Loop Tightrope - Shelley Vohr | JSHeroes 2019
- # Chromium多线程模型设计和实现分析
- # libuv漫谈之线程
- # Chromium 消息循环和线程池 (MessageLoop 和 TaskScheduler)
- # Chromium 线程与消息循环
转载自:https://juejin.cn/post/7153280595412385805
图来自——《cross-platform desktop applications using NW.js and electron》 的 6.1.2 章