38|浏览器原理(二):浏览器进程通信与网络渲染详解

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个“应用层协议”,分别为MessagePipe,DataPipe和SharedBuffer(这里你是不是感觉很像网络栈,在 TCP 上封装了 HTTP)。整体结构如下图:

在这里插入图片描述

我们在 Chromium 代码中使用 Mojo,是不必做 Mojo 初始化相关工作的,因为这部分Chromium 代码已经做好了。如果我们在 Chromium 之外的工程使用 Mojo,还需要做一些初始化的工作,代码如下:

int main(int argc, char** argv) {
  // 初始化CommandLine,DataPipe 依赖它
  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 和 handle1,MessagePipe 向其中一个handle写的数据可以从另外一个handle读出来。如果把其中的一个 handle 发送到另外一个进程,这一对 handle 之间依然能够相互收发数据。

Mojo 提供了多种方法来发送 handle 到其他的进程,其中最简单的是使用 Invitation。要在多个进程间使用 Mojo,必须先通过 Invitation 将这些进程“连接”起来,这需要一个进程发送Invitation,另一个进程接收 Invitation。

发送Invitation的方法如下:

// 创建一条系统级的IPC通信通道
// 在Linux上是 Domain Socket, Windows 是 Named Pipe,macOS是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个Message 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会被复制到新的随机的fd,fd号改变
// 在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::Message,IPC::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 解析的过程中当解析到

在 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 Socket,Windows 是 Named Pipe,macOS是Mach Port。

之后,我们通过网页渲染的例子深入了解了,不同进程之间如何协作来进行渲染。最后我给你列举了 Chrome 项目的基本目录结构,如果你对其感兴趣,可以自行下载源码,深入探索。

这节课的导图如下,供你参考:

在这里插入图片描述

扩展阅读

浏览器是一个极为庞大的项目,仅仅通过两节课的内容,想要完全了解浏览器的特性是不太可能的。希望这两节课能抛砖引玉,更多的内容需要你自己去进行探索。

这里我为你整理了一些参考资料,如果你能够认真阅读,相信会获得意想不到的收获。

1.首先是 Chromium 官方的 设计文档,包含了 Chromium and Chromium OS 的设计思维以及对应源码。

2.其次是 Chrome 开发者的 官方博客,里面的系列文章详细介绍了 Chrome 渲染页面的工作流程。

3.还有Mojo 的 官方文档,从这里你可以了解Mojo 的简单使用以及实现。

4.最后就是 WebKit技术内幕 ,这本书详细介绍了WebKit的渲染引擎和 JavaScript 引擎的工作原理

思考题

为什么JS代码会阻塞页面渲染?从浏览器设计的角度看,浏览器可以做哪些操作来进行优化?在开发前端应用过程中又可以做哪些优化呢?

欢迎你在留言区和我交流讨论。如果这节课对你有启发,别忘了分享给身边更多朋友。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

源码头

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值