代理模式与线程安全 · Proxy 如何抹平线程

应用可能从任意线程调 API,组件却只能在归属线程上被碰——WebRTC 用「代理对象」抹平这个矛盾:每次调用都被自动、阻塞地转发到正确的线程执行。这一章拆开 pc/proxy.h 的宏魔法,讲清 Interface / Proxy / Impl 三层如何协作保证线程安全。

作者 David更新于 第 5 篇(共 112 篇)

代理模式与线程安全 · Proxy 如何抹平线程

上一章留了个矛盾:应用可能从任意线程调 PeerConnection 的方法,可 PeerConnection 的实现只能在 signaling 线程上被碰。这两者怎么调和?WebRTC 的答案,是一个贯穿全库的精巧设计——代理对象(Proxy)。理解它,你就理解了 Interface / Proxy / Impl 三副面孔背后的运作。

问题:调用方在哪,实现要求在哪,对不上

把矛盾摆明:

  • 实现对象 PeerConnection:内部状态用 RTC_GUARDED_BY(signaling_thread()) 保护,只能在 signaling 线程上安全访问。
  • 应用:可能在 UI 线程、网络回调线程、随便哪条线程上,想调 pc->CreateOffer(...)。

如果让应用的调用直接打到实现对象上,就会在错误的线程上访问受保护的状态——轻则数据竞争,重则崩溃。总不能要求每个应用开发者「调 WebRTC 前先手动切到 signaling 线程」吧?太反人性。

解法:在中间插一个代理

WebRTC 的做法是,在 Interface 和 Impl 之间插一层 Proxy:

代理对象和实现对象实现同一个接口,但代理的每个方法只做一件事:把调用「阻塞地」转发到实现对象的归属线程,在那条线程上执行真正的实现,再把结果带回来。 g3doc/threading_design.md 的原话:

Many APIs are defined in terms of a "proxy object", which will do a blocking dispatch of the function to another thread, and an "implementation object" which will do the actual work, but can only be created, invoked and destroyed on its "home thread".

于是应用在任意线程调用都安全:你调的其实是 PeerConnectionProxy,它替你跳到 signaling 线程,在那儿调真正的 PeerConnection,等它跑完、把返回值递给你。从调用方看,这就是一次普通的同步函数调用——线程的事,代理默默抹平了。

三层协作,一张图:

图表加载中…

宏魔法:proxy.h 怎么少写几千行

几十个接口、每个接口几十个方法,如果每个代理方法都手写「切线程、调用、返回」的样板,得写到天荒地老。WebRTC 用一组宏自动生成这些代理,全在 pc/proxy.h。它文件头的示例用法,把机制讲得一清二楚:

// pc/proxy.h 文件头注释里的示例
BEGIN_PROXY_MAP(Test)
  PROXY_PRIMARY_THREAD_DESTRUCTOR()
  PROXY_METHOD0(std::string, FooA)
  PROXY_CONSTMETHOD1(std::string, FooB, arg1)
  PROXY_SECONDARY_METHOD1(std::string, FooC, arg1)
END_PROXY_MAP()

读法:

  • BEGIN_PROXY_MAP(Test) / END_PROXY_MAP() —— 为 Test 生成一个 TestProxy 类。
  • PROXY_METHOD0(返回类型, 方法名) —— 生成一个无参方法,转发到主线程(primary thread)。数字是参数个数(PROXY_METHOD1 一个参数,以此类推)。
  • PROXY_SECONDARY_METHOD1(...) —— 转发到次线程(secondary thread)。
  • PROXY_PRIMARY_THREAD_DESTRUCTOR() —— 连析构都要在主线程上做(实现对象「只能在归属线程销毁」)。

主线程与次线程

代理支持两条目标线程,pc/proxy.h 文件头写明它们通常是谁:

the primary thread will be the PeerConnection's signaling thread, and the secondary thread will be either the PeerConnection's worker thread or the PeerConnection's network thread.

也就是说:大部分方法用 PROXY_METHOD 转发到 signaling 线程(控制面);少数需要在 worker/network 线程上做的方法,用 PROXY_SECONDARY_METHOD 转发到次线程。代理的创建函数因此长这样(pc/proxy.h 文件头):

// 传入两条线程 + 真正的实现对象
TestProxy::Create(Thread* signaling_thread, Thread* worker_thread,
                  TestInterface*);

还有一个变体 BEGIN_PRIMARY_PROXY_MAP,它只认主线程,所有方法都往 signaling 线程转发——适合完全不碰 worker/network 的组件。

代价:阻塞,以及「简单调用会跳线程」

代理很优雅,但不是没有代价,读源码和调性能时要记住两点:

  • 阻塞派发有开销:每次跨线程调用,调用线程都要等目标线程把这活儿排到、跑完。若目标线程正忙(比如 signaling 线程在处理一个大 SDP),你的调用就得排队等着。所以热路径上要尽量避免频繁的跨线程阻塞调用。
  • 「简单」调用其实会跳线程:这正好解释了上一章那条警告——increment(x); increment(x) 为什么不安全。因为每个 increment 都是一次代理调用,会跳到目标线程执行;两次之间,目标线程可能已经处理了一大堆别的排队任务。代理让「跨线程」变得透明,但透明不等于免费,更不等于原子。

小结与下一章

代理对象抹平了「应用在任意线程调用」与「实现只能在归属线程执行」的矛盾:Interface(契约)/ Proxy(阻塞转发到正确线程)/ Impl(在归属线程真正干活)三层协作。pc/proxy.h 用 BEGIN_PROXY_MAP / PROXY_METHOD0 / PROXY_SECONDARY_METHOD 等宏自动生成代理,主线程通常是 signaling、次线程是 worker 或 network。代价是阻塞派发的开销,以及「透明的跨线程调用并不原子」。

至此,导论的架构地基——是什么、源码地图、通话流程、线程、代理——已经就位。最后一章更实用:怎么真正上手读这套源码——构建系统、目录约定、命名规律,以及怎么对照本教程按图索骥。👉 怎么读这套源码。

参考资料

  • [1] libwebrtc 源码.pc/proxy.h(代理宏 BEGIN_PROXY_MAP / PROXY_METHOD 及文件头示例)。
  • [2] libwebrtc 源码.g3doc/threading_design.md(proxy object 与 blocking dispatch)。
  • [3] 设计模式.Proxy(代理)—— GoF 经典模式在多线程边界上的一次工业应用。

本页目录