这是我 Chromium 源码学习系列的第七篇。前面六篇分别写了整体架构、Navigation、Network、Renderer、Input 和 V8 / JavaScript Pipeline。这一篇不再新开一条业务链路,而是回头把前几篇里反复出现、但每次都一笔带过的一件事讲清楚:跨进程调用。
写到第七篇,我发现有个词一直在出现
整理前几篇笔记的时候,我注意到一件事。
几乎每一篇里,都有一个"跨进程"的瞬间:
第二篇 Navigation Pipeline 里,content 组织完导航后,通过 CommitNavigation 把 commit 参数和 body pipe 交给 renderer。这是一次 Browser process 到 Renderer process 的调用。
第三篇 Network Pipeline 里,资源请求通过 URLLoaderFactory 创建 URLLoader,交给 Network Service 处理,响应再通过 URLLoaderClient 持续返回。这是 Renderer / Browser 和 Network Service 之间的调用。
第四篇 Renderer Pipeline 里,cc 合成完的 CompositorFrame 要提交给 Viz 做最终聚合。这是 Renderer process 到 GPU process 的调用。
第五篇 Input Pipeline 里,Browser process 判断完输入目标后,要把事件转发给正确的 renderer。这又是一次跨进程调用。
当时写这些的时候,我都是一句话带过:"交给""提交给""转发给"。
好像进程之间传东西是一件很自然的事。
但它其实一点都不自然。
进程之间不能直接调用 C++ 对象
这是多进程架构里最基本的一个约束。
Browser process 里的一个对象,不能直接 new 一个 Renderer process 里的对象,也不能直接调用它的方法。它们是两个独立的操作系统进程,地址空间完全隔离。
所以前面那些"交给""提交给""转发给",每一次背后都需要回答同一组问题:
text
接口怎么定义,让两边对方法和参数有一致理解?
调用方手里拿到的是什么?
实现方在另一个进程,消息怎么送过去?
结果怎么回来?
Chromium 对这组问题的统一答案,就是 Mojo。
我现在的理解是:
text
前面几篇里所有的跨进程调用,
不管是 CommitNavigation、URLLoaderFactory 还是 CompositorFrame 提交,
底下都是同一套机制:
接口用 .mojom 定义,调用方持有 Remote,服务方绑定 Receiver,
方法调用被序列化成消息,通过 MessagePipe 跨进程传递。
这一篇就想把这个基本模型讲清楚。
一切从 .mojom 开始
Mojo 里的跨进程接口,先用 .mojom 文件定义。
比如一个简化的例子:
mojom
interface PermissionService {
HasPermission(PermissionDescriptor permission)
=> (PermissionStatus status);
};
真实接口的参数和返回类型会更复杂一些(比如返回的是 PermissionStatusWithDetails),但形态就是这样。
.mojom 本身只是一份接口合同。
它定义了:
text
接口名
方法名
参数类型
返回值类型
但它不决定:
text
谁调用?
谁实现?
在哪个进程?
调用方和实现方怎么接起来?
编译时,.mojom 会生成对应的 C++ bindings,让调用方和实现方都使用同一套强类型接口。第三篇里出现过的 URLLoaderFactory、URLLoader、URLLoaderClient,源头都是 services/network/public/mojom/ 下的 .mojom 文件。
接口合同有了,接下来的问题是:两边怎么接起来?
四个角色
Mojo 最小模型里有四个角色,我第一次看的时候经常混,后来发现用一张表就能记住:
| 对象 | 谁持有 | 作用 |
|---|---|---|
mojo::Remote<T> |
调用方 | 发接口消息,接 response |
mojo::PendingReceiver<T> |
临时传递中 | 代表还没绑定的接收端 |
mojo::Receiver<T> |
服务方 | 接消息,分发给 Impl |
Impl |
服务方 | 真正执行接口方法 |
最短版:
text
Remote 发消息,PendingReceiver 传接收端,Receiver 绑定 Impl,Impl 真正执行。
这里最重要的一个纠正是:
text
Remote 不是远端对象,Remote 是本地代理。
调用方写 permission_service_->HasPermission(...) 时,看起来像在调用一个普通 C++ 对象,但这个对象不是 PermissionServiceImpl,它只负责把方法调用编码成 Mojo message 发出去。
这张图是我现在对 Mojo 基本模型的整体理解:.mojom 定义合同,生成 bindings,调用方持有 Remote,服务方用 Receiver 绑定 Impl,中间靠 MessagePipe 传消息。
最容易混的一件事:接线和调用是两个阶段
我刚开始看 Mojo 代码的时候,最大的困惑是:一堆 BindNewPipeAndPassReceiver、GetInterface、BindReceiver 的代码,和真正的 remote->Method() 调用混在一起,分不清哪些代码在"建立连接",哪些代码在"使用连接"。
后来我把它拆成两个阶段,一下就清楚了:
text
接线阶段:把 Remote 和 Impl 接起来。
调用阶段:在接好的线上发消息。
这两个阶段分开看,Mojo 的代码就不再绕了。
接线阶段:新 pipe 的一头,是通过老 pipe 送过去的
先看接线。
调用方通常会写这样一行代码:
cpp
mojo::Remote<blink::mojom::PermissionService> permission_service_;
auto receiver = permission_service_.BindNewPipeAndPassReceiver();
BindNewPipeAndPassReceiver() 做了三件事:
text
创建一条新的 MessagePipe;
Remote 绑定 pipe 的一端;
另一端包装成 PendingReceiver 返回。
到这一步,Renderer 手里有了一个能发消息的 Remote,和一个还没人认领的 PendingReceiver。
接下来的问题是:PendingReceiver 要交到 Browser 手里,Browser 才能把它绑定到真正的实现对象。但 PendingReceiver 不能凭空飞到另一个进程。
它必须走一条已经存在的通道。
对 frame 级的 browser 侧接口来说,这条已有通道就是 BrowserInterfaceBroker。Renderer 通过它的 GetInterface() 把 PendingReceiver 送到 Browser,Browser 侧再通过 BinderMap 找到这个接口对应的绑定入口,创建 Impl 并用 mojo::Receiver 绑定。

这张时序图是接线阶段的完整过程。我觉得里面最关键的一句话是:
text
新 pipe 的一头,是通过老 pipe 送过去的。
业务接口的 pipe 是新建的,但把接收端送到对面进程这件事,靠的是一条已经接好的基础通道。
那 BrowserInterfaceBroker 自己从哪来?
看到这里我当时立刻卡住了一个问题:
text
业务接口的 PendingReceiver 通过 BrowserInterfaceBroker 传,
那 BrowserInterfaceBroker 自己的 pipe 是谁传的?
这看起来像鸡生蛋。
答案是:递归有一个根。
BrowserInterfaceBroker 不是通过业务接口机制自己创建自己的。它是在 frame 创建、navigation commit 这类系统级流程里,由更基础的浏览器-渲染器控制通道直接带过去的。
这里正好回扣了第二篇。
第二篇里我写过,CommitNavigation 把 commit 参数交给 renderer。当时我只关注了"页面从这里开始切换"。现在再看,broker 通道的接线就藏在这类系统级流程里,而且分成两种情况。
frame 创建时,Browser 会在 content/common/frame.mojom 的 CreateFrame / CreateChildFrame 参数里,直接把 broker 的 remote 端下发给 renderer。
跨文档 commit 时,方向反过来:renderer 会为新 document 新建一条 broker pipe,自己留住 remote,把接收端装进 commit 的回执参数(content/common/frame_messages.mojom 里的 DidCommitProvisionalLoadInterfaceParams)交回 Browser,Browser 再把它绑定到自己这一侧。
也就是说:
text
navigation commit 不只是提交了一个新页面,
这一来一回,同时把新 document 后续请求各种浏览器能力的基础通道也接好了。
有意思的是,"新 pipe 的一头,是通过老 pipe 送过去的"这句话,对 broker 自己也成立------只不过这一次,新 pipe 是 renderer 建的,老 pipe 是 frame 创建和 commit 用的那条更基础的控制通道。
frame 创建和 commit 这类流程建立基础通道;之后所有普通业务接口,再通过这条基础通道按需接线。
调用阶段:一次调用怎么走完往返
接线完成后,才轮到真正的方法调用:
cpp
permission_service_->HasPermission(permission, callback);
这一步底下发生的事情是:
text
Remote 把 HasPermission 和参数编码成 Mojo message;
message 通过这条业务 pipe 送到 Browser 进程;
Receiver 收到后反序列化,分发给 PermissionServiceImpl::HasPermission();
Impl 执行完,结果编码成 response message 原路返回;
Remote 收到 response,调用当初传入的 callback。

这张时序图里我想强调两点。
第一,调用默认是异步的。remote->Method() 发完消息就返回了,结果通过 callback 回来。跨进程通信里等待对方是有代价的,Mojo 把异步作为默认形态,而不是让它看起来像一次同步本地调用。
第二,方法调用不创建通道。remote->Method() 只是在已经存在的业务 pipe 上发一条 message。接线是接线,调用是调用。
有些场景的结果不是一次性的 response,而是持续的流式回调。这时 .mojom 里会定义一个反向的 client 接口,由调用方实现、服务方持有。第三篇里的 URLLoaderClient 就是这种:redirect、response headers、body、complete 都是 Network Service 通过它持续回调给请求方的。
三层生命周期
接线和调用分开之后,还有一层容易糊掉的东西:这些通道各自活多久?
我现在会把它分成三层:
text
Frame / Document 生命周期
└── BrowserInterfaceBroker pipe
├── PermissionService pipe
│ ├── HasPermission message
│ ├── RequestPermission message
│ └── ...
├── ClipboardHost pipe
└── ...
第一层,BrowserInterfaceBroker 基础通道跟着 frame / document 走。frame 创建或新 document commit 时接好,document 替换、frame 销毁时关闭。它不是每请求一个接口就新建一次。
第二层,业务接口通道按需创建。Renderer 需要 PermissionService 时才建这条 pipe,建好后可以被多次方法调用复用。它什么时候断,取决于 Remote、Receiver、Impl 是否还活着,以及 frame / document 生命周期是否结束。
第三层,一次方法调用只是业务 pipe 上的一条 message。调用结束后 pipe 不销毁。
最短版:
text
Broker 通道跟 frame/document 走;
业务通道按需建立;
方法调用只是通道上的消息。
回头看第三篇:URLLoaderFactory 就是这套模型
有了基本模型,再回头看第三篇的资源加载,会发现它就是这套模型的一个标准实例:
text
Renderer 需要加载资源
-> 持有 network::mojom::URLLoaderFactory 的 Remote
-> CreateLoaderAndStart 把 ResourceRequest 和 URLLoader 的 PendingReceiver 交出去
-> Network Service 绑定 URLLoader receiver 并开始加载
-> 响应通过 URLLoaderClient 持续回调:redirect、response、body、complete
对号入座:
URLLoaderFactory/URLLoader:Impl 在 Network Service,Remote 在请求方。URLLoaderClient:反向 client 接口,请求方实现,Network Service 持有。- Blink 不需要链接 net 内部对象,它只依赖这几个
.mojom接口。
第四篇的 CompositorFrame 提交、第五篇的输入事件转发,同样能对号入座:cc 通过 mojom 接口把 frame 提交给 Viz,Browser 通过 mojom 接口把输入事件发给 renderer。接口不同,模型相同。
写到这里,前几篇里那些"交给""提交给""转发给",在我这里终于不再是黑盒了。
出问题的时候,我会从哪查起
Mojo 相关的问题,症状经常很统一:调用发出去了,没有任何回应,也不报错。
我现在的反查路径是:
text
结果没回来
-> callback / client 是否还活着
-> receiver 是否绑定到实现对象
-> remote 是否正确创建并绑定
-> .mojom 方法和参数是否正确
-> 服务进程是否启动 / 存活
-> 调用是否被策略拒绝
反过来,想找一个 .mojom 接口的实现在哪个进程,我会这样搜:
text
.mojom 接口名
-> generated namespace / C++ 类型
-> rg 接口名
-> rg BindReceiver / AddReceiver / MakeSelfOwnedReceiver
-> 实现类和它所在的进程
BindReceiver 这类绑定代码在哪,Impl 就在哪。这是我目前找"某个能力到底在哪个进程实现"最快的办法。
源码阅读时,我会先抓这些锚点
这一轮我用到的锚点:
text
mojo/public/cpp/bindings/remote.h
mojo/public/cpp/bindings/receiver.h
mojo/public/cpp/bindings/pending_receiver.h
mojo/public/cpp/bindings/pending_remote.h
third_party/blink/public/mojom/browser_interface_broker.mojom
content/common/frame.mojom
content/common/frame_messages.mojom
content/browser/renderer_host/render_frame_host_impl.cc
services/network/public/mojom/url_loader_factory.mojom
services/network/public/mojom/url_loader.mojom
看 remote.h 里的 BindNewPipeAndPassReceiver(),就能理解接线阶段的起点;看 render_frame_host_impl.cc 里的 BindBrowserInterfaceBrokerReceiver(),就能看到基础通道在 Browser 侧被接好的位置。
我现在会这样复述这条链路
text
定义接口:.mojom 声明方法和参数,生成 C++ bindings
接线阶段:
调用方 BindNewPipeAndPassReceiver() 创建新 pipe
Remote 留住一端,PendingReceiver 通过已有基础通道送到服务方
服务方用 Receiver 把它绑定到 Impl
调用阶段:
remote->Method() 编码成 Mojo message
message 跨进程送达,Receiver 分发给 Impl
结果通过 callback 或 client interface 返回
再压缩一点:
text
Broker 通道是基础通道;
业务通道通过 Broker 按需建立;
方法调用只是业务通道上的消息。
以及那句我觉得最点题的话:
text
新 pipe 的一头,是通过老 pipe 送过去的。
最后
这一篇和前面几篇不太一样。
前六篇每篇都在往前推一段新链路,这一篇是往下挖了一层:把前几篇里反复出现的"跨进程调用"打开,看到它们底下是同一套 Mojo 机制。
对我来说,这层理解最大的价值是,以后在源码里再看到 mojo::Remote、PendingReceiver、BindReceiver 这些代码时,我能立刻分清它是在接线还是在调用,也能顺着 .mojom 找到实现方所在的进程。
同时它也带出了下一个问题。这一篇里反复出现"跟着 frame / document 走"的生命周期:broker 通道在 frame 创建和 navigation commit 时接好,在 document 替换和 frame 销毁时关闭。那么 frame、document、renderer process 本身的生命周期是怎么管理的?一个 tab 背后有几个 frame?跨站 iframe 为什么会被放进不同进程?这会进入 Frame / Process Lifecycle Pipeline。
我的计划还是一样:读一段,写一段,把我踩过的坑和当时的误解都记下来。
如果你也在读 Chromium 源码,欢迎交流。