[整合转载]Electron内部:整合 Node和Chrome的message loop

483 阅读8分钟

参考文章

Electron的技术架构

下图是Chromium的架构图。主进程负责管理窗口、标签页、右键菜单等等,这一部分跟操作系统强相关。渲染进程负责网页的渲染,这一部分跟操作系统无关。

image.png

参考这个经典的架构图,我们需要注意如下几点:

  1. Chromium 分为 Browser 主进程和若干个 Render 进程,一个个 Render 进程对应着我们浏览器中的一个个 tab,而每个 render 进程通过ipc与远程的 server 建立连接。

下图是Electron的架构图,可以看到他的核心工作就是把Node.js塞进去。

image.png 技术难点: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。

image.png

整合的内部机制和发展

已经有很多尝试使用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 线程,其调用入口分别在如下三个地方:

image.png

但是调用的流程大概一致:

  1. node_bindings_->PrepareEmbedThread
  2. node_bindings_->createEnvironment 获取 env
  3. electron_bindings_->BindTo
  4. node_bindings_->LoadEnvironment(env)
  5. 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_waituv_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的变化是可控的。

——参考:zhuanlan.zhihu.com/p/25973650