Blink是如何工作的
[本文为Chrome团队的haraken@(Kentaro Hara)写的一篇blink的介绍文档的翻译。原始文档在Google Docs上。]
[目前这个文档还在完善中,许多参考链接里文档也还没有写。]
为Blink项目工作并不容易。对于新的Blink开发者来说不容易,因为Blink引入了许多特有的概念和编码规则以实现高效的渲染。对于经验丰富的Blink开发者来说也不容易,因为Blink非常庞大,对性能、内存和安全性极为敏感。
本文档旨在提供一个“Blink是如何工作的”概述,希望能帮助Blink开发者快速熟悉架构:
-
本文档不是一个Blink详细架构和编码规则的全面教程。相反,本文档简明地描述了Blink的基本面,这些基本面在短期内不太可能发生变化,并提供了一些资源链接,如果您想了解更多信息,可以进一步阅读。
-
本文档不介绍特定功能(如ServiceWorkers,编辑)。相反,该文档介绍了在代码库中的许多地方都用到的基础功能(如内存管理,V8 API)。
有关Blink开发的更多信息,请参阅Chromium wiki。
Blink是做什么的
Blink是一个Web渲染引擎。粗略地说,Blink实现了一个浏览器标签页里显示的所有内容:
- 实现Web标准(如HTML标准),包括DOM,CSS和Web IDL
- 嵌入V8运行JavaScript
- 从底层网络栈请求资源
- 构建DOM树
- 计算样式和布局
- 嵌入Chrome Compositor绘制图形
Blink通过content public API集成到Chromium,Android WebView和Opera等用户中。
从代码库的角度来看,Blink通常指的是//third_party/blink/
。从项目角度来看,Blink通常是指实现Web平台功能的项目。实现Web平台功能的代码在//third_party/ blink/
,//content/renderer/
,//content/browser/
等地方。
进程/线程架构
进程
Chromium是多进程架构。Chromium有一个browser进程和N个在沙盒中的renderer进程。 Blink在renderer进程中运行。
有多少个renderer进程会被创建?出于安全原因,隔离跨站点iframe之间的内存地址非常重要(这称为Site Isolation)。理论上,应该为每个站点创建一个renderer进程。 然而实际上,当用户打开太多标签页或设备没有足够内存时,为每个站点创建一个renderer进程会消耗太多资源。这时一个renderer进程可能被从不同站点加载的多个iframe或标签页共享。这意味着同一个标签页中的iframe可能由不同的renderer进程托管,不同标签页中的iframe也可能由同一renderer进程托管。renderer进程,iframe和标签页之间没有1对1映射关系。
鉴于renderer进程在沙箱中运行,Blink需要请求browser进程进行系统调用(如文件访问,播放音频)和访问用户数据(如cookie,密码)。这个browser进程和renderer进程间的通信由Mojo实现。(注:过去我们使用的是Chromium IPC, 有些地方仍在使用它。但是它已经被弃用,并且在底层使用Mojo。) 在Chromium方面,正在进行的 Servicification工作正在将browser进程抽象为一组service。从Blink的角度来看,Blink可以使用Mojo与这些service和browser进程进行交互。
更多信息:
- 多进程架构
- Blink中的Mojo编程: platform/mojo/MojoProgrammingInBlink.md
线程
renderer进程中创建了多少个线程?
Blink有一个主线程,N个worker线程和几个内部线程。
几乎所有重要的事情都发生在主线程。所有JavaScript(worker除外),DOM,CSS,样式和布局计算都在主线程上运行。Blink经过高度优化,可以最大限度地提高主线程的性能,主要是基于单线程架构。
Blink可能会创建多个worker线程来运行Web Workers,ServiceWorker和Worklet。
Blink和V8可能会创建一些内部线程来处理WebAudio,数据库,GC等等等。
对于跨线程通信,必须使用PostTask API进行消息传递。除了几个出于性能原因需要使用的地方,不鼓励使用共享内存。这就是为什么Blink代码库中看不到很多MutexLocks的原因。
更多信息:
- Blink中的线程编程: platform/wtf/ThreadProgrammingInBlink.md
- workers: core/workers/README.md
Blink的初始化和终止化(finalization)
Blink由BlinkInitializer::Initialize()初始化。必须在执行任何Blink代码之前调用BlinkInitializer::Initialize()。
另一方面,Blink永远不会终止,也就是说,renderer进程会不进行清理强制结束。一个原因是性能考虑。另一个原因是,通常很难以优雅的顺序清理renderer进程中的所有内容(不值得在这里投入精力)。
目录结构
Content public API和Blink public API
Content Public API是让embedder嵌入渲染引擎的API层。Content public API必须仔细维护,因为它们会暴露给embedder使用。
Blink public API是向Chromium暴露//third_party/blink/
的功能的一层API。这一层API只是从WebKit继承而来的历史包袱。在WebKit时代,Chromium和Safari共享了WebKit的实现,因此需要一层API将Webkit的功能暴露给Chromium和Safari。现在Chromium是//third_party/blink/
的唯一embedder了,这一层API就没有意义了。我们正在将web平台代码从Chromium移动到Blink,以减少Blink public API的数量(这个项目叫做Onion Soup)。
目录结构和依赖关系
//third_party/blink/
具有以下目录。这些目录的更详细定义,请参阅此文档。
-
platform/
Blink底层功能的集合,从
core/
中重构而来。如几何和图形工具库。 -
core/
和modules/
Web标准中定义的所有Web平台功能的实现。
core/
中实现了与DOM紧密结合的功能。modules/
实现了更多独立的功能,如WebAudio,IndexedDB。 -
bindings/core/
和bindings/modules/
理论上
bindings/core/
是core/
的一部分,bindings/modules/
是modules/
的一部分。大量使用V8 API的文件会放在bingings
中。 -
controller/
一组使用
core/
和modules/
的高层库。如devtools的前端。
依赖关系按以下顺序:
- Chromium => controller / => modules/和bindings/modules/ => core/和bindings/core/ => platform/ => 底层元素(primitive),如//base,//v8和//cc
Blink小心地维护了暴露于//third_party/blink/
的底层元素的列表。
更多信息:
- 目录结构和依赖关系:blink/renderer/README.md
WTF
WTF是一个“Blink特有的基础库”,位于platform/wtf/
。我们试图尽可能地在Chromium和Blink之间统一编码元素(primitive),因此WTF应该很小。 WTF是必需的,因为有许多类型,容器和宏需要针对Blink的工作负载和Oilpan(Blink GC)进行优化。如果在WTF中定义了类型,则Blink必须使用WTF类型而不是// base或std库中定义的类型。最常用的是vector,hashset,hashmap和string。 Blink应该使用WTF::Vector
,WTF::HashSet
,WTF::HashMap
,WTF::String
和WTF::AtomicString
而不是std::vector
,std::*set
,std::*map
和std::string
。
更多信息:
- 如何使用WTF:platform/wtf/README.md
内存管理
就Blink而言,你需要关心三个内存分配器:
- PartitionAlloc
- Oilpan(也就是Blink GC)
- malloc(不推荐)
你可以使用USING_FAST_MALLOC()
在PartitionAlloc的heap上分配一个对象:
class SomeObject {
USING_FAST_MALLOC(SomeObject);
static std :: unique_ptr<SomeObject> Create(){
return std :: make_unique<SomeObject>(); //在PartitionAlloc的堆上分配。
}
};
PartitionAlloc分配的对象的生命周期应由scoped_refptr<>
或std::unique_ptr<>
管理。强烈不建议手动管理生命周期。Blink中禁止手动delete
。
你可以使用GarbageCollected
在Oilpan的堆上分配一个对象:
class SomeObject:public GarbageCollected<SomeObject> {
static SomeObject* Create(){
return new SomeObject; //在Oilpan的堆上分配。
}
};
Oilpan分配的对象的生命周期由垃圾回收自动管理。你必须使用特殊的指针(如Member<>
和Persistent<>
)来保存Oilpan堆上的对象。请参阅这个API参考以熟悉有关Oilpan的编程限制。最重要的限制是不允许在Oilpan的对象的析构函数中操作任何其他Oilpan的对象(因为无法保证销毁顺序)。
如果既不使用USING_FAST_MALLOC()
也不使用GarbageCollected
,则会在系统malloc的堆上分配对象。Blink强烈建议不要这样做。所有Blink对象应由PartitionAlloc或Oilpan分配:
- 默认情况下使用Oilpan。
- 仅当 1) 对象的生命周期非常清楚并且
std::unique_ptr<>
足够用,2) 在Oilpan上分配对象引入了很多复杂性或者 3) 在Oilpan上分配对象给垃圾收集运行时增加了许多不必要的压力时,才使用PartitionAlloc。
无论你是使用PartitionAlloc还是Oilpan,你都必须非常小心,不要创建悬空指针(注:强烈建议不要使用原始指针)或造成内存泄漏。
更多信息:
- 如何使用PartitionAlloc:platform/wtf/allocator/Allocator.md
- 如何使用Oilpan:platform/heap/BlinkGCAPIReference.md
- Oilpan GC的设计:platform/heap/BlinkGCDesign.md
任务调度
为了提高渲染引擎的响应能力,blink的task应该尽量的异步执行。任何同步的IPC/Mojo/可能需要几毫秒的操作都需要避免(尽管有些操作是不可避免的,如用户的JavaScript执行)。
renderer进程中的所有task都应该设置适当的类型post到Blink Scheduler,如下所示:
//将task post到一帧的scheduler,类型为kNetworking
frame->GetTaskRunner(TaskType::kNetworking)->PostTask(..., WTF::Bind(&Function));
Blink Scheduler维护多个任务队列并巧妙地确定任务的优先级,以最大化用户感知的性能。指定正确的任务类型非常重要,能使Blink Scheduler正确且巧妙地安排任务。
更多信息:
- 如何发布任务:platform/scheduler/PostTask.md
Page,Frame,Docuemnt,DOMWindow等
概念
Page,Frame,Document,ExecutionContext和DOMWindow是以下概念:
- Page对应于标签页(OOPIF没有打开的情况。下面会解释OOPIF)。每个renderer进程可能包含多个标签页。
- Frame对应于main 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";
在这种情况下,将为https://example.com创建一个新的Window和一个新的Document。然而Frame可能被重复使用。
(注:确切地说,在某些情况下会创建一个新Document,但Window和Frame会被重用。还有更复杂的情况。)
更多信息:
- core/frame/FrameLifecycle.md
OOPIF(out of process iframe)
网站隔离(Site Isolation)使blink更安全,但也更复杂:)。网站隔离的想法是为每个网站创建一个renderer进程。(这里网站是网页的注册域名+1级子域名及其URL scheme。例如,https://mail.example.com和https://chat.example.com位于同一网站,但https://noodles.com和https://pumpkins.com不是。)如果一个页面包含一个跨站点iframe,那么该页面可能由两个renderer进程托管。考虑以下页面:
<!-- https://example.com -->
<body>
<iframe src="https://example2.com"></iframe>
</body>
main frame和iframe可能由不同的renderer进程托管。在这个renderer进程的frame由LocalFrame表示,而不在这个renderer进程的frame由RemoteFrame表示。
从main frame的角度来看,main frame是LocalFrame,iframe是RemoteFrame。从iframe的角度来看,main frame是RemoteFrame,iframe是LocalFrame。
LocalFrame和RemoteFrame(可能存在于不同的renderer进程中)之间的通信通过browser进程处理。
更多信息:
- 设计文档:网站隔离设计文档
- 网站隔离下如何编写代码:core/frame/SiteIsolation.md
Detached Frame/Document
Frame/Document可能处于分离状态。考虑以下情况:
doc = iframe.contentDocument;
iframe.remove(); // iframe与DOM树分离。
doc.createElement("div"); //但你仍然可以在分离的Frame上运行脚本。
棘手的事实是你仍然可以在分离的Frame上运行脚本或执行DOM操作。由于Frame已经分离,大多数DOM操作都会失败并抛出错误。不幸的是,分离的Frame上的行为在浏览器之间并不一致,也没有在标准中明确定义。基本上JavaScript应该继续运行,但是大多数DOM操作都应该失败,除了一些适当的例外,例如:
void someDOMOperation(...) {
if(!script_state_->ontextIsValid()) { // Frame已经分离
...; // 设置异常等
return;
}
}
这意味着,在通常情况下Blink需要在frame被分离时做一些清理操作。你可以通过继承ContextLifecycleObserver
来做到这一点:
class SomeObject : public GarbageCollected<SomeObject>, public ContextLifecycleObserver {
void ContextDestroyed() override {
//在这里执行清理操作。
}
~SomeObject() {
//在这里进行清理操作并不是一个好主意,因为这样做已经太晚了。另外,析构函数不允许触及Oilpan堆上的任何其他对象。
}
};
Web IDL binding
当JavaScript访问node.firstChild
时,node.h中的Node::firstChild()
会被调用。这是如何工作的?我们来看看node.firstChild
是如何工作的。
首先,您需要根据标准定义一个IDL文件
// node.idl
interface: EventTarget {
[...] readonly attribute Node? firstChild;
};
Web IDL的语法在Web IDL标准中定义。[...]
叫做IDL扩展属性。一些IDL扩展属性在Web IDL标准中定义,其他的是Blink特有的IDL扩展属性。除了Blink特有的IDL扩展属性,IDL文件应以遵守标准的方式编写(也就是只需从标准中复制和粘贴)。
其次,你需要为Node定义一个C++类并为firstChild实现一个C++的getter:
class EventTarget : public ScriptWrappable { //所有暴露给JavaScript的类都必须从ScriptWrappable继承。
...;
};
class Node : public EventTarget {
DEFINE_WRAPPERTYPEINFO(); //所有具有IDL文件的类都必须具有此宏。
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()
。
更多信息:
- 如何添加Web IDL binding:bindings/IDLCompiler.md
- 如何使用IDL扩展属性:bindings/IDLExtendedAttributes.md
- 标准:Web IDL标准
V8和Blink
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进程中有多个Context。当调用V8 API时,必须确保处于正确的Context中。否则,v8::Isolate::GetCurrentContext()
将返回错误的上下文,在最坏的情况下,它会泄漏对象并导致安全问题。
World概念是为了支持Chrome扩展程序内容脚本。World不与Web标准中的任何内容对应。内容脚本希望与网页共享DOM,但出于安全原因,内容脚本的JavaScript对象必须与网页的JavaScript堆隔离。 (一个内容脚本的JavaScript堆也必须与另一个内容脚本的JavaScript堆隔离。)为了实现隔离,主线程为网页创建一个main world,为每个内容脚本创建一个隔离的world。main world和隔离的world可以访问相同的C++ DOM对象,但它们的JavaScript对象是隔离的。通过为一个C++DOM对象创建多个V8 wrapper来实现这种隔离。即每个World一个V8 wrapper。
Context,World和Frame之间有什么关系?
想象一下主线程上有N个World(一个main world + (N - 1)个隔离的world)。然后一个Frame应该有N个window对象,每个对象用于一个world。Context是对应于window对象的概念。这意味着当我们有M个Frame和N个World时,我们有M * N个Context(但是Context延迟创建的)。
对于worker,只有一个World和一个全局对象。因此,只有一个Context。
同样,当使用V8 API时,应该非常小心使用正确的Context。否则,你最终会在隔离的World之间泄漏JavaScript对象并导致安全灾难(如A.com的扩展可以操纵B.com的扩展)。
更多信息:
V8 API
//v8/include/v8.h中定义了很多V8 API。由于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 = ...; // 这是对的
}
class SomeObject : public GarbageCollected<SomeObject> {
v8::Local<v8::Object> object_; // 这是错的
};
如果要从机器堆栈外部指向V8对象,则需要使用wrapper tracing。但是,你必须非常小心,不要使用它创建引用循环。通常,V8 API很难使用。如果你不知道自己在做什么,请询问blink-review-bindings@。
更多信息:
- 如何使用V8 API和辅助类: platform/bindings/HowToUseV8FromBlink.md
V8 wrapper
每个C++ DOM对象(如Node)都有相应的V8 wrapper。准确地说,每个World中的每个C++ DOM对象都有相应的V8 wrapper。
V8 wrapper对它们对应的C++ DOM对象有强引用。但是,C++ DOM对象对V8 wrapper只有弱引用。因此,如果您希望将V8 wrapper保持一段生存时间,则必须显式地执行这个操作。否则,V8 wrapper将被过早回收,V8 wrapper上的JS属性将丢失…
div = document.getElementbyId("div");
child = div.firstChild;
child.foo = "bar";
child = null;
gc(); //如果我们什么都不做,那么|firstChild|的V8 wrapper将被GC回收
assert(div.firstChild.foo === "bar"); //...这会失败
如果我们不做任何事情, child会被GC回收,因此child.foo丢失。为了使div.firstChild的V8 wrapper保持生存,我们必须添加一种机制,保证只要div所在的DOM树能被V8访问,div.firstChild的V8 wrapper就能保持生存。
有两种方法可以保持V8 wrapper存活: ActiveScriptWrappable和wrapper tracing。
更多信息:
- 如何管理V8 wrapper的生存周期:bindings/core/v8/V8Wrapper.md
- 如何使用wrapper tracing:platform/bindings/TraceWrapperReference.md
渲染管线
从Blink收到HTML文件,到像素显示在屏幕上,经过了很长的一段旅程。渲染管线的架构如下。
阅读这个优秀的演示文档,以了解渲染管道的每个阶段所作的工作。(我不认为我能写出比这更好的解释了:-)
更多信息:
- 概述: 像素的生命
- DOM: core/dom/README.md
- 样式:core/css/README.md
- 布局:core/layout/README.md
- Paint:core/paint/README.md
- Compositor线程: Chromium图形渲染
问题?
如果你有任何问题,请在[email protected](一般性问题)或[email protected] (与架构相关的问题)上提问。我们很乐意提供帮助!:D