learn-tech/专栏/计算机基础实战课/38浏览器原理(二):浏览器进程通信与网络渲染详解.md
2024-10-16 10:18:29 +08:00

298 lines
19 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

因收到Google相关通知网站将会择期关闭。相关通知内容
38 浏览器原理(二):浏览器进程通信与网络渲染详解
你好我是LMOS。
通过前面的学习,你应该对浏览器内的进程和线程已经有了一个大概的印象,也知道了为了避免一些问题,现代浏览器采用了多进程架构。
这节课我们首先要说的是Chrome中的进程通信。这么多的进程它们之间是如何进行IPC通信的呢要知道如果IPC通信设计得不合理就会引发非常多的问题。
Chrome如何进行进程间的通信
[上节课]我们提了一下Chrome进程架构Chrome有很多类型的进程。这些进程之间需要进行数据交换其中有一个浏览器主进程每个页面会使用一个渲染进程每个插件会使用一个插件进程。除此之外还有网络进程和GPU进程等功能性进程。
进程之间需要进程通信渲染进程和插件进程需要同网络和GPU等进程通信借助操作系统的功能来完成部分功能。其次同一类进程如多个渲染进程之间不可以直接通信需要依赖主进程进行调度中转。
进程与进程之间的通信也离不开操作系统的支持。在前面讲IPC的时候我们了解过多种实现方式。这里我们来看看Chrome的源码Chrome中IPC的具体实现是通过IPC::Channel这个类实现的具体在 ipc/ipc_channel.cc 这个文件中封装了实现的细节。
但是在查阅代码的过程中,我发现 Chrome 已经不推荐使用IPC::Channel机制进行通信了Chrome 实现了一种新的 IPC 机制—— Mojo。
目前IPC::Channel 底层也是基于 Mojo 来实现的,但是上层接口和旧的 Chrome IPC 保持兼容IPC::Channel 这种方式即将被淘汰,所以这里我们先重点关注 Mojo后面我们再简单了解一下 Chrome IPC 接口。
Mojo
Mojo 是一个跨平台 IPC 框架,它源于 Chromium 项目主要用于进程间的通信ChromeOS 用的也是Mojo框架。
Mojo官方文档给出的定义是这样的
“Mojo是运行时库的集合这些运行时库提供了与平台无关的通用IPC原语抽象、消息IDL格式以及具有用于多重目标语言的代码生成功能的绑定库以方便在任意跨进程、进程内边界传递消息。”
在Chromium中有两个基础模块使用 Mojo分别是 Services 和 IPC::Channel。
Services 是一种更高层次的 IPC 机制,底层通过 Mojo 来实现。Chromium 大量使用这种IPC机制来包装各种功能服务用来取代 IPC::Channel ,比如 device 服务performance 服务audio 服务viz 服务等。
Mojo 支持在多个进程之间互相通信这一点和其他的IPC有很大的不同其他IPC大多只支持2个进程之间进行通信。
这些由Mojo组成的、可以互相通信的进程就形成了一个网络。在这个网络内任意两个进程都可以进行通信并且每个进程只能处于一个 Mojo 网络中,每一个进程内部有且只有一个 Node每一个 Node 可以提供多个 Port每个 Port 对应一种服务,这点类似 TCP/IP 中的 IP 地址和端口的关系。一个 Node:port 对可以唯一确定一个服务。
Node 和 Node 之间通过 Channel 来实现通信,在不同平台上 Channel 有不同的实现方式在Linux上是Domain Socket在Windows上是Named Pipe在macOS平台上是 Mach Port。
在 Port 的上一层Mojo 封装了3个“应用层协议”分别为MessagePipeDataPipe和SharedBuffer这里你是不是感觉很像网络栈在 TCP 上封装了 HTTP。整体结构如下图
我们在 Chromium 代码中使用 Mojo是不必做 Mojo 初始化相关工作的因为这部分Chromium 代码已经做好了。如果我们在 Chromium 之外的工程使用 Mojo还需要做一些初始化的工作代码如下
int main(int argc, char** argv) {
// 初始化CommandLineDataPipe 依赖它
base::CommandLine::Init(argc, argv);
// 初始化 mojo
mojo::core::Init();
// 创建一个线程用于Mojo内部收发数据
base::Thread ipc_thread("ipc!");
ipc_thread.StartWithOptions(
base::Thread::Options(base::MessageLoop::TYPE_IO, 0));
// 初始化 Mojo 的IPC支持只有初始化后进程间的Mojo通信才能有效
// 这个对象要保证一直存活否则IPC通信就会断开
mojo::core::ScopedIPCSupport ipc_support(
ipc_thread.task_runner(),
mojo::core::ScopedIPCSupport::ShutdownPolicy::CLEAN);
// ...
}
MessagePipe 用于进程间的双向通信类似UDP消息是基于数据报文的底层使用 Channel通道SharedBuffer 支持双向块数据传递,底层使用系统 Shared Memory 实现DataPipe 用于进程间单向块数据传递类似TCP消息是基于数据流的底层使用系统的 Shared Memory实现。
一个 MessagePipe 中有一对 handle分别是 handle0 和 handle1MessagePipe 向其中一个handle写的数据可以从另外一个handle读出来。如果把其中的一个 handle 发送到另外一个进程,这一对 handle 之间依然能够相互收发数据。
Mojo 提供了多种方法来发送 handle 到其他的进程,其中最简单的是使用 Invitation。要在多个进程间使用 Mojo必须先通过 Invitation 将这些进程“连接”起来这需要一个进程发送Invitation另一个进程接收 Invitation。
发送Invitation的方法如下
// 创建一条系统级的IPC通信通道
// 在Linux上是 Domain Socket, Windows 是 Named PipemacOS是Mach Port该通道用于支持跨进程的消息通信
mojo::PlatformChannel channel;
LOG(INFO) << "local: "
<< channel.local_endpoint().platform_handle().GetFD().get()
<< " remote: "
<< channel.remote_endpoint().platform_handle().GetFD().get();
mojo::OutgoingInvitation invitation;
// 创建1个essage Pipe用来和其他进程通信
// 这里的 pipe 就相当于单进程中的pipe.handle0
// handle1 会被存储在invitation中随后被发送出去
// 可以多次调用以便Attach多个MessagePipe到Invitation中
mojo::ScopedMessagePipeHandle pipe =
invitation.AttachMessagePipe("my raw pipe");
LOG(INFO) << "pipe: " << pipe->value();
base::LaunchOptions options;
base::CommandLine command_line(
base::CommandLine::ForCurrentProcess()->GetProgram());
// 将PlatformChannel中的RemoteEndpoint的fd作为参数传递给子进程
// 在posix中fd会被复制到新的随机的fdfd号改变
// 在windows中fd被复制后会直接进行传递fd号不变
channel.PrepareToPassRemoteEndpoint(&options, &command_line);
// 启动新进程
base::Process child_process = base::LaunchProcess(command_line, options);
channel.RemoteProcessLaunchAttempted();
// 发送Invitation
mojo::OutgoingInvitation::Send(
std::move(invitation), child_process.Handle(),
channel.TakeLocalEndpoint(),
base::BindRepeating(
[](const std::string& error) { LOG(ERROR) << error; }));
在新进程中接收 Invitation 的方法如下
// Accept an invitation.
mojo::IncomingInvitation invitation = mojo::IncomingInvitation::Accept(
mojo::PlatformChannel::RecoverPassedEndpointFromCommandLine(
*base::CommandLine::ForCurrentProcess()));
// 取出 Invitation 中的pipe
mojo::ScopedMessagePipeHandle pipe =
invitation.ExtractMessagePipe("my raw pipe");
LOG(INFO) << "pipe: " << pipe->value();
上面使用 Mojo 的方法是通过读写原始的 buffer ,还是比较原始的。-
Chromium 里面使用了更上层的 bindings 接口来进行 IPC 通信。它先定义了一个 mojom 的接口文件然后生成相关的接口cpp代码。发送方调用cpp代码接口接收方去实现cpp代码接口。这种用法类似 Protocol Buffers。
我们不需要显式地去建立进程间的IPC连接因为这些Chromium代码已经做好了。Chromium的每个进程都有一个Service Manage它管理着多个Service。每个Server又管理着多个Mojo接口。在Chromium中我们只需要定义Mojo接口然后在恰当的地方去注册接口、实现接口即可。
legacy IPC
说完Mojo我还想带你简单看一下 legacy IPC。虽然它已经被废弃掉但是目前还有不少逻辑仍在使用它你可以在这里看到目前还在使用它的部分都是一些非核心的消息。所以我们还是要大致理解这种用法。
后面这张图是官方的经典图解:
-
我们看到每个Render进程都有一条Legacy IPC 通过 Channel 和 Browser 连接ResourceDispacher通过 Filter 同 Channel进行连接。IPC 里面有几个重要的概念:
IPC::Channel一条数据传输通道提供了数据的发送和接收接口
IPC::Message在Channel中传输的数据主要通过宏来定义新的Message
IPC::Listener提供接收消息的回调创建Channel必须提供一个Listener
IPC::Sender提供发送IPC::Message的Send方法IPC::Channel就实现了IPC::Sender接口
IPC::MessageFilter也就是Filter用来对消息进行过滤类似管道的机制它所能过滤的消息必须由其他Filter或者Listener传给它
IPC::MessageRouter一个用来处理 Routed Message 的类。
Legacy IPC的本质就是通过IPC::Channel接口发送IPC::MessageIPC::Channel是封装好的类IPC::Message需要用户自己定义。
IPC::Message 有两类,一类是路由消息 “routed message”一类是控制消息 “control message”。
唯一不一样的就是 routing_id() 不同,每一个 IPC::Message都会有一个 routing_id控制消息的 routing_id 始终是 MSG_ROUTING_CONTROL ,这是一个常量。除此之外,所有 routing_id 不是这个常量的消息,都是路由消息。
网页渲染的流程
前面我们讲了浏览器的架构,进程/线程模型以及浏览器内的 IPC 通信实现,有了这些铺垫,我们再来理解浏览器内部的进程模型的工作机制,就更容易了。进程通信会伴随着网络渲染的过程,所以,我推荐你从实际的渲染过程来观察,也就是搞明白浏览器是怎么借助计算机进行页面图像渲染的。
浏览器接收到用户在地址栏输入的URL以后浏览器的网络进程会利用操作系统内核网络栈进行资源获取。在第一季的网络篇我们曾经用了一节课的时间讲解[网络数据包是在网络中如何流转的]。如果你想要详细了解,可以去看看。这里我们着重关注浏览器收到响应后的渲染过程。
在浏览器启动后,浏览器会通过监听系统的某个指定端口号,监听数据的变化。在浏览器收到网络数据包后,会根据返回的 Content-Type 字段决定后续的操作如果是HTML那么浏览器则会进入渲染的流程。
在渲染过程中主要工作交由渲染进程处理我们可以简要分为几个部分建立数据传输管道、构建DOM树、布局阶段、绘制以及合成渲染。下面我们分别进行讲解。
建立数据传输管道
当网络进程接收到网络上出来的 HTML 数据包的时候渲染进程不会等网络进程完全接受完数据才开始渲染流程。为了提高效率渲染进程会一边接收一边解析。所以渲染进程在收到主进程准备渲染的消息后会使用Mojo接口通过边解析变接收数据的方式和网络进行IPC通信建立数据传输的管道将数据提交到渲染进程。
构建 DOM 树
渲染进程收到的是 HTML 的字符串,是一种无法进程结构化操作的数据,于是我们需要将纯文本转为一种容易操作、有结构的数据 —— DOM 树。
DOM树本质上是一个以 document 为根节点的多叉树DOM 树是结构化、易操作的,同样浏览器也会提供接口给到开发者,浏览器通过 JS 语言来操作 DOM 树,这样就可以动态修改页面内容了。
在渲染进程的主线程内部,存在一个叫 HTML解析器HTMLParser的东西想要将文本解析为 DOM 离不开它的帮助。HTML 解析器会将 HTML 的字节流,通过分词器转为 Token 流,其中维护了一个栈结构,通过不断的压栈和出栈,生成对应的节点,最终生成 DOM 结构。
在 DOM 解析的过程中当解析到标签时,它会暂停 HTML 的解析,渲染进程中的 JS 引擎加载、解析和执行 JavaScript 代码完成后,才会继续解析。
在 JS 解析的过程中JS 是可能进行 CSS 操作的,所以在执行 JS 前还需要解析引用的 CSS 文件,生成 CSSOM 后,才能进行 JS 的解析。CSSOM 是 DOM 树中每个节点的具体样式和规则对应的树形结构,在构建完 CSSOM 后,要先进行 JS 的解析执行,然后再进行 DOM 树的构建。
布局阶段 —— layout
这时已经构建完 DOM 树和 CSSOM 树,但是还是无法渲染,因为目前渲染引擎拿到的只是一个树形结构,并不知道具体在浏览器中渲染的具体位置。
布局就是寻找元素几何形状的过程,具体就是主线程遍历 DOM 和计算样式,并创建包含 xy 坐标和边界框大小等信息的布局树。
布局树可能类似于 DOM 树的结构,但它只包含与页面上可见内容相关的信息。比如说,布局树构建会剔除掉内容,这些内容虽然在 DOM 树上但是不会显示出来如属性为display: none的元素其次布局树还会计算出布局树节点的具体坐标位置。
绘制
渲染进程拿到布局树已经有具体节点的具体位置,但是还缺少一些东西,就是层级。我们知道,页面是类似 PS 的图层,是有图层上下文顺序的,而且还有一些 3D 的属性浏览器内核还需要处理这些专图层并生成一棵对应的图层树LayerTree
有了图层的关系,就可以开始准备绘制了,渲染进程会拆分出多个小的绘制指令,然后组装成一个有序的待绘制列表。
合成渲染
从硬件层面看,渲染操作是由显卡进行的,于是浏览器将具体的绘制动作,转化成待绘制指令列表。
浏览器渲染进程中的合成线程会将数据传输到栅格化线程池从而实现图块的栅格化最终把生成图块的指令发送给GPU。然后在GPU中执行生成图块的位图并保存在GPU的内存中。
此时显示器会根据显示器的刷新率,定期从显卡的内存中读取数据。这样,图像就可以正常显示,被我们看到了。
浏览器渲染的流程比较复杂,其中的细节也比较多,如果要详细分析,还可以拆成一篇超长篇幅,所以这里我们只是了解简单过程。你如果想要了解完整过程,可以阅读拓展材料中的 Chrome 开发者的官方博客。
Chromium 的文件结构解析
前面课程里,我们通过一些概念和例子简单了解了 WebKit 和 Chromium 的架构,不过这两者是非常庞大的项目,代码量也是非常的巨大,除去其中依赖的第三方库,这两个项目的代码量都是百万级别的,如果直接阅读的话是非常困难的。
但是良好的代码组织结构,很好地帮助了开发者和学习者们。下面我大致介绍一下它们的目录结构及其用处,方便你快速地理解整个项目。
因为里面的一二级目录非常多和深,所以我们把焦点放在核心的部分即可。我们可以通过 GitHub 将 Chromium 的源码下载下来阅读,但是源码非常大,如果你不想下载,可以通过这个链接 访问在线版本。
├── android_webview - 安卓平台webview的 `src/content` 目录所需要的接口
├── apps - chrome打包 apps 的代码
├── base - 基础工具库,所有的子工程公用
├── build - 公用的编译配置
├── build_overrides //
├── cc - 合成器
├── chrome - chrome 相关的稳定版本实现比如渲染进程中的某些API 的回调函数和某些功能实现
├── app - 程序入口
├── browser - 主进程
├── renderer - 渲染进程
...
├── chromecast
├── chromeos - chromeos 相关
├── components - content层调用的一些组件模块
├── content - 多进程模型和沙盒实现的代码
├── app - contentapi 的部分 app 接口
├── browser - 主进程的实现
├── common - 基础公共库
├── gpu - gpu 进程实现
├── ppapi_plugin - plugin 进程实现
├── public - contentapi 接口
├── renderer - 渲染进程实现
...
├── courgette
├── crypto - 加密相关
├── device - 硬件设备的api抽象层
├── docs - 文档
├── gpu - gpu 硬件加速的代码
├── headless - 无头模式,给 puppeteer 使用
├── ipc - ipc 通信的实现,包括 mojo 调用和 ChromeIPC
├── media - 多媒体相关的模块
├── mojo - mojo 底层实现
├── native_client_sdk
├── net - 网络栈相关
├── pdf - pdf 相关
├── ppapi - ppapi 代码
├── printing - 打印相关
├── sandbox - 沙箱项目,安全用防止利用漏洞攻击操作系统和硬件
├── services
├── skia - Android 图形库,直接从 Android 代码树中复制过来的
├── sql - 本地数据库实现
├── storage - 本地存储实现
├── third_party - 三方库
├── Webkit
...
├── tools
├── ui - 渲染布局的基础框架
├── url - url 解析和序列化
└── v8 - V8 引擎
重点回顾
今天,我们学习了 Chrome 下的多进程之间的协作方式。
老版本的 Chrome 使用 Legacy IPC 进行 IPC 通信,它的本质就是通过 IPC::Channel 接口发送 IPC::Message。而新版本的 Chrome 使用了 Mojo 进行 IPC 通信Mojo 是源于 Chrome 的 IPC 跨平台框架。Chrome 在不同的操作系统下的 IPC 实现方式有所不同在Linux上是 Domain SocketWindows 是 Named PipemacOS是Mach Port。
之后,我们通过网页渲染的例子深入了解了,不同进程之间如何协作来进行渲染。最后我给你列举了 Chrome 项目的基本目录结构,如果你对其感兴趣,可以自行下载源码,深入探索。
这节课的导图如下,供你参考:
扩展阅读
浏览器是一个极为庞大的项目,仅仅通过两节课的内容,想要完全了解浏览器的特性是不太可能的。希望这两节课能抛砖引玉,更多的内容需要你自己去进行探索。
这里我为你整理了一些参考资料,如果你能够认真阅读,相信会获得意想不到的收获。
首先是 Chromium 官方的设计文档,包含了 Chromium and Chromium OS 的设计思维以及对应源码。
其次是 Chrome 开发者的官方博客,里面的系列文章详细介绍了 Chrome 渲染页面的工作流程。
还有Mojo 的官方文档从这里你可以了解Mojo 的简单使用以及实现。
最后就是《WebKit技术内幕》这本书详细介绍了WebKit的渲染引擎和 JavaScript 引擎的工作原理
思考题
为什么JS代码会阻塞页面渲染从浏览器设计的角度看浏览器可以做哪些操作来进行优化在开发前端应用过程中又可以做哪些优化呢
欢迎你在留言区和我交流讨论。如果这节课对你有启发,别忘了分享给身边更多朋友。