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 秒实验
- 把消费者的
sleep从 2 毫秒改成 0,看队列最深变成多少。 - 算一下:队列上限 15 包、每包平均 2 毫秒解完,最多能攒多少毫秒的延迟?
- 判断一下:为什么读线程遇到
EAGAIN不能直接当作出错退出?
常见误区
- 以为读线程会「自觉限速」。 它不会,是队列满了让
put阻塞。 - 以为读完文件就该退出。 要先塞空包让解码器冲出残留帧,否则最后几帧永远不显示。
- 以为退出可以直接强杀线程。 强杀会让锁和内存处于不确定状态,正路是「标志 + 唤醒 + join」。
- 以为队列越深越安全。 深度直接换算成延迟,直播里这就是「越看越慢」。
自测三问(点开对答案)
1. ffplay 的流控靠什么实现? —— 靠阻塞式队列的 `put`:队列满时读线程自动停在那里,不需要任何限速代码。 2. 读到 EOF 之后 ffplay 做什么? —— 往队列里塞一个空包,告诉解码器「没有更多数据了」,让它把攒着的帧吐出来。 3. 退出为什么要「标志 + 唤醒」两层? —— 只设标志,卡在条件变量上的线程看不见;只广播,没检查标志的循环会继续跑。读进来之后就该解了。下一节看 decoder_decode_frame——它是 avcodec_send_packet 和 avcodec_receive_frame 的一层包装,包装的正是那个「一包可能解出零帧」的麻烦事。
- ☐ (本节完,下一节 09.04 FFplay的音视频解码)