How Blink Works 中文译文

u4内核

2019-01-03

Chromium 的工程师们写了两篇技术文章 How Blink Works (opens new window)How cc Works (opens new window),分别介绍了 Chrome 浏览器内核内部的两个重要模块 Blink 和 cc 内部设计和实现的一些细节。对于想要了解 Chrome 内核内部实现的同学,这两篇文章提供了不错的入门指引。在征得作者同意后,我将其翻译成中文,以馈读者。

文中部分术语觉得难以翻译成合适的中文的,我会保留原文。对于一些较为复杂的概念会根据自己的理解给出一些额外的解释。如果有我理解有误,没有正确翻译的地方,也请读者留言指出。

Blink 的开发并不容易。对于新的 Blink 开发者来说,这种不容易体现在内部的许许多多 Blink 特定的概念和编码约定,它们是为了实现一个高性能渲染引擎而引入的。对于有经验的 Blink 开发者来说也是不容易的,因为 Blink 非常庞大,并且对性能,内存占用和安全非常敏感。

本文旨在提供一篇指引,从一万公尺的高空概览 “Blink 是如何工作” 的全貌,我希望这能够帮助 Blink 的开发者快速了解 Blink 的整体架构设计:

本文并不是一篇完整的教程,去解析 Blink 架构的细节和编码规则(这些部分也容易因为发生变化而过时)。相反,本文简明地描述了 Blink 的基础设计,它们短期内不太容易发生变化,如果读者需要了解更多,本文包含了其它资料的指引可供进一步阅读; 本文也不对特定的特性进行解析(比如 ServiceWorkers,editing)。相反,本文描述的是基础特性,它们被其它模块所广泛使用(比如内存管理,V8 APIs)。 有关 Blink 开发的更多一般信息,请参阅 Chromium wiki 页面 (opens new window)

Blink 是 Web 平台的渲染引擎。粗略地说,Blink 实现了将网页内容绘制到一个浏览器标签内的所有代码:

  1. 实现了 Web 平台的规范(也就是 HTML 标准 (opens new window)),包括 DOM,CSS 和 Web IDL;
  2. 内嵌 V8 运行 JavaScript;
  3. 通过底层的网络栈请求资源;
  4. 构建 DOM 树;
  5. 计算样式和排版;
  6. 内嵌 Chrome Compositor (opens new window) 用于绘图; Blink 的使用者比如 Chromium,Android WebView 和 Opera 通过 content public APIs (opens new window) 内嵌 Blink 并调用。 undefined

Blink 从代码结构的角度来看,"Blink" 一般意味着 "//third_party/blink/" 目录下的代码。从项目的角度来看,"Blink" 一般意味着实现 Web 平台特性的项目。实现这些 Web 特性的代码分布在 "//third_party/blink/", "//content/renderer/","//content/browser/" 和其它地方。

[译注]

  1. 关于 Embedder 的概念,可以参考我的一篇旧文 - 理解 Embedder,理解 Chromium 的系统层次结构。
  2. “Blink 实现了将网页内容绘制到一个浏览器标签内的所有代码” 这句话我的理解应该是指广义的 "Blink",并不仅仅指 "//third_party/blink/"。

# 进程/线程架构

# 进程

Chromium 使用多进程的架构 (opens new window)。 Chromium 拥有一个 browser 进程和 N 个沙盒 renderer 进程。Blink 运行在 renderer 进程。

有多少个 renderer 进程会被创建?为了安全的原因,对跨站的 documents 进行内存地址空间隔离是非常重要的(这被称为 Site Isolation (opens new window))。理想的情况,每个站点都应该分配一个独立的 renderer 进程,但是实际上,当用户打开太多的标签页,或者设备没有足够的内存,就很难限制每个 renderer 进程都只对应一个站点。所以有时一个 renderer 进程会被多个来自不同站点的 iframes 或者标签页所共享。这也意味着一个标签页下的 iframes 可能位于不同的 renderer 进程,或者不同标签页的 iframes 位于同一个 renderer 进程。renderer 进程,iframes,标签页三者之间不是完全 1:1 的映射关系。

因为 renderer 进程运行在沙盒环境下,Blink 需要请求 browser 进程去处理系统调用(比如文件访问,播放音频等),还有访问用户的账号数据(比如 cookie,密码等)。browser-renderer 进程间通讯是由 Mojo (opens new window) 来实现的。(过去是使用 Chromium IPC,现在还有很多代码仍旧继续使用。不过 Chromium IPC (opens new window) 已经逐步放弃,实际上它的底层也换成了用 Mojo 实现) 对 Chromium 来说,服务化 (opens new window)还在继续进行,browser 进程会被抽象成一组服务的集合。从 Blink 的角度来看,Blink 可以通过 Mojo 跟其它服务和 browser 进程进行交互。

Process undefined

如果你希望了解更多:

  1. 多进程架构 (opens new window)
  2. Blink 的 Mojo 编程:platform/mojo/MojoProgrammingInBlink.md

# 线程

在 renderer 线程会创建多少个线程?

Blink 会拥有一个主线程,N 个 worker 线程和一些内部线程。

几乎所有的主要活动都发生在主线程。所有的 JavaScript 调用(除了运行在 workers 线程的 JS 代码),DOM,CSS,样式和排版计算都运行在主线程。Blink 经过高度优化来最大化主线程的性能,它的架构被认为主要是单线程的。

Blink 可能会创建若干 worker 线程来运行 Web Workers (opens new window)Service Worker (opens new window),和 Worklets (opens new window)

Blink 和 V8 还可能会创建一些内部线程来处理 webaudio,database,GC 等等。

对跨线程通讯,你需要使用 PostTask APIs 来发送消息。我们并不鼓励使用共享内存编程,除了少数地方因为性能的原因才需要使用。这也是你在 Blink 的代码里面并没有看到太多需要使用 Mutex 加锁的原因。 undefined

Thread 如果你希望了解更多:

  1. Blink 的线程编程:platform/wtf/ThreadProgrammingInBlink.md
  2. Workers: core/workers/README.md (opens new window)

Blink 的初始化位于 BlinkInitializer::Initialize() (opens new window),在执行任何 Blink 代码之前必须先调用该方法。

另一方面来说,Blink 并没有提供终结的方法,也就是说,我们会强制退出 renderer 进程,也不做任何清理。一个原因是因为性能。另外一个原因是,通常非常困难以优雅有序的方式去清理 renderer 进程的所有内容(并且也不值得去做)。

# 目录结构

Content public APIs (opens new window) 是 embedders 使用来嵌入 Blink 渲染引擎的 API 层。Content public APIs 需要小心地进行维护,因为它们会被暴露给 embedders。

Blink public APIs (opens new window) 是将 //third_party/blink/ 的功能暴露给 Chromium 的 API 层。这个 API 层是从 WebKit 时代继承过来的历史产物。在 WebKit 时代,Chromium 和 Safari 共享同样的 WebKit 实现,所以我们需要一个 API 层来暴露 WebKit 内部的功能给 Chromium 和 Safari 使用。现在 Chromium 是 //third_party/blink/ 的唯一 embedder,所以实际上我们已经不再需要一个额外的 API 层。我们正在积极地减少 Blink public APIs 的数量,通过将更多的 web 平台相关的代码从 Chromium 移到 Blink 内部(这个项目被成为 Onion Soup)。 undefined

# 目录结构和依赖性

//third_party/blink/ 的子目录如下。这篇文档 (opens new window)提供了更多的信息:

platform/ - Blink 的低阶功能集合,从庞大的 core/ 中分解出来,包括 geometry 和 graphics 等相关的功能。 core/ 和 modules/ - 在规范定义的所有 web 平台特性的实现。core/ 实现了跟 DOM 紧密相关的特性。modules/ 实现的特性相对来说会更自包含。比如 webaudio,indexeddb。 bindings/core 和 bindings/modules/ - 概念上来说 bindings/core 属于 core/ 的一部分,bindings/modules/ 属于 modules/ 的一部分。放置于独立的目录是因为这部分代码跟 V8 紧密相关。 controller/ 一些使用 core/ 和 modules/ 的高级库。比如,devtools 的前端。 依赖关系的方向如下:

Chromium => controller/ => modules/ and bindings/modules/ => core/ and bindings/core/ => platform/ => low-level primitives such as //base, //v8 and //cc

Blink 很小心地维护哪些低阶基础模块可以被 //third_party/blink/ 所使用。

如果你希望了解更多:

目录结果和依赖性:blink/renderer/README.md (opens new window)

[译注] Blink 需要很小心地避免过多的外部依赖,维持更高程度的自包含性,但是像 //base 和 //cc 这样的基础模块都不能直接使用的话,也会导致非常复杂的桥接层,重复实现和多余的类型转换,也妨碍了 Blink 后续的演进。早期 Blink 的确是不能使用 //base 和 //cc。

# WTF

WTF 是 “Blink 版本的 base” 库,位于 platform/wtf/. 我们试图尽可能统一 Chromium 和 Blink 所使用的基础编码原语,所以 WTF 应该规模很小。这个库仍然被需要是因为仍有一些类型,容器和宏是针对 Blink 特定的用途所优化的,另外还有 Olipan(Blink GC)。如果一个类型在 WTF 中存在,Blink 需要优先使用 WTF 的版本而不是 //base 或者 std 库。最常见的例子是 vectors,hashsets,hashmaps 和 strings。Blink 应该使用 WTF::Vector,WTF::HashSet,WTF::HashMap,WTF::String 和 WTF::AtomicString,而不是 std::vector,std::*set,std::*map 和 std::string。

如果你希望了解更多:

如何使用WTF:platform/wtf/README.md (opens new window)

# 内存管理

在 Blink 关注的范畴内,你需要了解三类不同的内存分配器:

class SomeObject {
  USING_FAST_MALLOC(SomeObject);
  static std::unique_ptr<SomeObject> Create() {
    return std::make_unique<SomeObject>();  // Allocated on PartitionAlloc's heap.
  }
};

使用 PartitionAlloc 分配的对象生命周期应该使用 scoped_refptr<> 或者 std::unique_ptr<> 来管理。我们强烈地不鼓励手动管理对象的生命周期。手动调用 delete 在 Blink 里面是被禁止的。

使用 GarbageCollected 可以在 Olipan 堆上分配一个对象:

class SomeObject : public GarbageCollected<SomeObject> {
  static SomeObject* Create() {
    return new SomeObject;  // Allocated on Oilpan's heap.
  }
};

Oilpan 分配的对象生命周期是由垃圾收集机制来管理的。你必须使用特殊的指针来持有 Oilpan 堆上的对象(比如,Member<>, Persistent<>)。需要了解 Oilpan 的编码限制可以查阅这份 API参考 (opens new window)。其中最重要的限制是不允许在一个 Olipan 对象的析构函数里面访问其它 Olipan 对象,因为析构的顺序是无法保证的。

如果你不使用 USING_FAST_MALLOC() 或者 GarbageCollected,对象就会直接分配在系统的 malloc 堆上。我们强烈地不建议在 Blink 里面这样做。所有的 Blink 对象都应该使用 PartitionAlloc 或者 Oilpan 进行分配,规则如下:

默认使用 Oilpan。 仅当满足以下条件时可以使用 PartitionAlloc 1) 对象的生命周期非常清晰,使用 std::unique_ptr<> 就足够了,2) 使用 Olipan 分配对象增加了大量的复杂度,或者 3) 使用 Oilpan 分配对象给垃圾收集运行时带来太大不必要的压力。 无论是使用 PartitionAlloc 还是 Oilpan,你都需要非常小心避免造成悬挂指针(注:强烈不建议使用 raw pointers)或者内存泄露。

如果你希望了解更多:

# 任务调度

为了改进渲染引擎的响应性,Blink 的任务应该尽可能异步执行。同步 IPC/Mojo 或者其它可能花费几毫秒的操作都应该尽量避免(虽然有些确实无法避免,比如运行用户的 JavaScript)。

所有在 renderer 进程执行的任务都需要通过 Blink Scheduler 提交,并且需使用合适的任务类型作为参数,比如:

// Post a task to frame's scheduler with a task type of kNetworking
frame->GetTaskRunner(TaskType::kNetworking)->PostTask(..., WTF::Bind(&Function));

Blink Scheduler 维护了多个任务队列,并巧妙地确定任务的优先级,以最大化用户可感知的性能。 指定正确的任务类型使得 Blink Scheduler 可以正确且巧妙地调度任务是非常重要。

如果你希望了解更多:

  • 如何提交任务:platform/scheduler/PostTask.md

# Page, Frame, Document, DOMWindow 和其它

# 概念

Page, Frame, Document, ExecutionContext 和 DOMWindow 这些 Blink 里面的的重要对象,它们的概念如下:

  • 一个 Page 对应着一个标签页(如果 OOPIF 没有开启)。每个 renderer 进程可以拥有多个标签页。
  • 一个 Frame 对应着网页里面的 frame(主 frame 或者 iframe)。每个 Page 都可能包含一个或者多个 Frame,构成树状的结构。
  • 一个 DOMWindow 对应 JavaScript 里面的 window 对象。每个 Frame 拥有一个 DOMWindow。
  • 一个 Document 对应 JavaScript 里面的 window.document 对象。每个 Frame 拥有一个 Document。
  • 一个 ExecutionContext 是 Document(主线程)或者 WorkerGlobalScope(worker 线程)的抽象。 Renderer 进程 : Page = 1 : N Page : Frame = 1 : M Frame : DOMWindow : Document (或者 ExecutionContext) 无论何时都是 1 : 1 : 1 的关系,但是映射的对象可能会发生变化。比如,考虑如下的代码:
iframe.contentWindow.location.href = "https://example.com";

上面的例子里面,Blink 会为 https://example.com 创建一个新的 Window 和新的 Document 对象。但是它们仍然对应原来的 Frame 对象。

(注:更精确地说,仍有一些特定的情况,我们会创建新的 Document 对象,但是仍旧重用原来的 Window 和 Frame 对象。另外还有一些更复杂的情况 (opens new window)。)

如果你希望了解更多:

  • core/frame/FrameLifecycle.md

# 进程外 iframe(OOPIF)

Site Isolation (opens new window) 机制可以进一步保护网页的安全,但是也使得事件变得更复杂。Site Isolation 的基本思路是一个 renderer 进程只对应一个站点。(一个站点是由网页的注册域名和它的 URL scheme 组合定义的。例如,https://mail.example.com 和 https://chat.example.com 被认为是同一个站点,但是 https://noodles.com 和 https://pumpkins.com 就不是)如果一个 Page 包括一个跨站的 iframe,那么这个 Page 应该被两个 renderer 进程所托管。考虑如下页面:

<!-- https://example.com -->
<body>
<iframe src="https://example2.com"></iframe>
</body>

主 frame 和 <iframe> 可能从属于不同的 renderer 进程。一个从属于该 renderer 进程的 frame 对象由 LocalFrame 表征,而不从属于该 renderer 进程的 frame 对象用 RemoteFrame 表征。

上面的例子,如果从主 frame 的角度看,主 frame 是 LocalFrame 而 <iframe> 是 RemoteFrame。从 <iframe> 的角度看,主 frame 是 RemoteFrame 而 <iframe> 是 LocalFrame。

LocalFrame 和 RemoteFrame 之间的通讯通过 browser 进程来处理(它们可能存在于不同的 renderer 进程)。

如果你希望了解更多:

  • 设计文档:Site isolation 设计文档
  • 如何编写符合 Site isolation 的代码:core/frame/SiteIsolation.md

# Detached Frame/Document

Frame/Document 对象可能会处于分离状态。考虑下面的情况:

doc = iframe.contentDocument;
iframe.remove();  // The iframe is detached from the DOM tree.
doc.createElement("div");  // But you still can run scripts on the detached frame.

一个吊诡的事实是你仍然可以在分离的 frame 上面运行脚本和 DOM 操作。但是由于 frame 已经处于分离状态,大部分的 DOM 操作都会失败并抛出错误。不幸的是,已分离的 frame 的行为在规范上并没有明确定义,不同浏览器的实现也有差异。基本上可以期望的是,脚本仍然可以正常运行,但是除了少数适当的例外,大部分 DOM 操作都会失败,例如:

void someDOMOperation(...) {
  if (!script_state_->ContextIsValid()) { // The frame is already detached
    …;  // Set an exception etc
    return;
  }
}

通常这意味着,当 frame 被分离的时候,Blink 需要进行一系列的清理操作。你可以通过继承 ContextLifecycleObserver 来监听该事件,如下所示:

class SomeObject : public GarbageCollected<SomeObject>, public ContextLifecycleObserver {
  void ContextDestroyed() override {
    // Do clean-up operations here.
  }
  ~SomeObject() {
    // It's not a good idea to do clean-up operations here because it's too late to do them. Also a destructor is not allowed to touch any other objects on Oilpan's heap.
  }
};

# Web IDL 绑定

当 JavaScript 访问 node.firstChild 时,将调用 node.h 中的 Node::firstChild()。 它是如何工作的? 让我们来看看 node.firstChild 的实现。

首先,你需要根据规范定义 IDL 文件:

// node.idl
interface Node : EventTarget {
  [...] readonly attribute Node? firstChild;
};

Web IDL 的语法在 Web IDL 规范 (opens new window)中定义。 [...] 称为 IDL 扩展属性。一些 IDL 扩展属性在 Web IDL 规范中定义,而其他的则是 Blink 特定的 IDL 扩展属性 (opens new window)。 除了 Blink 特定的 IDL 扩展属性外,IDL 文件应以符合规范的方式编写(即,只需从规范中复制和粘贴)。

[译注] [Affects=Nothing, PerWorldBindings] readonly attribute Node? firstChild; 像上面的实际例子,Affects=Nothing 和 PerWorldBindings 就是扩展属性,有些是在规范内的,有些是 Blink 自己特有的。

其次,您需要为 Node 定义 C++ 类并为 firstChild 实现 C++ getter:

class EventTarget : public ScriptWrappable {  // All classes exposed to JavaScript must inherit from ScriptWrappable.
  ...;
};

class Node : public EventTarget {
  DEFINE_WRAPPERTYPEINFO();  // All classes that have IDL files must have this macro.
  Node* firstChild() const { return first_child_; }
};

一般情况而言,就是像上面这样。构建 node.idl 时,IDL 编译器会自动为 Node 接口和 Node.firstChild 生成 Blink-V8 绑定。自动生成的绑定位于 //src/out/{Debug,Release}/gen/third_party/blink/renderer/bindings/core/v8/ v8_node.h。当 JavaScript 调用 node.firstChild 时,V8 在 v8_node.h 中调用 V8Node::firstChildAttributeGetterCallback(),然后再调用你在上面定义的 Node::firstChild()。

如果你希望解更多:

# Isolate, Context, World

当你编写涉及 V8 API 的代码时,了解 Isolate,Context 和 World 的概念非常重要。它们分别由代码库中的 v8::Isolate,v8::Context 和 DOMWrapperWorld 表示。

Isolate 对应于物理线程。Isolate:物理线程的比例在 Blink 中是 1:1。主线程有自己的 Isolate。Worker 线程也有自己的 Isolate。

Context 对应于全局对象(在 Frame 的情况下,它是 Frame 的 window 对象)。由于每个 Frame 有自己的 window 对象,因此在 renderer 进程中会有多个 Contexts。当你调用 V8 API 时,你必须确保你处于正确的 Context 中。否则,v8::Isolate::GetCurrentContext() 将返回错误的 Context,在最坏的情况下,它将最终导致对象泄漏并导致安全问题。

World 是用于支持 Chrome 扩展程序中的 content 脚本的概念。World 并不对应 Web 标准中的任何内容。Content 脚本希望与网页共享 DOM,但出于安全原因,Content 脚本的 JavaScript 对象必须与网页的 JavaScript 堆进行隔离。 (并且一个 content 脚本的 JavaScript 堆也必须与另一个 content 脚本的 JavaScript 堆隔离。)为了实现隔离,主线程为网页创建一个 main world,为每个 Content 脚本创建一个 isolated world。Main world 和 isolated world 可以访问相同的 C++ DOM 对象,但它们的 JavaScript 对象是隔离的。我们通过为一个 C++ DOM 对象创建多个 V8 wrappers 来实现这种隔离。即一个 C++ DOM 对象在每个 world 都有一个对应的 V8 wrapper。 undefined

Context,World 和 Frame 之间有什么关系?

想象一下主线程线上有 N 个 Worlds(一个 main world +(N - 1)个 isolated world)。 然后一个 Frame 应该有 N 个 window 对象,一个 window 对象被一个对应的 world 所使用。Context 是对应于 window 对象的概念。 这意味着当我们有 M 个 Frames 和 N 个 Worlds 时,我们有 M * N Contexts(但是 Contexts 是延迟创建的)。

对于 worker 来说,只有一个 World 和一个全局对象。因此只有一个 Context。

再次强调,当你使用 V8 API 时,应该非常小心保证使用正确的 context。 否则,你最终可能会在 isolated world 之间泄漏 JavaScript 对象并导致安全灾难(例如,A.com 的扩展可以操纵来自 B.com 的扩展)。

如果你希望了解更多:

# V8 APIs

V8 API 大部分都是在 //v8/include/v8.h 中定义。由于 V8 API 处于较低层次并且难以正确使用,因此 platform/bindings/ 提供了一堆对 V8 API 进行包装的辅助类。一般而言应该尽可能优先使用辅助类。 如果你的代码必须大量使用 V8 API,那么这些代码文件应该放在 bindings/{core,modules} 中。

V8 使用句柄指向 V8 对象。最常见的句柄是 v8::Local<>,用于指向堆栈中的 V8 对象。必须先在堆栈上分配 v8::HandleScope 后,再使用 v8::Local<>。另外不应在堆栈外使用 v8::Local<>:

void function() {
  v8::HandleScope scope;
  v8::Local<v8::Object> object = ...;  // This is correct.
}

class SomeObject : public GarbageCollected<SomeObject> {
  v8::Local<v8::Object> object_;  // This is wrong.
};

如果是在堆栈外部指向 V8 对象,则需要使用 wrapper tracing (opens new window)。但是,必须非常小心避免创建循环引用。一般而言,V8 API 的确难以使用。如果你不太确定应该怎么做,请咨询 blink-review-bindings@。

如果你希望了解更多:

  • 如何使用 V8 API 和辅助类:platform/bindings/HowToUseV8FromBlink.md

# V8 wrappers

每个 C++ DOM 对象(例如,Node)都有其对应的 V8 wrapper。准确地说,每个 C++ DOM对象在每个 world 都有对应的 V8 wrapper。

V8 wrappers 对它们对应的 C++ DOM 对象持有强引用。反之,C++ DOM 对象对 V8 wrappers 只持有弱引用。 所以如果希望 V8 wrappers 保持存活一段时间,则必须显式地进行声明。 否则,V8 wrappers 将可能被过早回收,而 V8 wrappers 上的 JS 属性将会丢失...

div = document.getElementbyId("div");
child = div.firstChild;
child.foo = "bar";
child = null;
gc();  // If we don't do anything, the V8 wrapper of |firstChild| is collected by the GC.
assert(div.firstChild.foo === "bar");  //...and this will fail.

如果我们不做任何事情,child 就会被 GC 回收,因此 child.foo 将丢失。为了使 div.firstChild 的 V8 wrapper 保持存活,我们需要增加一种机制,“只要 div 所属的 DOM 树从 V8 是可达的,则让 div.firstChild 的 V8 wrapper 处于存活状态”。

有两种方法可以保持 V8 wrappers 存活:ActiveScriptWrappable (opens new window)wrapper tracing (opens new window)

如果你希望了解更多:

# 渲染流水线

从传送给 Blink 的 HTML 文档到屏幕上显示的像素是一个十分漫长的旅程。渲染流水线架构的示意图如下: undefined

这一篇非常棒的文章 (opens new window)描述了上面渲染流水线的每一个步骤。我想恐怕我无法写的比这篇文章更好了 😃

如果你希望了解更多:

# 更多问题?

如果你有更多问题,可以发邮件到 blink-dev@chromium.org(一般性的问题),或者 platform-architecture-dev@chromium.org(架构相关的问题)。我们总是乐于提供帮助!😄