3. 多线程与异步架构:推流线程模型设计、线程池调优、异步IO与事件驱动、避免锁竞争
推流这事儿,说白了就是跟时间赛跑。你想想看,一帧视频数据从采集到编码再到网络发送,中间但凡卡一下,用户那边就是花屏、卡顿、音画不同步。我早期做推流SDK的时候,单线程一把梭,结果CPU一飙,整个进程都跟着抖。后来才明白,推流架构的核心,就是怎么把“干活的人”安排明白。
3.1 推流线程模型设计:别让一个人扛所有事
推流链路其实挺清晰的:采集→预处理→编码→封装→发送。但每个环节的节奏完全不一样。采集是硬件中断驱动的,编码是CPU/GPU密集型的,网络发送是IO阻塞型的。你把它们放一个线程里,任何一个环节慢下来,其他都得等着。
我个人习惯把推流拆成三个核心线程:
| 线程名称 | 职责 | 特点 |
|---|---|---|
| 采集线程 | 从摄像头/麦克风获取原始数据 | 实时性要求极高,回调频率固定 |
| 编码线程 | 将原始帧压缩为H.264/H.265 | CPU/GPU密集型,耗时不确定 |
| 发送线程 | 将封装后的数据包推送到CDN | IO阻塞型,依赖网络状况 |
这三个线程之间通过无锁队列传递数据。采集线程只管往队列里塞原始帧,编码线程从队列里取帧编码,编码完再塞给发送队列。这样每个线程各干各的,谁也不等谁。
关键原则:线程之间不要有同步依赖。采集线程不需要知道编码线程什么时候处理完,编码线程也不需要等网络发送完成。数据流是单向的,这是高并发推流的基础。
我在项目中遇到过一个问题:采集线程回调频率是30fps,但编码线程处理一帧可能要40ms。如果采集线程等编码线程处理完再继续,帧率直接掉到25fps。拆成独立线程后,采集线程只管收数据,编码线程慢慢消化,中间用队列缓冲,帧率就稳住了。
3.2 线程池调优:别让线程频繁创建销毁
推流过程中,有些任务是突发性的,比如网络重连、日志写入、截图保存。这些任务如果每次都创建新线程,开销太大了。线程池就是干这个的。
但线程池不是越大越好。我见过有人把线程池设成64个线程,结果上下文切换开销比干活还多。调优线程池,核心看两个参数:核心线程数和最大线程数。
| 参数 | 建议值 | 说明 |
|---|---|---|
| 核心线程数 | CPU核心数 + 1 | 保证CPU密集型任务不浪费 |
| 最大线程数 | 核心线程数 × 2 | 应对IO阻塞时的突发任务 |
| 队列容量 | 128 ~ 512 | 太大导致延迟,太小导致拒绝 |
| 空闲存活时间 | 60秒 | 避免线程频繁回收重建 |
我的经验:推流场景下,线程池的队列最好用SynchronousQueue。它不缓存任务,直接交给线程处理。如果线程不够,就创建新线程。这样能保证突发任务不被积压,延迟最低。
我曾经在直播推流SDK里用过LinkedBlockingQueue,结果网络抖动时,队列里积压了上千个重连任务。等网络恢复,这些任务瞬间全部执行,直接把CPU打满。换成SynchronousQueue后,任务要么立刻执行,要么被拒绝,反而更可控。
3.3 异步IO与事件驱动:别让线程傻等
推流最怕什么?网络发送阻塞。你调用send()发一个包,如果对端窗口满了,这个调用可能卡住几十毫秒。如果发送线程被卡住,后面的帧就发不出去了。
解决方案就是异步IO。Linux上用epoll,macOS/iOS上用kqueue,Windows上用IOCP。核心思想是:你告诉操作系统“我要发数据”,然后该干嘛干嘛,等操作系统通知你“可以发了”,你再发。
事件驱动模型在推流中的典型应用:
- 非阻塞socket:设置
O_NONBLOCK,send()不会阻塞,返回EAGAIN就等下次可写事件。 - 事件循环:一个线程管理所有socket的读写事件,通过epoll_wait批量处理。
- 回调机制:数据准备好后,通过回调通知上层逻辑,而不是轮询检查。
注意:异步IO的回调函数里不要做耗时操作。回调里只做数据拷贝和状态标记,真正的处理逻辑扔到线程池里。否则回调阻塞,整个事件循环就停了。
我记得有一次优化推流SDK的网络模块,把同步send()改成epoll+非阻塞IO后,发送线程的CPU占用从15%降到了3%。因为线程不再空转等待网络,而是真正有数据时才被唤醒。
3.4 避免锁竞争:锁是性能杀手
多线程编程,锁是绕不开的。但锁用不好,性能直接腰斩。推流场景下,数据流是高频的,每一帧都可能涉及多个线程的共享数据访问。锁竞争一旦激烈,线程都在等锁,实际干活的时间少得可怜。
我总结了几条避免锁竞争的经验:
- 用无锁数据结构:比如
boost::lockfree::queue、moodycamel::ConcurrentQueue。它们基于CAS原子操作,没有锁等待。 - 缩小锁粒度:如果必须用锁,只锁最小范围的代码。比如更新一个计数器,只锁那行赋值语句,不要锁整个函数。
- 读写分离:读多写少的场景,用
std::shared_mutex。多个线程可以同时读,只有写的时候才独占。 - 避免锁嵌套:线程A持有锁1,等锁2;线程B持有锁2,等锁1。这就是死锁。我习惯用
std::lock一次性锁多个互斥量,避免顺序问题。
避坑指南:我曾经在编码线程和发送线程之间用了一个std::mutex保护的队列。压测时发现,编码线程有30%的时间在等锁。换成无锁队列后,编码线程的吞吐量直接提升了40%。锁的代价比你想象的大得多。
另外,原子操作也不是万能的。我记得有一次用std::atomic<int>做引用计数,以为没有锁就万事大吉。结果多核CPU下,频繁的CAS操作导致缓存行 bouncing,性能反而比用锁还差。后来改成每线程本地计数,最后汇总,才解决问题。
3.5 整体架构图:把思路串起来
说了这么多,咱们用一张图把整个多线程异步架构串起来。你一看就明白各个模块怎么配合了。
这张图里,数据从左到右单向流动,每个线程只关心自己的事。发送线程通过事件驱动监听socket可写事件,不用轮询。线程池在旁边待命,处理那些不紧急的杂活。整个架构的核心就是解耦和异步。
最后说一句:多线程架构没有银弹。你得根据实际场景去调线程数、队列大小、锁策略。我建议你在压测时用perf或者火焰图看看线程都在等什么,找到瓶颈再优化。别一开始就想着上无锁队列,有时候一个简单的读写锁就够了。