参考文章
- 【译+源码分析】Electron内部:整合 Message Loop
- Electron的技术架构
- # Electron 集成Node event loop 和Chromium message loop 事件循环原理探究
Electron的技术架构
下图是Chromium的架构图。主进程负责管理窗口、标签页、右键菜单等等,这一部分跟操作系统强相关。渲染进程负责网页的渲染,这一部分跟操作系统无关。
参考这个经典的架构图,我们需要注意如下几点:
- Chromium 分为 Browser 主进程和若干个 Render 进程,一个个 Render 进程对应着我们浏览器中的一个个 tab,而每个 render 进程通过ipc与远程的 server 建立连接。
下图是Electron的架构图,可以看到他的核心工作就是把Node.js塞进去。
技术难点:Node.js事件循环基于libuv,但Chromium基于message bump,而一个线程在同一时间只能运行一个事件循环。
一开始,Electron是用libuv来实现message bump,在渲染进程中libuv实现message bump比较简单。但是在主进程内,由于各个操作系统的GUI都不一样,Mac是NSRunLoop、Linux是glib,所以工程量很大而且各种边界情况都处理不好。
后来libuv引入了backend_fd的概念,相当于是libuv轮询事件的文件描述符。通过轮询backend_fd可以知道libuv的一个新事件。这样就可以实现将Node.js集成到Chromium。
整合的内部机制和发展
已经有很多尝试使用Node进行GUI编程框架,例如用于GTK +绑定的node-gui和用于QT绑定的node-qt。 但是他们都没有在生产环境中工作,因为GUI开发套件有自己的消息循环,而Node使用libuv作为自己的事件循环,并且主线程只能同时运行一个循环。 因此,在Node中运行GUI消息循环的常用技巧是以非常小的时间间隔用interval把GUI的消息注入到Node消息循环中,但这会使GUI界面响应变慢并占用大量CPU资源。
在Electron的开发过程中,我们遇到了同样的问题,但方式相反:我们必须将Node的事件循环集成到Chromium的消息循环中。
Main进程和渲染进程
在我们深入了解消息循环集成的细节之前,我将首先解释Chromium的多进程体系结构。
在Electron中,有两种类型的进程:主进程和渲染进程(实际上这非常简化,完整的视图请参阅多进程体系结构)。 主流程负责像创建窗口一样的GUI工作,而渲染器进程仅处理运行和呈现网页。
Electron允许使用JavaScript来控制Main进程和渲染进程,这意味着我们必须将Node集成到两个进程中。
用libuv来体会Chromium的message loop
我的第一次尝试是用libuv重新实现Chromium的消息循环。
在渲染器进程很容易做到,因为它的消息循环只监听文件描述符和定时器,而我只需要用libuv实现接口。
然而,主进程中显然更加困难。 每个平台都有自己的GUI消息循环--我理解是将chromium的消息塞到node中相对很复杂。 macOS Chromium使用NSRunLoop,而Linux使用glib。 我尝试了很多窍门从本地GUI消息循环中提取底层文件描述符,然后将它们提供给libuv进行迭代,但仍遇到无法工作的边界情况。
所以最后我添加了一个定时器来以小的间隔轮询GUI消息循环。 结果是该进程占用大量CPU使用率,并且某些操作有很长时间的延迟。
轮询Node的事件循环在一个单独的线程中
随着libuv的成熟,开始有可能使用另一种方法。
backend fd的概念被引入到libuv中,可以理解为libuv轮询其事件循环的文件描述符(或句柄)。 因此,通过轮询backend fd,可以在libuv中发生新事件时收到通知。
所以在Electron中,我创建了一个单独的线程来轮询backend fd,因为我是使用系统调用来轮询而不是libuv API,所以它是线程安全的。 每当libuv事件循环中发生新事件时,都会将消息发布到Chromium的消息循环中,然后在主线程中处理libuv事件--显然, 反过来将nodejs的事件循环塞进chromium中比较简单。
通过这种方式,我不用修改Chromium和Node源码,并且在主进程和渲染进程中都使用了相同的代码。
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的变化是可控的。