第二十六章:多线程在音视频处理中的应用
音视频处理,说白了就是跟时间和数据量赛跑。一秒钟 30 帧的 1080p 视频,每帧可能有上百万个像素点要处理。单线程?那得等到天荒地老。我早年做第一个播放器项目时,就是单线程解码,结果画面卡得像幻灯片。后来才明白——多线程不是锦上添花,是刚需。
音视频编解码的并行化思路
编解码这事儿,天生就适合并行。为什么?因为视频是由一帧一帧独立画面组成的。帧与帧之间虽然有参考关系(比如 P 帧依赖 I 帧),但同一帧内部的宏块、切片,完全可以分开处理。
我个人习惯把并行化分成三个层次:
- 任务级并行:音频解码、视频解码、渲染输出,各跑各的线程。
- 数据级并行:把一帧图像切成若干块,每个线程处理一块。
- 流水线并行:解码、处理、编码三个阶段像工厂流水线一样接力跑。
我在项目中遇到过一种情况:用 8 个线程解码 4K 视频,结果性能反而下降了。排查后发现是锁竞争太严重——每个线程都在抢同一个帧缓冲区的锁。后来改成无锁队列,问题才解决。
核心原则:并行不是线程越多越好。线程数通常建议等于 CPU 物理核心数,超线程环境下可以适当多 1-2 个。多了反而因为上下文切换导致性能下降。
流水线模型:让每个核心都不闲着
流水线模型,我打个比方:就像工厂里的装配线。第一个工人拧螺丝,第二个工人装轮子,第三个工人喷漆。每个人只干自己的活,东西传下去就行。
在音视频处理中,典型的流水线是这样的:
// 伪代码:三阶段流水线
void* decoder_thread(void* arg) {
while (1) {
packet = read_packet(); // 阶段1:读取数据包
frame = decode(packet); // 阶段2:解码
queue_push(output_queue, frame); // 传给下一阶段
}
}
void* processor_thread(void* arg) {
while (1) {
frame = queue_pop(input_queue); // 从解码器拿帧
processed = process(frame); // 阶段3:处理(滤镜、缩放等)
queue_push(render_queue, processed);
}
}
void* renderer_thread(void* arg) {
while (1) {
frame = queue_pop(render_queue); // 从处理器拿帧
render(frame); // 阶段4:渲染显示
}
}
你看,每个线程只做一件事。解码线程不用等渲染完成,渲染线程也不用等解码开始。只要队列不空,大家都能全速跑。
我的经验:队列长度要控制好。太短会导致线程频繁等待,太长会占用过多内存。我一般设成 2-3 倍的帧缓冲数,比如 30fps 的视频,队列长度 60-90 就够用。
帧级并行处理:把一帧拆开干
流水线解决的是阶段间的并行。那阶段内部呢?比如解码一帧 4K 图像,单线程可能要 30 毫秒。如果拆成 4 块,每块 7.5 毫秒,理论上能快 4 倍。
这就是帧级并行。H.264/H.265 编码标准里本来就支持切片(slice)的概念。每个切片可以独立解码。我们只需要把一帧的比特流按切片边界拆开,分给不同线程处理。
// 帧级并行解码示例(简化版)
#define NUM_THREADS 4
typedef struct {
uint8_t* slice_data;
int slice_size;
int slice_id;
Frame* output_frame;
} SliceTask;
void* decode_slice(void* arg) {
SliceTask* task = (SliceTask*)arg;
// 解码当前切片,写入 output_frame 的对应区域
decode_h264_slice(task->slice_data, task->slice_size,
task->output_frame, task->slice_id);
return NULL;
}
void decode_frame_parallel(Frame* frame, uint8_t* bitstream, int size) {
pthread_t threads[NUM_THREADS];
SliceTask tasks[NUM_THREADS];
// 把一帧数据分成 4 个切片
int slice_size = size / NUM_THREADS;
for (int i = 0; i < NUM_THREADS; i++) {
tasks[i].slice_data = bitstream + i * slice_size;
tasks[i].slice_size = (i == NUM_THREADS-1) ?
(size - i * slice_size) : slice_size;
tasks[i].slice_id = i;
tasks[i].output_frame = frame;
pthread_create(&threads[i], NULL, decode_slice, &tasks[i]);
}
// 等待所有切片解码完成
for (int i = 0; i < NUM_THREADS; i++) {
pthread_join(threads[i], NULL);
}
}
嗯,这里要注意:不是所有编码格式都支持切片级并行。比如 VP9 就不太友好。我踩过这个坑——项目里用了 VP9,想并行解码,结果发现切片边界不清晰,强行拆分反而出错。后来老老实实换回 H.264。
避坑指南:我曾经在帧级并行时忽略了参考帧的同步问题。P 帧依赖前面的 I 帧,如果 I 帧还没解码完,P 帧的线程就开始跑,那画面就花了。解决办法是加一个屏障(barrier),确保 I 帧完全解码后,P 帧线程才能启动。
知识体系总览
下面这张图把本章的核心逻辑串起来了。你看一眼,心里就有数了。
实际项目中的组合策略
说实话,实际项目里很少只用一种模型。我最近做的一个直播推流工具,就是三层并行全用上了:
| 层次 | 实现方式 | 线程数 | 效果 |
|---|---|---|---|
| 任务级 | 音频采集、视频采集、编码、推流各一个线程 | 4 | 互不阻塞 |
| 流水线 | 采集→预处理→编码→推流,4级流水 | 4 | 延迟降低 30% |
| 帧级 | 编码时每帧分成 4 个切片并行编码 | 4 | 编码速度提升 2.8 倍 |
你想想看,如果只用单线程,一帧从采集到推流可能要 80 毫秒。用了这套组合拳,能压到 20 毫秒以内。这就是多线程的魅力。
关键提醒:并行度不是越高越好。我试过把帧级并行开到 8 个线程,结果因为内存带宽瓶颈,反而比 4 线程慢。建议用 perf 工具先跑一下,看看瓶颈在 CPU 还是内存,再决定线程数。
避坑总结
最后,把我这些年踩过的坑列一下,你遇到了能少走弯路:
- 锁竞争:能用无锁队列就别用互斥锁。CAS 操作比 pthread_mutex_lock 快一个数量级。
- 缓存伪共享:多个线程操作相邻内存地址时,会导致 CPU 缓存频繁失效。用 __attribute__((aligned(64))) 让每个线程的数据独占一个缓存行。
- 线程创建开销:不要每帧都创建销毁线程。用线程池,提前创建好,任务来了直接派发。
- 优先级反转:低优先级线程持有锁,高优先级线程等锁。用优先级继承协议可以解决。
嗯,多线程音视频处理这块,说白了就是「分而治之」四个字。把大任务拆小,让每个核心都有活干,同时小心别让锁和缓存拖了后腿。做到这些,你的播放器、编解码器就能跑得又快又稳。
公众号:蓝海资料掘金营,微信deep3321