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

read_thread 的主循环只有三步:读一个包、判断类型、塞进对应的队列。 但「塞」这个动作在队列满时会阻塞——这就是 ffplay 全部的流控机制。

📝 本节和前面几节的关系

09.02 讲了容器,这一节讲「谁往里放」。 read_thread 是整份源码里最像「主循环」的地方,也是最能体现「阻塞式设计」的地方。 理解它之后,再回头看 09.01 那张架构图,就能把每个箭头都对上具体代码。

一、从 main 到 stream_open:六步

main 做六件事,然后就把控制权交出去了:

步骤 做什么 出错会怎样
1 解析参数 parse_options 打印用法退出
2 初始化 SDL 与网络 SDL_Init、avformat_network_init 报错退出
3 建 SDL 窗口 (-nodisp 时跳过) 报错退出
4 stream_open 建队列、起读线程 返回失败
5 进 event_loop 主线程开始处理事件 —
6 退出时清理 关线程、关文件 —

注意第四步和第五步的分工:stream_open 起完线程就返回, 主线程立刻进 event_loop,不等任何数据。 所以「打开文件」和「开始播放」在同一步完成,主线程从头到尾没有被数据阻塞过。

这个设计值得抄:初始化只做「把机器装好」,不碰「让机器跑起来」。 很多自己写的播放器在 open 里同步解码第一帧,结果界面卡几百毫秒。

二、stream_open:起线程之前先建队列

顺序是「先建队列、再起线程」,反了就出事:因为线程一起来就会往里塞,队列还没建就崩了。

| 顺序 | 做 | 为什么不能反 | |---|---| | 1 | packet_queue_init | 线程一起来就要用 | | 2 | frame_queue_init | 同上 | | 3 | SDL_CreateThread(read_thread) | 最后才起 |

frame_queue_init 还要传「视频帧的大小」,为什么帧队列要预先知道尺寸: 因为它是定长数组,一次分配好,之后不再分配——这是为了避免「在播放过程中 malloc」这种不可控的抖动。

「播放过程中不分配内存」是一条很硬的工程规矩,它出现在很多实时系统里: 分配可能失败、可能触发锁、可能让耗时不可预测。 所以宁可一开始多占一点,也不要在跑的时候要。

三、read_thread 主循环:三步加两个检查

循环体本身的骨架非常短:

for (;;) {
    if (is->abort_request) break;
    ret = av_read_frame(is->ic, pkt);
    if (ret < 0) { /* EOF 或出错,见后 */ }
    if (pkt->stream_index == is->video_stream)
        packet_queue_put(&is->videoq, pkt);
    else if (pkt->stream_index == is->audio_stream)
        packet_queue_put(&is->audioq, pkt);
    else
        av_packet_unref(pkt);   /* 用不上的流,直接丢 */
}

看着像「死读」,其实有三处刹车:第一处是 abort_request(退出); 第二处是 av_read_frame(网络慢就卡在这儿); 第三处是 packet_queue_put(队列满就卡在这儿)。

第三处是最容易被忽略的,因为它藏在函数调用里: put 在队列满时会 wait 在一个条件变量上,直到消费者腾出位置。 所以「读线程不会读爆内存」不是因为它自觉,是因为它走不动。

最后那个 else 分支也有讲究:用不上的流(比如字幕、数据流)必须 unref, 否则每个包都漏一点内存。 这种「不用的也要还」的写法,在 C 里是硬要求。

四、EOF 之后不是结束,是「等」

av_read_frame 返回负数时,ffplay 不立刻退出,它分三种情况:

返回值 含义 怎么做
AVERROR_EOF 文件读完了 塞一个空包,表示流结束
EAGAIN 暂时没数据 睡一下再试
其他负数 真出错 记下错误、跳出

第一行是关键:「塞空包」这个动作,就是本课反复讲的「冲空包」在架构层的体现。 读线程已经没数据了,但解码器里还攒着几帧,得让它把这些吐出来。 所以它往队列里放一个「空包」当信号。

第二行在直播里尤其常见:网络卡一下就是 EAGAIN, 这时候既不能当出错,也不能死循环空转,只能睡一小下再试。 这个「睡一小下」的时长很讲究:睡太短白烧 CPU,睡太长增加延迟。

五、队列满就是刹车:流控的实质

Demo 把这个机制演了一遍:让消费端固定每包睡 2 毫秒,再换三种队列深度。

结果是「总耗时几乎一样,队列最深那一列不同」:深度小的顶在 2 和 8,深度大的涨到几十。 这说明读取线程确实被 put 卡住了,它没有「超前去读」。

这种流控叫「反压」(back pressure),它的好处是自适应的: 解码快,队列就浅;解码慢,队列自然积压到上限。 你不需要写任何「限速」逻辑。

代价在直播里会显现:队列满时读线程阻塞,数据就堆在网络上,延迟越积越大。 所以直播必须额外做「丢包追上」或者「跳帧追上」,这就是 09.07 播放控制要处理的事。

六、退出:为什么要一个 abort 标志

退出这件事在阻塞式设计里很难:读线程可能正卡在网络读上,解码线程可能正卡在队列空上, 你去 join 它,它永远不返回。

ffplay 的解法是两层:

层 做什么
标志位 abort_request = 1,让循环自己检查
叫醒 packet_queue_abort 广播条件变量

两层缺一不可:只设标志,卡在 wait 里的线程看不见;只广播,没设标志的循环会继续跑。

这个模式叫「标志 + 唤醒」,是写多线程程序的基本功: 凡是「可能无限等」的地方,都要提供一个「被叫醒」的路径。

顺带说一个常见的错误写法:用 SDL_KillThread 之类的强制手段。 强杀会让线程在任意一行停下,锁没释放、内存没还,进程就处于不确定状态。 所以正路永远是「标志 + 唤醒 + join」。

七、动手 Demo:读线程跑太快会怎样——队列满了就是刹车

「读得比解得快」是所有播放器都会遇到的情况,区别只在于是「读爆内存」还是「自然停住」。 Demo 用一个很小的队列把这件事演出来:消费端固定慢,看队列最深能到哪儿。

def producer():
    for pk in demux():
        q.put(pk)        # 满了就阻塞,这就是刹车
    q.put(None)

while True:
    pk = q.get()
    if pk is None: break
    time.sleep(0.002)    # 假装解码慢慢来

实跑输出:

--------------------------------------------------------------------
读线程跑太快会怎样——队列满了就是刹车
--------------------------------------------------------------------
**`read_thread` 的主循环看着像个「死读」的循环**:**一直 `av_read_frame`,读到就塞队列。**
**但它不会把内存读爆,秘密就在「塞队列」这一步。**

  队列上限     总耗时       读出包数     队列最深
  ---------------------------------------------------
  2            184 毫秒     60           2
  8            182 毫秒     60           8
  64           182 毫秒     60           58

**三行的耗时几乎一样,因为消费速度(每包 2 毫秒)是固定的**。
**不同的是「队列最深」那一列**:**它顶到了上限就再也不涨,说明读取线程被 `put` 卡住了。**

**这就是 ffplay 的流控:不是靠「限速读」,而是靠「队列满了自然停」。**
**好处是自适应的**——**解码快,队列就浅;解码慢,队列自然积压到上限。**

**坏处也要说清**:**队列满的时候,`read_thread` 是「阻塞」而不是「丢包」**,
**所以网络那边会反压回服务器(TCP 窗口变小)。**
**这在点播里是好事,在直播里就意味着「延迟越积越大」**。
**直播必须靠丢包或者跳帧来「追上来」**,**这就是 09.07 播放控制要解决的事。**

三行的耗时几乎一样,这是本节最重要的观察:因为消费速度是固定的,整条链的节拍就由消费决定。 读取再快也没用,它会被队列拖住。

「队列最深」那一列才是证据:深度设成 2,最深就是 2;设成 8,最深就是 8。 它顶到上限就不动了,说明生产者在 put 上等着。

「顶到上限就不动」这件事,用一句话概括就是:队列不是「缓冲区」,是「流量计」。 它的深度直接反映「生产比消费快多少」。 所以调试播放器卡顿时,先看队列深度,比看 CPU 占用有用。

但要注意这里有个前提:阻塞式队列才有这个效果。 如果队列是「满了就丢」的(无界队列或者丢弃策略), 那就变成了「丢包」,是另一种设计——直播里常见的「低延迟模式」就是把它改成丢。

最后回到 ffplay:它的包队列是阻塞式的,所以点播体验平滑、延迟可控; 而直播场景要的恰好相反,所以真正的直播播放器会在这一层加「丢旧包」的逻辑。 同一个队列,两种策略,选哪个取决于你要「平滑」还是「实时」。

30 秒实验

  1. 把消费者的 sleep 从 2 毫秒改成 0,看队列最深变成多少。
  2. 算一下:队列上限 15 包、每包平均 2 毫秒解完,最多能攒多少毫秒的延迟?
  3. 判断一下:为什么读线程遇到 EAGAIN 不能直接当作出错退出?

常见误区

  1. 以为读线程会「自觉限速」。 它不会,是队列满了让 put 阻塞。
  2. 以为读完文件就该退出。 要先塞空包让解码器冲出残留帧,否则最后几帧永远不显示。
  3. 以为退出可以直接强杀线程。 强杀会让锁和内存处于不确定状态,正路是「标志 + 唤醒 + join」。
  4. 以为队列越深越安全。 深度直接换算成延迟,直播里这就是「越看越慢」。
自测三问(点开对答案) 1. ffplay 的流控靠什么实现? —— 靠阻塞式队列的 `put`:队列满时读线程自动停在那里,不需要任何限速代码。 2. 读到 EOF 之后 ffplay 做什么? —— 往队列里塞一个空包,告诉解码器「没有更多数据了」,让它把攒着的帧吐出来。 3. 退出为什么要「标志 + 唤醒」两层? —— 只设标志,卡在条件变量上的线程看不见;只广播,没检查标志的循环会继续跑。

读进来之后就该解了。下一节看 decoder_decode_frame——它是 avcodec_send_packet 和 avcodec_receive_frame 的一层包装,包装的正是那个「一包可能解出零帧」的麻烦事。

相关笔记