Electron 集成Node event loop 和Chromium message loop 事件循环原理探究

1,327 阅读17分钟

前言

最近一直在琢磨为啥 Electron 可以在渲染进程直接访问到 Nodejs 的 api,仿佛就是浏览器环境与 Node 环境融合到一起了似的。

比如当你用如下方式创建一个 Electron 的窗口:

  const mainWindow = new BrowserWindow({
    width: 800,
    height: 600,
    webPreferences: {
      contextIsolation: false,
      nodeIntegration: true,
    }
  })

那么,你就可以调用挂载在 window 上的 Node API:

image.png

那么底层是怎么实现的呢,笔者最近做了相关的研究,将相关的探究结果总结如下,希望对 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 都有用到它

V8Node 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 的整体架构可以用下面这张图总结:

image.png

跨平台桌面框架的难题

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 并修改人家源码,具体架构图可以总结为:

image.png 图来自——《cross-platform desktop applications using NW.js and electron》 的 6.1.2 章

那么, Electron 为实现 Chromium 和 Nodejs 事件循环结合,它又做了哪些事情呢?接下来本文将就此进行探究。

chromium 的多进程架构

研究如何结合之前,我们需要对 Chromium 有所了解,因此实际上 Electron 的架构整体上还是沿用 Chromium 的,所以先对 Chromium 的架构进行浅析是有必要的,首先看其进程架构。

image.png

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

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

进程的理解

计算机系统角度。进程是资源分配的最小单位,进程拥有独立的上下文。

计算机网络角度。网络通信宏观上是两个可计算的终端设备之间的通信,微观上是两个进程之间的通信,在 tcp/ip 的通信协议下,tcp 协议中两个设备需要提供 ip:port 来建立一个 socket 连接,所以每个进程都应有分配的端口号,以让其建立网络通信。

  1. 进程之间通过 IPC 通信。这里主要是 Browser 进程和 Render 进程之间通信,主进程包含了 ui主线程、I/O 线程、File 线程等,Render 进程要想进行 io 操作就需要通过 IPC 通信。

IPC vs RPC

inter-process communication 和 remote-process communication,二者区别在于是否是在机器内部建立进程间通信,而我们只需要提供一个 ip:port 就可以建立 tcp 连接。

  1. I/O 线程主要用来完成进程间通信(IPC)、网络通信。

这里就比较好理解了,IPC 和 RPC 的区别就是是否在同一机器,而这里 I/O 线程便统一处理了这类事情:不仅提供对本机的进程间通信,还能提供对远程机器上进程的通信。

  1. 那么这样看来,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

——blog.csdn.net/Luoshengyan… ——keyou.github.io/blog/2019/0…

Message LoopMessage Pump 的关系:MessageLoop 类内部通过成员变量 run_loop_ 指向的一个RunLoop对象和成员变量 pump_ 指向的一个MessagePump对象来描述一个线程的消息循环。

——blog.csdn.net/Luoshengyan…

MessagePumps 负责处理原生的消息,并且将它们喂给循环,MessagePumps 负责将原生消息和对应的回调结合,以防止其他类型的事件循环饿死。

有如下几种不同的 MessagePumpTypes

  • DEFAULT: 仅支持 tasks 和 timers。
  • UI: 支持原生 ui 事件(例如窗口消息)。
  • IO: 支持异步 IO(而非文件 I/O)。
  • CUSTOM: 用户提供对 MessagePump 接口的自定义实现。

不同的平台,它们对应有不同的 MessagePump 的子类,这些子类被包含在 MessageLoopForUI 和 MessageLoopForIO 类中。

——blog.51cto.com/u_15469043/…

在 UI 线程内置了 message loop ,并且针对不同的平台,其 message loop 的实现也有所不同,如下图: image.png

关于多线程的具体实现原理和 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 处理成为可能。 所以整个流程可以用图表示为: image.png

此外,需要注意的是,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 线程,其调用入口分别在如下三个地方:

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

补充参考

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阶段的情况有:

  1. 使用stop_flag直接强制跨过。
  2. event-loop中没有活跃的handle且没有活跃的请求时。
  3. idle不为空的时候
  4. pending_queue不为空的时候(uv__io_init会初始化pending_queue)。
  5. 关闭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… 的回答,并翻译如下:

image.png

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++ 不太熟悉,文中如有描述不对地方,欢迎批评指正。

参考文章