🎬 第九章 FFplay源码剖析及音视频同步
📄 一句话概括

ffplay 的架构可以用一句话说完:三条线程、两级队列、一个时钟。 读线程只负责解封装,解码线程只管解,主线程只管画——三者之间靠队列解耦,靠时钟对齐。

📝 本节和前面几节的关系

第八章是「用它」,第九章是「拆它」。 前八章讲过的每一个 API,到这里都会被放进同一条流水线里。 所以这一章读起来会觉得「都是认识的零件,但装法没见过」——这正是读源码的价值所在。 08.01 已经讲过它的三条线程,这一节要把「为什么是三条而不是两条或四条」讲透。

一、一句话架构:三线程、两队列、一时钟

先把整体钉死,后面所有细节都往这张图上挂:

角色 谁 输入 输出
生产者 read_thread 文件或网络 包队列
加工者 video_thread/audio_thread 包队列 帧队列
消费者 主线程 帧队列 屏幕与声卡

两级队列是「解耦点」:包队列把「读」和「解」分开,帧队列把「解」和「画」分开。 没有队列,三条线程就只能同步跑,等于一条。

一个时钟是「对齐点」:队列解决「快慢不均」,时钟解决「该在什么时候出现」。 这两件事完全不同,缺一个都不成播放器。

为什么音频和视频各有一条解码线程,而读取只有一条: 因为「读」是天然串行的——一个文件只有一个读指针,两个线程同时读只会互相打架。 而「解」是天然并行的——音频和视频是两套完全独立的编码,谁也不依赖谁。

二、为什么不是两条线程,也不是四条

这个问题值得认真想,因为它决定了你以后自己写播放器时的骨架。

划法 会怎样
一条线程 任何一处阻塞,全停
两条(读 + 播) 音频和视频抢一条线程,谁的包先到谁先解
三条(读 + 音 + 视) ffplay 的选择
四条(读 + 音 + 视 + 渲染) 渲染本来就要在主线程,多一条没意义

关键在第二行: 音频和视频如果共用一条解码线程,就会出现「音频卡住时视频也停」——而它们的编码复杂度差得很远。一个 1080p 的 H.264 视频帧可能要解十几毫秒,一帧 AAC 音频只要零点几毫秒。 混在一起,音频就得等视频。

第四行也值得说:渲染不能单独开线程, 因为窗口、纹理、事件这些东西都有「线程亲和性」——谁建的谁碰,别的线程碰就崩。 第八章 08.03 里 MFC 那条「窗口只能在创建它的线程里碰」,说的是同一件事。

三、主线程到底在忙什么

很多人以为主线程就是「画」,其实它做四件事,而且都很轻:

事 频率 贵不贵
收事件(SDL_PollEvent) 每轮一次 一微秒级
算这一帧该显示多久 每帧一次 纯算术
贴图 + 呈现 每帧一次 几百微秒
检查队列、必要时唤醒 每轮一次 可忽略

四条加起来通常不到一毫秒,所以主线程大部分时间是在「等」——等下一帧该显示的时刻到。

这个「等」就是同步逻辑的主战场:等多久、等不及了怎么办、等过头了怎么办, 09.08 整节都在讲它。

顺着这个思路,可以推出一条判断播放器好坏的标准:主线程占用越低越好。 如果它占满了,说明重活被塞进了不该塞的地方。

四、三个文件,三种职责

ffplay 虽然只有一个 ffplay.c,但它依赖的东西分三层:

层 是什么 你能改吗
ffplay.c 播放逻辑:线程、队列、同步 随便改
cmdutils.c 命令行解析、选项注册 改起来容易出错
libav* 解封装、解码、转换 别改,用就行

真正的「播放器」只有第一层。后两层是 FFmpeg 给的,你换不掉也不用换。

这个分层解释了一个现象:很多人想「做一个比 ffplay 更好的播放器」, 结果做出来的东西,第一层几乎一样,差别全在界面和功能上。 因为播放逻辑的最优解就这一种划法,没有第二条路。

五、读源码的正确顺序

三千行不能从头读,按「数据怎么流」读,比按「代码怎么排」读快十倍。

第一遍只看四个函数: main(参数与初始化)、stream_open(起线程)、read_thread(读)、video_thread(解)。 这四个串起来就是一条完整的数据通路。

第二遍再看三个「不好读但躲不掉」的地方:队列的加锁与唤醒、时钟的读写、同步的阈值判断。 这三处是全文件唯一有「技巧」的地方,其余都是直白的流程。

第三遍才看界面与交互:键盘处理、窗口大小变化、字幕。 这些是「功能」,不是「架构」。

顺序反过来会怎样: 从 main 开始逐行读,读到三千行的一半就放弃了——因为中间大段是选项注册和界面事件,跟播放没什么关系。

六、三条线程的收益,量一遍

架构讲完了,得有个数撑着。Demo 把同一批帧用两种方式跑了一遍:串着做,和三级流水。

结果里最值得看的是那张「每一级花了多久」的表:这一次最慢的不是解码,是「转 RGB」。

这个结果有三层意思:第一,流水线快不快由最慢那一级决定; 第二,最慢的是哪一级不是固定的,它取决于分辨率、编码参数、目标格式; 第三,所以「多开一条线程」这种优化,在没量之前不要做。

顺带说清一个常见的误解:分线程不是为了「让 CPU 用得更满」, 而是为了让「等待」重叠起来。 读的时候可以同时在解,解的时候可以同时在画——重叠的收益,取决于各级耗时是否接近。

七、动手 Demo:三线程流水线到底快多少,以及它快不过哪一级

把一条链拆成三级,让三级同时忙——这件事的能量化程度,比想象中高。 同一批帧,串着做一遍,再流水着做一遍,比较两个总时间。 然后看每一级各自占多少,就知道瓶颈在哪。

# 串行:读、解、转,一次做完一帧再做下一帧
for pk in demux():
    for fr in decode(pk):
        fr.to_ndarray(format="rgb24")

# 流水:三级各一条线程,靠两个队列连起来
t1 = Thread(target=read_into_q1)     # 读 -> 包队列
t2 = Thread(target=decode_q1_to_q2)  # 解 -> 帧队列
t3 = Thread(target=convert_q2)       # 转
t_pipe = wall_clock(t1, t2, t3)

实跑输出:

--------------------------------------------------------------------
三线程流水线到底快多少,以及它快不过哪一级
--------------------------------------------------------------------
**ffplay 的三条线程不是「为了并行而并行」**,**它是把一条链拆成三级,让三级同时忙。**
**这件事可以量化:同一批帧,串着做一遍,再流水着做一遍。**

  **串行**:一次读、解、转,60 帧用了 **123.4 毫秒**

  **流水线**:三级各跑一条线程,60 帧用了 **114.6 毫秒**
  加速比:**1.08 倍**

  级     墙钟跨度       占最慢一级
  ------------------------------------
  读     12.5 毫秒      11%
  解     61.2 毫秒      55%
  转     111.5 毫秒     100%

**加速比不是 3,这是本节最重要的一句话**:**流水线的速度由最慢的一级决定**,
**不是「三级加起来除以三」。**

**表里那一列是「墙钟跨度」,不是「纯计算时间」**——**它会包含等待上下游的时间。**
**所以最慢的那一级,就是整条流水线的节拍器。**

**这次最慢的是「转」这一级,不是「解」**。
**这个结果跟 D53 完全对得上**:**320×240 转成 `rgb24` 比解一帧还贵。**
**所以「解码是瓶颈」这句话不能当成常识**,**它取决于分辨率、编码参数和你要转成什么格式。**

**这也解释了 ffplay 为什么把「转换」放在渲染线程里、而不是解码线程里**:
**因为它跟解码是两种完全不同的开销,混在一起会让两个瓶颈互相拖。**

**还有一个容易忽略的成本**:**线程间传包要加锁、要拷贝引用计数。**
**级数越多,这笔开销越多。**
**所以三级的划分不是越细越好,是按「阻塞来源」划分最划算。**

加速比只有一倍出头,这是本节最该记住的数。 三段可以重叠,理论上最多三倍,实测拿不到,因为三段根本不均匀。

表里那一列是「墙钟跨度」,不是「纯计算时间」——它会包含等待上下游的时间。 所以最慢的那一级,就是整条流水线的节拍器,其他两级再快也没用。

这一次最慢的是「转」,不是很多人以为的「解」。 这个结果跟 09.05 的测量完全对得上:320×240 转成 rgb24,比解一帧还贵。 所以「解码是瓶颈」这句话不能当常识用。

这也解释了 ffplay 的一个设计选择:它把格式转换放在渲染线程(主线程)里,而不是解码线程里。 因为转换和解码是两种完全不同的开销,混在一条线程里,两个瓶颈会互相拖。

最后提醒一句:这里的线程间传包,用的是 Python 的队列,它比 C 里的加锁队列重得多。 所以这个加速比是「偏保守」的——C 版本里同样的划分能拿到更好的数。 但结论的方向不会变:瓶颈决定上限。

30 秒实验

  1. 把「转」这一级改成「什么都不做」,看加速比变成多少。
  2. 算一下:三级分别要 10、50、40 毫秒,流水线跑 100 帧最少要多久?
  3. 判断一下:如果把音频和视频塞进同一条解码线程,会出现什么现象?

常见误区

  1. 以为线程越多越快。 加速比由最慢一级决定,其他级再快也是空转。
  2. 以为瓶颈一定是解码。 这次实测最慢的是转 RGB;瓶颈随分辨率、编码参数、目标格式变。
  3. 以为渲染可以单独开线程。 窗口和纹理有线程亲和性,谁建的谁碰。
  4. 以为读也要开两条线程(音视频各一条)。 读是串行的,一个文件只有一个读指针。
自测三问(点开对答案) 1. 为什么音频和视频要分成两条解码线程? —— 因为它们编码复杂度差很远,共用一条时音频要等视频。而「读」是串行的,所以读取只有一条线程。 2. 队列和时钟各自解决什么问题? —— 队列解决「快慢不均」,让三条线程解耦;时钟解决「该在什么时候出现」,让音视频对齐。 3. 读 ffplay 源码应该按什么顺序? —— 按数据流读:main → stream_open → read_thread → video_thread,再看队列、时钟、同步三处技巧, 最后看界面。

架构有了,接下来看零件。ffplay 里所有的状态都挂在一个结构体上, 不先认识它,读任何函数都会看到一堆 is->xxx。下一节先把这四个结构体拆开。

相关笔记