Blink 内核是如何工作的?

2020-04-28 · 28 min read

原文链接

Author: haraken@ Last update: 2018 Aug 14 Status: PUBLIC 译: LeoY

对于刚接触 Blink 的开发者来说, Blink 相关的工作并不简单。因为实现一个高效快速的渲染引擎,需要了解大量与 Blink 相关的概念和代码约定。这对于经验丰富的 Blink 开发者来说也并不简单,因为 Blink 项目很庞大,并且对于性能、内存和安全性很敏感。

本文的目标是提供一个关于 Blink 工作原理的概览,希望能够帮助开发者快速熟悉 Blink 的架构。

  • 本文不是一个关于 Blink 架构细节和代码风格的详细教程,而是关于 Blink 基本原理的简单介绍。这部分原理在短期内不会有大的改变,另外提供了一些深入了解这些部分的相关资源。
  • 本文不会介绍具体的功能(比如 ServiceWorkersediting 等),而是介绍了代码中广泛使用的一些基本的功能(比如内存管理, V8 APIs 等)

访问 Chromium wiki page 来获取更多的关于 Blink 开发的信息

Blink 做了什么

Blink 是一个Web平台的渲染引擎。粗略地说,在一个浏览器tab页中与内容渲染相关的所有事情都是由 Blink 实现的。

  • 实现Web平台的规格(比如, HTML标准规格),包括 DOM , CSSWeb IDL (Web浏览器编程接口描述)

  • 嵌入 V8 和运行 Javascript

  • 从底层的网络堆栈请求资源

  • 构建 DOM tree

  • 计算样式和布局

  • 嵌入 Chrome Compositor 和图形渲染绘制

    • what is Chrome Compositor?

    cc is responsible for taking painted inputs from its embedder, figuring out where and if they appear on screen, rasterizing and decoding and animating images from the painted input into gpu textures, and finally forwarding those textures on to the display compositor in the form of a compositor frame. cc also handles input forwarded from the browser process to handle pinch and scroll gestures responsively without involving Blink.

在很多地方都能见到 Blink 的身影,比如 ChromiumAndroid WebView 以及通过 content public APIs 内嵌 BlinkOpera 浏览器。

image3

从代码库的角度来看, Blink 对应 //third_party/blink/ 。 从项目本身来看, Blink 实现了Web平台的功能,这些代码在主要在 //third_party/blink///content/renderer///content/browser/ 目录中。

进程/线程 架构

进程

Chromium 是一个 多进程架构 multi-process architecture 的浏览器引擎 。 Chromium 运行时会创建一个浏览器进程和N个在沙盒中运行的渲染进程。 Blink 则是在渲染进程中运行的。

创建多少渲染进程?一般来说,一个 site 会独占一个渲染进程,而当用户开太多tabs页面内存不足时,多个 site 可能会共享一个渲染进程。

出于安全性的考虑,跨站文档(cross-site documents)的内存地址会被隔离开来(这被称为Site Isolation)。理想情况下,每个渲染进程是每个网站专用的,然而当用户打开太多标签页或者机器内存不够时,这种限制就很麻烦。所以实际上,多个页面或者不同网站的多个 iframe 可能会共享同一个渲染。这意味着一个tab页中的多个 iframe 可能是不同的渲染进程渲染的,不同的tab页中的 iframe 也有有可能是同一个渲染进程渲染的。所以渲染进程,iframe 和 tab 三者之间不是(1:1)一对一的映射关系

由于渲染进程是运行在沙盒中的,所以 Blink 需要向浏览器进程发起系统调用(比如文件访问,音频播放)和(用户配置)数据的获取(比如 Cookie ,密码)。浏览器进程和渲染进程之间通过 Mojo 实现通信。(Note: 以前是通过 Chromium IPC 实现,现在还有部分代码仍在使用,但是会逐渐弃用) Chromium 中的 Servicification 将浏览器进程封装出了一些独立的服务。 Blink 可以直接使用 Mojo 调用这些独立服务来或者与浏览器进程交互。

image5

了解更多:

线程

在一个渲染进程中创建了多少线程?

Blink 中会有一个主线程,N个工作线程和三两个内部线程。

几乎所有重要的事情都发生在主线程中。 Javascript (不包括 service worker ), DOMCSS ,样式和布局计算都在主线程中运行。 Blink 通过许多优化来最大化主线程的性能,模拟了一个近乎单线程的架构。 Blink 可能会创建多个工作线程来运行 Web Workers ServiceWorker 以及 Worklet BlinkV8 可能会创建三两个内部进程来处理 音频 webaudio , 数据库 database , 内存回收 GC 等。

线程之间的通信,需要使用 PostTask APIs 来传递。 出于性能的考虑,除了几个特别的地方,共享内存编程是不推荐的,所以在 Blink 中源码中也很少使用互斥锁这种东西。

image2

了解更多:

Blink 的初始化和终止

Blink 通过 BlinkInitializer::Initialize() 来初始化。这个方法必须在执行 Blink 代码前调用。

Blink 没有终止化的状态,原因是渲染进程是被强制退出的,而不是被清理回收的。原因之一是出于性能的考虑(强制退出不需要做额外的操作)。另一个原因是渲染进程在正常退出的情况下,一般很难把所以东西都清理回收掉。(并且这样做的代价高于带来的效益)

目录架构

Content public APIsBlink public APIs

Content public APIs 是用嵌入渲染引擎的API层。 Content public APIs 必须小心维护,因为它们是提供给(想内嵌 Blink 引擎的)嵌入器的。

Blink public APIs 是为 Chromium 提供 //third_party/blink/ 中的 Blink 功能的API层。 Blink public APIs 继承自 Webkit APIs 。在 Webkit 时代,由于 ChromiumSafari 会共享 Webkit 的实现,所以当时这个API层既要顾及 Chromium 也要顾及 Safari 。而现在 Blink 内核的功能只需要提供给 Chromium ,旧的API层有些API就不需要了。所以我们将 Chromium 中与平台相关的代码迁移到 Blink 中来减少 Blink public APIs 的数量(这个项目被叫作 Onion Soup

img

目录架构和依赖

//third_party/blink/ 的目录如下,查阅这个文档了解更多。

  • platform/
    • 一组从core/里面分解出来的 Blink 底层功能,比如地理位置 geometry 和图形 graphics 相关的库
  • core/ 和 modules/
    • 实现Web平台规格文件的所有功能。core/主要是实现 DOM 相关的功能。modules/主要实现了一些浏览器自有的功能,比如 webaudio , indexeddb
  • bindings/core/ 和 bindings/modules/
    • 从命名就可以猜到, bindings/core/core/ 的一部分, bindings/modules/modules/ 的一部分。频繁调用 V8 APIs 的文件都放在 bindings/{core,modules} 里面
  • controller/
    • 一些调用 core/modules/ 的顶层工具库(比如devtools的前端部分 devtools front-end

各部分代码的依赖关系如下:

  • Chromium => controller/ => modules/bindings/modules/ => core/bindings/core/ => platform/ => 底层原语 比如 //base , //v8//cc

提供给 //third_party/blink/ 底层原语在 Blink 项目中被小心翼翼地精心维护着

了解更多:

WTF

WTFBlink 特有的工具库,位于 platform/wtf/ 。我们尽可能统一 ChromiumBlink 的代码,所以 WTF 的体积会很小。 WTF 这个工具库之所以存在,是因为对于 Blink 的工作负载和 Oilpan (即 Blink GC ) 中有大量的类型( types ),容器( containers )和宏( macros )需要做性能优化。如果类型在 WTF 中有相应的定义,在 Blink 中就需要使用 WTF 的类型而不是定义在 //base 或者 std libraries 中的类型。 使用的最多的类型是 vectors , hashsets , hashmapsstrings 。相应的在 Blink 中应该使用 WTF::Vector , WTF::HashSet , WTF::HashMap , WTF::StringWTF::AtomicString 而不是 std::vector , std::*set, std::*mapstd::string

了解更多:

内存管理

你需要关注三个与 Blink 相关的内存分配器。

给一个对象分配 PartitionAlloc 上的堆内存,可以用 USING_FAST_MALLOC()

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<> 管理。强烈不建议手动去管理生命周期。手动回收内存在 Blink 是不允许的。

给一个对象分配 Oilpan 上的堆内存,你可以使用 GarbageCollected

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

Oilpan 堆内存中的对象生命周期是由 garbage collection 自动管理的。你需要使用特殊的指针(比如 Member<>Persistent<> )来存 Oilpan 堆内存中的对象。参考这个API手册this API reference来熟悉在 Oilpan 上开发的一些限制。最重要的一个限制就是在一个 Oilpan 对象的解构函数中不允许处理任何其他的 Oilpan 对象。(原因是解构的顺序是没有保证的)

如果你既没有使用 USING_FAST_MALLOC() 也没有使用 GarbageCollected ,那么对象就被分配在系统堆内存中。这在 Blink 中是极其不推荐的。所有的 Blink 对象都应该按如下规则分配在 PartitionAlloc 或者 Oilpan 的堆内存中。

  • 默认使用 Oilpan
  • 仅在三种情况下使用 PartitionAlloc
    • 对象的生命周期非常清晰,只用 std::unique_ptr<>scoped_refptr<> 就可以满足需求
    • 当使用 Oilpan 分配内存给当前情况增加很多的复杂性时
    • 当使用 Oilpan 分配内存给当前情况的垃圾回收机制 garbage collection runtime 带来了大量不必要的(性能)压力时

不管使用 PartitionAlloc 还是 Oilpan 来分配内存,都需要极其小心,以免创建出悬空指针( Dangling pointer )甚至内存泄露(Note: 裸指针也是极其其不推荐的)

了解更多:

任务调度

为了提升渲染引擎的响应速度,在 Blink 中的任务都应该尽可能的异步执行。同步的 IPC/Mojo 或者其他可能耗费数毫秒的操作都是不推荐使用的。(尽管有些操作无法避免,比如执行用户的 JavaScript ,其 JavaScript 代码本身可能会阻塞渲染)。

在渲染进程中的所有任务都会通知 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 Schedule 的高效调度是十分重要的。

了解更多:

  • How to post tasks 如何发起任务: platform/scheduler/PostTask. md

Page , Frame , Document , DOMWindow etc

概念

Page , Frame , Document , ExecutionContextDOMWindow 的含义如下:

  • Page 对应tab页(如果下文介绍的 OOPIF 没有开启的话)。一个渲染进程可能会渲染多个tab页
  • Frame 对应 frame ( main frame 或者一个 iframe )。一个 Page 包含一个或者多个 Frame 并且包含在一个树结构中。
  • DOMWindow 对应 Javascript 中的 window 对象。每个 Frame 有一个 DOMWindow
  • Document 对应 Javascript 中的 window.document 对象。每个 Frame 有一个 Document
  • ExecutionContext 是(主线程的) Document 和(工作线程的) WorkerGlobalScope 的抽象。

渲染进程 : Page = 1 : N. Page : Frame = 1 : M. Frame : DOMWindow : Document (or ExecutionContext ) = 1 : 1 : 1(在任何时刻都成立,不过映射关系可能会改变)

举个栗子:

iframe.contentWindow.location.href = "https://example.com";

在这种情况下,访问 https://example.com. 创建了新的 DOMWindowDocument ,而 Frame 则可能被重用。(Note: 准确的来说,还存在一些更复杂的情况创建了新的 Document ,而 DOMWindowFrame 被重用了)。

了解更多:

  • core/frame/FrameLifecycle. md

Out-of-Process iframes (OOPIF 进程外的iframe)

Site Isolation 网站隔离增加了安全性的同时也增加了复杂性。:) Site Isolation 的设想是为每一个网站创建一个渲染进程 。

(A site is a page’s registrable domain + 1 label, and its URL scheme. For example, https://mail.example.com and https://chat.example.com are in the same site, but https://noodles.com and https://pumpkins.com are not. )

如果 Page 包含跨站的 iframe , 那么这个 Page 可能被两个渲染进程共同渲染。参考下面这种 Page :

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

frameiframe 可能运行在不同的渲染进程中。渲染进程本地的 frameLocalFrame 呈现,不属于渲染进程本地的 frameRemoteFrame 呈现。

从主 frame 的角度来看,主 frame 是一个 LocalFrameiframe 是一个 RemoteFrame 。从 iframe 的角度来看,主 frame 是一个 RemoteFrame ,而 iframe 是一个 LocalFrame

LocalFrameRemoteFrame (两者可能存在与不同的渲染进程中)之间的通信是浏览器进程来处理的。

了解更多:

Detached Frame / Document 分离的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 的表现在不同浏览器中并不一致,在规格文件中也没有非常明确的定义。大体上来说,期望的表现是在 frame 分离后 JavaScript 还是可以正常的执行,但是大多数DOM操作都应该失败并抛出异常,如:

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

这意味着 Blink 需要在 frame 被分离时做大量的清除回收操作。这些操作可以通过 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 bindings: Web IDL绑定

JavaScript 访问 node.firstChild 的时候, node.h 中的 Node::firstChild() 即被调用。它是如何工作的呢,一起来看看 node.firstChild 是怎么工作的:

首先,你需要为每一个规格定义一个IDL文件,如:

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

Web IDL的语法定义在 the Web IDL spec 中。 [...] 被称为 IDL extended attributesthe Web IDL spec 里面定义了一些 IDL extended attributes ,其他的在 Blink-specific IDL extended attributes中。除了 Blink 特有的 IDL extended attributes ,其他IDL文件都应该按照和规格文件一致的格式来写(意思就是直接从规格文件里面cv)。

接下来,你需要为 Node 节点定义一个 C++ class 类,并用c++实现 firstChildgetter ,如:

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 the IDL compiler 会为 Node interfaceNode.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()

了解更多:

  • How to add Web IDL bindings: bindings/IDLCompiler. md
  • How to use IDL extended attributes: bindings/- IDLExtendedAttributes. md
  • Spec: Web IDL spec

V8 和 Blink

Isolate, Context, World

当你写和 V8 APIs 有关的代码时,理解 Isolate, Context, World 这三个概念很重要。它们在代码库中分别是 v8::Isolate , v8::ContextDOMWrapperWorld

Isolate 是一个物理上的线程,在 BlinkIsolate : physical=1:1 。主线程和工作线程都有其独立的 Isolate

Context 是一个全局的对象(以 Frame 来说, FrameContextwindow 对象)。由于每个 frame 有自己的 window 对象,所以一个渲染进程中会有多个 Context 。当调用 V8 APIs 时,你需要确认你在正确的 Context 中。否则, v8::Isolate::GetCurrentContext() 就会返回一个不正确的 Context ,最坏的情况会造成对象泄露并导致安全问题。

World 支撑 Chrome extensions 脚本的运行。 Worlds 和任何web标准都没有关系。 Chrome extensions 脚本和页面共享DOM,不过处于安全的考虑, Chrome extensions 脚本的 JavaScript 和页面的 JavaScript 堆内存是相互隔离的。(并且 Chrome extensions 脚本之间的 JavaScript 堆内存也是相互隔离的)。主线程通过为页面创建一个 main world 和为每个 Chrome extensions 脚本创建一个 isolated world 来实现隔离。 main worldisolated worlds 都可以访问到C++上的DOM对象,但是他们各自的 JavaScript 对象都是隔离的。这种隔离是通过为每个 C++DOM 对象创建多个 V8 wrapper 来实现的。即每个 world 对应一个 V8 wrapper

image4

Context , WorldFrame 之间有什么联系? 想象一下, 在主线程中存在N个 World (一个 main world +(N-1)个 isolated worlds) )。那么一个 Frame 就有N个 window objects ,每个 window objects 对应一个 world 。而 Context 也是对应 window objects ,这意味着当存在N个 Frame 和N个 Worlds 的时候,有M*N个 Contexts (不过 Contexts 是懒加载创建的)

对于 worker 而言,只有一个 World 和一个 global object ,所以就只存在一个 Context 此外,当你使用 V8 APIs 的时候,你应该非常注意是否使用了正确的 context ,否则你可能导致在不同的 isolated worlds 间泄露 JavaScript 对象甚至导致灾难般的安全问题。(比如,使得A. com的 Chrome extetion 可以操纵B. com的 Chrome extetion

了解更多:

V8 APIs

//v8/include/v8. h. 里面有大量的V8 APIs。由于 V8 APIs 都比较底层,使用起来略显麻烦,所以一般使用platform/bindings/ 提供的一组封装了的 V8 APIs 辅助类来( helper classes )进行调用。你应该尽量使用 helper classes 。如果你的代码中会重度使用原生 V8 APIs ,这些代码应该放到 bindings/{core,modules} 里面去。

V8使用 handle 来指向 V8 objects 。最常见的 handlev8::Local<> , v8::Local<> 用于从机器堆栈 machine stack 指向 V8 objectsv8::Local<> 必须在 v8::HandleScope 从机器堆栈 machine stack 分配之后才能使用。 v8::Local<> 也不能在 machine stack 之外使用:

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.
};

要从机器堆栈 machine stack 指外指向 V8 objects ,你需要使用 wrapper tracing。然而你需要特别小心地使用,以免创建出循环引用。通常 V8 APIs 都是难用的。如果你不确定你的用法可以上blink-review-bindings@提问。

了解更多:

  • How to use V8 APIs and helper classes: platform/bindings/HowToUseV8FromBlink. md

V8 wrappers

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

V8 wrappers 对它相应的 C++ DOM 是强引用关系。而 C++ DOMV8 wrappers 则是弱引用关系。所以如果想要使 V8 wrappers 在一段特定的周期延续,你需要明确地指定。否则 V8 wrappers 可能会被提前回收,导致 V8 wrappers 上的 JS properties 丢失…

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 tree 还可以通过 V8 访问到,就一直保留 div.firstChild 上的 V8 wrapper

有两种方式来保留 V8 wrappersActiveScriptWrappablewrapper tracing.

了解更多:

渲染管道 Rendering pipeline

一个HTML文件从传递到 Blink 再到屏幕上显示的像素之间有一段很长的历程。渲染管道的架构如下:

image6

Life of A Pixel里面介绍了渲染管道的每一个阶段。

了解更多:

Questions?

有问题可以到 [email protected]platform-architecture-dev@chromium 提问。


Profile picture

Blogs by Leo Yang who lives and works in Chengdu writing interesting things. You should follow me on Github 😁