Chromium 源码学习笔记(七):那些跨进程的调用,底下都是同一个东西——Mojo

这是我 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,让调用方和实现方都使用同一套强类型接口。第三篇里出现过的 URLLoaderFactoryURLLoaderURLLoaderClient,源头都是 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 代码的时候,最大的困惑是:一堆 BindNewPipeAndPassReceiverGetInterfaceBindReceiver 的代码,和真正的 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.mojomCreateFrame / 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::RemotePendingReceiverBindReceiver 这些代码时,我能立刻分清它是在接线还是在调用,也能顺着 .mojom 找到实现方所在的进程。

同时它也带出了下一个问题。这一篇里反复出现"跟着 frame / document 走"的生命周期:broker 通道在 frame 创建和 navigation commit 时接好,在 document 替换和 frame 销毁时关闭。那么 frame、document、renderer process 本身的生命周期是怎么管理的?一个 tab 背后有几个 frame?跨站 iframe 为什么会被放进不同进程?这会进入 Frame / Process Lifecycle Pipeline。

我的计划还是一样:读一段,写一段,把我踩过的坑和当时的误解都记下来。

如果你也在读 Chromium 源码,欢迎交流。

相关推荐
郝亚军1 小时前
如何安装webstorm、Node.js和vue CLI
前端·javascript·vue.js
IT_陈寒1 小时前
React的useEffect依赖项把我坑惨了
前端·人工智能·后端
东方小月2 小时前
从零开发一个Coding Agent:monorepo项目搭建
前端·后端·node.js
葬送的代码人生2 小时前
别再让 AI 瞎写代码了!Vibe Coding 三步法教你写出靠谱代码
前端·设计模式·架构
Shirley~~2 小时前
Code-Review-Graph:面向 AI 辅助代码审查的结构化上下文引擎
前端·ai编程
AI多Agent协作实战派2 小时前
AI多Agent协作系统实战(十七):凌晨4点,我的AI系统在“假装工作“——3个bug同时爆炸的5小时
java·前端·bug
qq_2518364572 小时前
基于java Web 动漫视频网站毕业论文
java·开发语言·前端
an317423 小时前
6MB 组织树大文件性能优化全流程
前端·javascript·vue.js
一只枫林3 小时前
MySQL内、外连接知识点汇总
java·前端·数据库
武子康3 小时前
Inkling 975B 说明“开放权重“与“普通开发者本地运行“已经分离,内容重点应是部署容量和运行时边界
前端·人工智能·后端