三大线程模型 · network / worker / signaling
WebRTC 是重度多线程的,而它的世界几乎完全由三条线程组织:signaling(控制)、worker(媒体处理)、network(收发包)。搞懂谁在哪条线程上跑,是读懂任何模块的前提。这一章讲清三线程的分工、TaskQueue 抽象,以及源码里那些 RTC_DCHECK_RUN_ON 注解。
三大线程模型 · network / worker / signaling
WebRTC 是重度多线程的系统——它必须是。想象一下:编码一帧 4K 图像要几毫秒,如果这几毫秒里网络包收不进来、API 调用也卡住,通话就会抖。所以 WebRTC 把工作切到不同线程并行跑。而它的整个世界,几乎完全由三条线程组织。不理解它们,你读任何模块都会一头雾水。
三条线程
PeerConnection 通过 ConnectionContext 持有三条线程,pc/connection_context.h 里看得清清楚楚:
// pc/connection_context.h(节选)
Thread* signaling_thread();
Thread* worker_thread();
Thread* network_thread();它们分工明确:
| 线程 | 负责 | 特点 |
|---|---|---|
| signaling(信令) | PeerConnection 的所有 API 调用、SDP 协商、状态机、观察者回调 | 控制面,逻辑复杂但不吃 CPU |
| worker(工作) | 媒体处理:编解码调度、Call 与各 modules 的运转、码率分配 | 计算面,CPU 密集 |
| network(网络) | 收发包:套接字 I/O、ICE 传输、包的路由分发 | I/O 面,延迟最敏感 |
一句话记住:signaling 管「控制」,worker 管「算」,network 管「收发」。
为什么偏偏是「线程」这么重要
因为在 WebRTC 里,「一个对象属于哪条线程」是它的硬约束——很多对象只能在自己的归属线程上被创建、调用、销毁,在别的线程上碰它就是 bug。g3doc/threading_design.md 讲得很直白:公共 API 被设计为从单一「客户端线程」调用,库内部再自行派发到该干活的线程去。
这带来一个反直觉的后果,文档特意警告:
writing "increment(x); increment(x)" is not a safe way to increment X by exactly two
因为哪怕看起来最简单的调用,内部也可能跳到别的线程执行,两次调用之间,别的线程可能已经插进来干了一堆事。在 WebRTC 里,「顺序执行」不是理所当然的——这正是多线程模型必须刻在脑子里的原因。
线程注解:代码自己声明「我该在哪跑」
WebRTC 不靠文档口头约定线程归属,而是把它写进代码、让编译器和运行期一起检查。你会在源码里铺天盖地看到两个宏:
RTC_DCHECK_RUN_ON 与 RTC_GUARDED_BY 是读 WebRTC 的路标——它们把「线程归属」变成可检查的断言。
RTC_DCHECK_RUN_ON(线程):放在函数体开头,断言「此函数必须在这条线程上被调用」。跑错线程,debug 版直接 crash,一眼抓出违规。RTC_GUARDED_BY(线程):放在成员变量声明后,声明「这个变量只能在这条线程上访问」,由静态分析在编译期检查。
pc/peer_connection.h 里俯拾皆是,例如:
// pc/peer_connection.h(节选)
PeerConnectionObserver* observer_ RTC_GUARDED_BY(signaling_thread()) = ...;
bool was_ever_connected_ RTC_GUARDED_BY(signaling_thread()) = false;
// 函数体里:
RTC_DCHECK_RUN_ON(signaling_thread());读代码时先看这些注解——它立刻告诉你「这段逻辑活在哪条线程」,是理解一个模块最快的入口。看到 RTC_DCHECK_RUN_ON(network_thread()),你就知道这是收发包的热路径、得快;看到 RTC_GUARDED_BY(signaling_thread()),你就知道这个状态属于控制面。
「线程」其实是「顺序任务队列」
这里要纠正一个直觉。WebRTC 说的「thread」,本质不是操作系统线程,而是「保证顺序执行的任务队列」。g3doc/threading_design.md 的脚注点明:
The term "thread" is used here to denote any construct that guarantees sequential execution - other names for such constructs are task runners and sequenced task queues.
这个抽象叫 TaskQueue(rtc_base/,第 10 部分细讲)。它的关键性质是:投进同一个队列的任务,一个接一个顺序执行,绝不并发。于是「在某条线程上」= 「在某个 TaskQueue 里排队」,而线程归属带来的安全性——同一队列内不会并发访问——就有了保证。一条 OS 线程通常承载一个 TaskQueue,但概念上二者是解耦的。
跨线程干活,靠的就是往目标队列投任务。比如 signaling 线程上的逻辑需要动媒体,就把一段活儿 post 到 worker 线程:
// 伪代码:把工作派发到 worker 线程的队列,顺序执行
worker_thread()->PostTask([this] {
RTC_DCHECK_RUN_ON(worker_thread()); // 到了对的线程
// …在 worker 线程上安全地干活…
});投递可以是异步(PostTask,不等结果)或阻塞(等它跑完再返回)。后者正是下一章「代理模式」的核心机制:代理帮你把跨线程调用阻塞地跳过去,让你在任意线程调 API 都安全。
回调在哪条线程
反过来,库通知应用(ICE 候选、连接状态变化、远端轨道到达)走 PeerConnectionObserver。g3doc/index.md 说明:这些回调多数在 signaling 线程上触发(准确说,是创建工厂时指定的那条 signaling 线程)。所以 g3doc/threading_design.md 给应用一条铁律:别在回调里直接回调 WebRTC 的函数——而应把活儿派发到你自己的线程再做,否则容易踩线程 / 重入的坑。
小结与下一章
WebRTC 的世界由三条线程组织:signaling(控制:API、SDP、状态机、回调)、worker(计算:Call、编解码、码率分配)、network(收发:套接字、ICE、包路由)。「线程」本质是顺序任务队列 TaskQueue,跨线程靠投任务。源码用 RTC_DCHECK_RUN_ON / RTC_GUARDED_BY 把线程归属写死进代码——它们是你读任何模块最快的路标。
三线程模型带来一个问题:应用可能从任意线程调 API,可组件却只能在归属线程上被碰——这个矛盾谁来抹平? 答案是 WebRTC 一个精巧的设计:代理对象。下一章拆开它。👉 代理模式与线程安全。
参考资料
- [1] libwebrtc 源码.
g3doc/threading_design.md(单客户端线程、顺序执行、TaskQueue)。 - [2] libwebrtc 源码.
pc/connection_context.h(三线程持有)、pc/peer_connection.h(RTC_DCHECK_RUN_ON/RTC_GUARDED_BY用例)。 - [3] libwebrtc 源码.
rtc_base/thread.h/TaskQueue(线程与任务队列实现,第 10 部分)。