2.2 线程模型与并发机制 上一节看到发送链在多个执行载体之间接力,本节把这些载体本身讲清楚:引擎有哪几条线程、任务队列怎么工作、线程归属怎么声明与检查。这是全书工程性最强的一节,也是嵌入定制代码时回报最高的知识。 三条主线程的分工 引擎为每个连接实体配备三条长驻线程,职责以局规形式写死。信令线程管一切"会话级"的事:协商状态机、接口调用的入口、应用回调的外送,接口文档里标注"必须在信令线程调用"的方法全部落在这里。网络线程管收发与传输层:套接字读写、候选检查、打包后的投递,它的纪律是最小化阻塞——任何耗时操作进了网络线程,全线延迟都会抖。工作线程管重活:编码、解码、统计聚合这类毫秒级运算,都通过任务投递挪到这里执行。
上一节看到发送链在多个执行载体之间接力,本节把这些载体本身讲清楚:引擎有哪几条线程、任务队列怎么工作、线程归属怎么声明与检查。这是全书工程性最强的一节,也是嵌入定制代码时回报最高的知识。
引擎为每个连接实体配备三条长驻线程,职责以局规形式写死。信令线程管一切"会话级"的事:协商状态机、接口调用的入口、应用回调的外送,接口文档里标注"必须在信令线程调用"的方法全部落在这里。网络线程管收发与传输层:套接字读写、候选检查、打包后的投递,它的纪律是最小化阻塞——任何耗时操作进了网络线程,全线延迟都会抖。工作线程管重活:编码、解码、统计聚合这类毫秒级运算,都通过任务投递挪到这里执行。
音频还有一条更特殊的实时线程,由系统音频设备驱动,优先级极高且对调度延迟极度敏感。它的规矩最严:禁止投递阻塞任务、禁止加锁等待、处理路径必须恒定耗时。音频链路之所以在架构上自成体系,一半原因就是伺候这条线程。

任务队列是这套并发体系的唯一桥梁。它本质是一个按序执行的mailbox:任何线程都可以往队列投递一个可调用对象,队列的属主线程按到达顺序逐个执行。数据需要跨线程时,不是两个线程去抓同一块缓冲,而是把数据的所有权连同处理逻辑一起投递过去——上一线程用完即弃,下一线程独占处理。共享状态因此只剩队列本身,而队列的入队操作足够短,竞争窗口小到可以忽略。
从使用者的角度,掌握两个动作就够。异步投递是最常用的:把活儿扔进队列立即返回,适合绝大多数跨线程协作;同步等待要慎用:当前线程阻塞到对方执行完毕才继续,它天然引入双向依赖,两边互相同步等待就是死锁。引擎自己的代码里同步等待几乎只出现在销毁与配置变更这类无法异步化的场景,而且都严格限制等待方向,嵌入代码模仿这个克制程度即可。
// 异步投递:编码线程把就绪帧移交发送队列 send_queue_->PostTask([frame = std::move(frame), this]() mutable { RTC_DCHECK_RUN_ON(send_queue_); pacer_->EnqueuePacket(frame.BuildPacket()); });
线程归属的声明与检查由断言设施完成:对象声明自己隶属于哪个任务队列,方法入口处放断言,一旦被错误线程调用,调试版立刻崩溃报错。新手觉得这个"宁可崩溃"很凶,老手明白这正是善意——归属错误如果默默放过,换来的是极难复现的数据竞争;当场崩溃把问题钉死在调用栈上,修复成本反而最低。
背景。团队把自研的屏幕采集模块接入引擎后,压测环境隔天必崩一次,崩溃点落在引擎内部的断言上,提示方法在错误线程被调用。开发机上无法复现,只有长跑压测能触发,问题挂了数日。
操作。先看崩溃栈:断言所属对象是接收侧的统计实体,声明隶属于工作线程;而崩溃时调用栈的顶端来自采集模块的回调线程。再查接入代码,发现采集模块在通知"新帧到达"时,为了图省事直接同步调用了引擎接口,而这个接口按约定只允许信令线程触碰。压测机核多、负载波动大,时序恰好错开的概率低,所以只在长跑中暴露。
结果。修改接入层:帧到达事件改为向信令线程投递一个轻量通知,帧数据本体经由引擎自己的通道传递,回调线程不直接触碰任何引擎对象。改完长跑三天,崩溃消失。
解读。这个案例里断言的价值值得反复讲:如果引擎不做归属检查,这次并发错误会变成偶发的统计错乱或内存踩踏,排查成本可能是一个月;断言把它压缩成一次明确指向的崩溃,半天定位。这也解释了嵌入引擎的开发方式为何强调"回调里不做重活、不改状态"——回调发生在引擎线程上,重活应当立刻投递回宿主自己的队列。
变式。同类问题的另一常见形态是"在引擎回调里同步等待另一个引擎调用",表现为低频死锁而非崩溃,排查时抓一次卡死时刻的全线程栈,看两条互相等待的队列即可定罪。预防手段统一且简单:引擎线程上只做轻活与投递,一切可能等待的操作挪回宿主线程。
按本节的机制,把并发类问题的两种典型形态与定位动作收成速查表,压测期贴在工位上能救急。
| 形态 | 表象 | 定位动作 |
|---|---|---|
| 归属错误 | 断言崩溃带 Wrong thread 字样 | 看断言对象声明的队列与调用栈顶端线程 |
| 互相等待 | 卡死不崩、线程栈互指 | 抓全线程栈,找两条互等的队列 |
| 池化泄漏 | 内存缓涨无平台 | 检查回调里是否长期持有借出的缓冲 |
| 回调重活 | 周期性尖刺与抖动 | 审计回调耗时,重活改投递 |
其中"互相等待"的形态最隐蔽,因为它不崩溃、只在特定时序下卡住几十秒或永久。判定它有一段固定的思维路径:卡死时刻抓全线程栈,若线程甲的栈顶是"等待任务完成"类调用、线程乙的栈顶正在处理一个需要甲线程结果的任务,两条栈互指即定罪。预防永远是上策——同步等待的方向必须在设计期定死并写进评审清单,运行期再发现就晚了。
还有一条经验值得单独交代:音频实时线程上的一切异常都会被放大。这条线程的调度窗口以毫秒计,任何在别的线程上无伤大雅的操作——读一次磁盘配置、等一把锁、打一条同步日志——在它上面都是停顿事故。凡涉及音频链路的定制,先把"恒定耗时"作为第一设计约束,再谈功能。
最后补一组数值直觉,帮助你在设计时做心算:任务投递一次的开销在微秒量级,跨线程通知(唤醒对方线程)在几十微秒量级,音视频帧的处理预算是十毫秒量级——三层差着两到三个数量级。这个悬殊意味着:投递本身几乎永远不是瓶颈,真正的成本在投递过去之后干了什么。反过来,若你在一条任务路径上串了成百次投递,每次几十微秒的唤醒开销累加起来也会进入毫秒视野——链路设计以"尽量少的跨线程接力"为美,接力次数是比单次开销更值得优化的量。
本节要点:三条主线程加一条音频实时线程构成全部执行载体,职责与纪律写死;任务队列用移交代替共享,异步投递是常态、同步等待是例外;归属断言宁可崩溃也要把并发错误钉在当下;嵌入代码的黄金法则是回调轻量化、跨线程全投递。至此,架构与线程两张底图都齐了,第三章开始,我们正式进入连接建立的时序世界。