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 避免锁竞争:锁是性能杀手

多线程编程,锁是绕不开的。但锁用不好,性能直接腰斩。推流场景下,数据流是高频的,每一帧都可能涉及多个线程的共享数据访问。锁竞争一旦激烈,线程都在等锁,实际干活的时间少得可怜。

我总结了几条避免锁竞争的经验:

  1. 用无锁数据结构:比如boost::lockfree::queue、moodycamel::ConcurrentQueue。它们基于CAS原子操作,没有锁等待。
  2. 缩小锁粒度:如果必须用锁,只锁最小范围的代码。比如更新一个计数器,只锁那行赋值语句,不要锁整个函数。
  3. 读写分离:读多写少的场景,用std::shared_mutex。多个线程可以同时读,只有写的时候才独占。
  4. 避免锁嵌套:线程A持有锁1,等锁2;线程B持有锁2,等锁1。这就是死锁。我习惯用std::lock一次性锁多个互斥量,避免顺序问题。

避坑指南:我曾经在编码线程和发送线程之间用了一个std::mutex保护的队列。压测时发现,编码线程有30%的时间在等锁。换成无锁队列后,编码线程的吞吐量直接提升了40%。锁的代价比你想象的大得多。

另外,原子操作也不是万能的。我记得有一次用std::atomic<int>做引用计数,以为没有锁就万事大吉。结果多核CPU下,频繁的CAS操作导致缓存行 bouncing,性能反而比用锁还差。后来改成每线程本地计数,最后汇总,才解决问题。

3.5 整体架构图:把思路串起来

说了这么多,咱们用一张图把整个多线程异步架构串起来。你一看就明白各个模块怎么配合了。

推流多线程异步架构图 采集线程 无锁队列 编码线程 无锁队列 发送线程 事件驱动 epoll / kqueue / IOCP 线程池 处理突发任务 架构说明: 1. 采集、编码、发送三个核心线程通过无锁队列解耦,互不阻塞。 2. 发送线程使用事件驱动模型,避免IO阻塞导致帧率下降。 3. 线程池处理网络重连、日志等突发任务,不干扰主链路。

这张图里,数据从左到右单向流动,每个线程只关心自己的事。发送线程通过事件驱动监听socket可写事件,不用轮询。线程池在旁边待命,处理那些不紧急的杂活。整个架构的核心就是解耦和异步。

最后说一句:多线程架构没有银弹。你得根据实际场景去调线程数、队列大小、锁策略。我建议你在压测时用perf或者火焰图看看线程都在等什么,找到瓶颈再优化。别一开始就想着上无锁队列,有时候一个简单的读写锁就够了。