第二十六章:多线程在音视频处理中的应用

音视频处理,说白了就是跟时间和数据量赛跑。一秒钟 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 帧线程才能启动。

知识体系总览

下面这张图把本章的核心逻辑串起来了。你看一眼,心里就有数了。

多线程音视频处理知识体系 任务级并行 音频解码线程 视频解码线程 渲染输出线程 各线程独立运行 通过队列交换数据 流水线模型 阶段1:读取数据包 阶段2:解码帧 阶段3:处理滤镜 阶段4:渲染显示 各阶段流水线接力 队列长度控制关键 帧级并行 按切片拆分帧 多线程同时解码 屏障同步 依赖H.264/H.265切片 注意参考帧依赖 三者可组合使用:任务级并行 + 流水线 + 帧级并行 = 最大化吞吐量

实际项目中的组合策略

说实话,实际项目里很少只用一种模型。我最近做的一个直播推流工具,就是三层并行全用上了:

层次 实现方式 线程数 效果
任务级 音频采集、视频采集、编码、推流各一个线程 4 互不阻塞
流水线 采集→预处理→编码→推流,4级流水 4 延迟降低 30%
帧级 编码时每帧分成 4 个切片并行编码 4 编码速度提升 2.8 倍

你想想看,如果只用单线程,一帧从采集到推流可能要 80 毫秒。用了这套组合拳,能压到 20 毫秒以内。这就是多线程的魅力。

关键提醒:并行度不是越高越好。我试过把帧级并行开到 8 个线程,结果因为内存带宽瓶颈,反而比 4 线程慢。建议用 perf 工具先跑一下,看看瓶颈在 CPU 还是内存,再决定线程数。

避坑总结

最后,把我这些年踩过的坑列一下,你遇到了能少走弯路:

  • 锁竞争:能用无锁队列就别用互斥锁。CAS 操作比 pthread_mutex_lock 快一个数量级。
  • 缓存伪共享:多个线程操作相邻内存地址时,会导致 CPU 缓存频繁失效。用 __attribute__((aligned(64))) 让每个线程的数据独占一个缓存行。
  • 线程创建开销:不要每帧都创建销毁线程。用线程池,提前创建好,任务来了直接派发。
  • 优先级反转:低优先级线程持有锁,高优先级线程等锁。用优先级继承协议可以解决。

嗯,多线程音视频处理这块,说白了就是「分而治之」四个字。把大任务拆小,让每个核心都有活干,同时小心别让锁和缓存拖了后腿。做到这些,你的播放器、编解码器就能跑得又快又稳。


公众号:蓝海资料掘金营,微信deep3321