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

decoder_decode_frame 只做一件事:把「发一个包、收若干帧」这个循环包起来。 它的返回值里有三个特殊值(0、EAGAIN、EOF), 分不清这三个,就会把「正常」当成「出错」。

📝 本节和前面几节的关系

09.03 讲完了「包怎么进来」,这一节讲「包怎么变成帧」。 第七章 07.03.03 已经用过一次这套 API, 这里是把它放进「常驻线程」的语境里再看一遍——区别在于:那里是一次性脚本,这里是永不退出的循环。 所以这一节的重点是「循环里的状态管理」,不是 API 本身。

一、decoder_decode_frame:一层薄包装

它的骨架短到可以默写:

for (;;) {
    ret = avcodec_receive_frame(d->avctx, frame);
    if (ret == 0) return 1;              /* 收到一帧 */
    if (ret == AVERROR(EAGAIN)) {        /* 要更多包 */
        ret = avcodec_send_packet(d->avctx, pkt);
        if (ret == AVERROR(EAGAIN)) continue;
        if (ret < 0) return -1;
    } else if (ret == AVERROR_EOF) return 0;
    else return -1;
}

为什么必须先 receive 再 send:因为解码器可能还攒着帧, 你要是不先收,直接送新包,会被判「缓冲区满」。 这个顺序反了不会崩,只会效率变差、甚至报错。

「先收后送」这条规矩,跟「先冲空包」是同一类经验: 带缓冲的接口,都得先看看里面还有没有东西。

二、三个返回值:0、EAGAIN、EOF

这三个值各有各的含义,混了就出怪事:

返回值 含义 调用者该做什么
1(收到帧) 正常 拿去用
0(EOF) 解码器彻底结束 退出循环
EAGAIN 暂时没帧 去喂下一个包,不是错误

第三行是本节的要害:EAGAIN 被当成错误处理,是最常见的一类写法错误。 它的意思是「现在没有,你继续喂」。 日志里天天刷 EAGAIN 的项目,八成就是这里判断写反了。

顺带说清一个容易混的点:EAGAIN 和 EOF 的区别在「还会不会有」。 EAGAIN 是「现在没有、等会儿有」,EOF 是「以后也不会有了」。 一个是「等一下」,一个是「结束」,处理方式完全不同。

三、一包零帧、一包多帧:为什么必须用 while

这是本课第四次讲这件事,因为在循环里比在脚本里更容易写错:

情况 什么时候出现 写错的后果
一包零帧 B 帧要等参考帧 把这一包丢了,画面会缺
一包一帧 最常见 —
一包多帧 小分辨率、高压缩 只取第一帧,漏掉其余的

「一包多帧」在真实片子里不罕见:分辨率小、码率低的时候,一个包能装好几帧。 所以「一个包对应一帧」这种假设,在小片子上就会露馅。

正确的写法只有一种:用 while 收,收到 EAGAIN 为止。 这也是 decoder_decode_frame 内部那个 for(;;) 的意义——它把那层循环包住了,让你在外面看起来是「一包换一帧」。

四、帧队列满了怎么办

解码线程把帧放进帧队列,也可能遇到「满了」,而它的处理跟包队列不一样:

| 队列 | 满了怎么做 | 为什么 | |---|---| | 包队列 | 阻塞等 | 包不能丢,丢了就缺帧 | | 帧队列 | 阻塞等 | 同上,但主线程会来取 |

两处都是阻塞,但帧队列的消费者是主线程,而主线程同时还在收事件, 所以它一定会来取——除非用户暂停了。

暂停的时候会发生什么: 主线程不再取帧,帧队列很快满了,解码线程阻塞,包队列跟着满,读线程也阻塞。 整条链一路往回堵,最后停在网络上。 这就是「暂停」天然带来的效果——不需要额外写「暂停时别读」的逻辑。

这个「堵回去」的特性很好用:暂停久了,网络那边也停了,不会白耗流量。 但它也带来一个副作用:恢复播放时,队列里攒着的都是「旧数据」,延迟会一下子暴露出来。 所以真正的播放器在恢复时会清一下队列。

五、多线程解码:thread_count 不是越大越好

-threads 这个参数最后落到 codec_context.thread_count,它的取值有三种含义:

值 含义
0 自动,按 CPU 核数决定
1 关掉多线程,只用一条
N 指定 N 条

Demo 在同一个文件上试了 1、2、4、0,结果是「不单调」: 两线程反而比一线程慢,四线程只快一点点。

这不是测错了,是样本太短:45 帧、一秒半的片子,线程启动成本还没摊开就结束了。 用它来判断「多线程有没有用」,会得出错误结论。

真正的并行度受两件事限制:一是「帧级并行」要靠参考帧不依赖(B 帧就得等), 二是「片级并行」要靠编码器真的切了片。 第七章 07.02.03 里那个「一帧被切成三片」的现象,就是片级并行的现场。

六、收尾:不冲空包就少几帧

这件事本课已经讲过三次,但解码这一处最容易漏,因为它在循环里:

while (1) {
    ret = decoder_decode_frame(d, frame, &pkt);
    if (ret <= 0) break;
    frame_queue_push(&is->pictq, frame);
}
/* ret == 0 是 EOF,说明解码器已经吐干净了 */

注意这里的 ret <= 0:0 表示「解码器结束」,这时候才真正退出循环。 如果写成 ret < 0,就会一直转下去;写成 ret != 1 就一跳出,会丢掉正在收的帧。

「什么时候算结束」这件事,在流式处理里永远是个细节:文件有长度,流没有。 所以 EOF 必须由解码器自己报,不能由「读完了」来推断。

七、动手 Demo:解码器开几条线程,到底差多少

-threads 是个看起来无害、其实很容易调错的参数。Demo 在同一批包上试了四种取值。 重点不是看谁最快,而是看「为什么快不了」——以及那个必须一致的数字:帧数。

for tc in (1, 2, 4, 0):
    cc = av.CodecContext.create(name, "r")
    cc.extradata = stream.codec_context.extradata
    cc.thread_count = tc     # 0 = 自动
    cc.open()
    n = sum(len(cc.decode(pk)) for pk in pkts) + len(cc.decode(None))

实跑输出:

--------------------------------------------------------------------
解码器开几条线程,到底差多少
--------------------------------------------------------------------
**ffplay 里有个参数叫 `-threads`**,**它最后落到 `codec_context.thread_count` 上。**
**这个数不是「开越大越好」,用真数据量一遍就知道。**

  thread_count     解 45 包耗时     每帧           帧数
  ---------------------------------------------------------
  1                6.88 毫秒        0.153 毫秒     45
  2                9.16 毫秒        0.203 毫秒     45
  4                7.61 毫秒        0.169 毫秒     45
  0(自动)        3.44 毫秒        0.076 毫秒     45

**看两件事**:**第一,线程数跟耗时不是单调关系**;
**第二,帧数必须一样**——**不一样就说明你在丢帧,那这个对比就作废了。**

**这次两线程反而比一线程慢,四线程和自动只快了一点点**。
**这不是测错了,是片子太短**:**45 帧、每秒 30 帧,一共一秒半**,
**开线程的启动成本还没摊开就结束了。**

**为什么 H.264 的并行度有限**:
**它的多线程主要靠「帧级并行」(同时解多帧)和「片级并行」(一帧切几片)**,
**而帧级并行受参考帧依赖限制**——**B 帧要等后面的参考帧,只能排队。**
**第七章 07.02.03 里那个「一帧被切成三片」的现象,就是片级并行的现场。**

**所以 `-threads` 的正确用法是「不设」**:**设 0 让 FFmpeg 自己按 CPU 核数决定**,
**只有在「诊断」或者「限制 CPU 占用」的时候才手动指定。**
**书上的示例常写死一个数,那是为了结果可复现,不是为了性能。**

先看最后一列:四行的帧数必须一样。 不一样就说明有丢帧,那这个对比就作废了。

再看耗时:两线程比一线程慢,四线程只快一点点。 这个结果不是 bug,原因是「样本太短」——45 帧、一秒半,线程启动成本还没摊开就结束了。

所以「多线程解码能快多少」这个问题,不能拿小片子去问。 要量就得用「至少几十秒、1080p、高码率」的样本,否则你量到的是启动成本,不是并行收益。

第三个观察在「每帧」那一列:它们之间的差异小到可以当噪声看。 这也说明一件事:小分辨率下,解码根本不是瓶颈——真正贵的是格式转换(09.05 会量)。

结论落到工程上就一句:thread_count 不要手写,用 0。 手写只有两种场合:要复现实验结果,或者要限制 CPU 占用。

30 秒实验

  1. 把样本换成 300 帧,再看四种取值的差异。
  2. 算一下:45 帧、每秒 30 帧,片子多长?这个长度够摊开线程启动成本吗?
  3. 判断一下:EAGAIN 和 EOF 有什么区别,各自该怎么处理?

常见误区

  1. 以为 EAGAIN 是错误。 它是「暂时没帧,继续喂包」,当成错误处理会误报一堆日志。
  2. 以为一个包只出一帧。 小分辨率下能出一包多帧,必须用 while 收。
  3. 以为 thread_count 越大越快。 片子短时线程启动成本占主导,实测两线程反而更慢。
  4. 以为读完文件解码器就没东西了。 它内部还攒着帧,要靠 EOF 或冲空包才吐出来。
自测三问(点开对答案) 1. `decoder_decode_frame` 为什么先 `receive` 再 `send`? —— 解码器内部可能还攒着帧,不先收就送新包会被判缓冲区满,效率和正确性都会受影响。 2. `EAGAIN` 和 `EOF` 的区别是什么? —— `EAGAIN` 是「现在没有、等会儿有」,要继续喂包;`EOF` 是「以后也不会有」,该退出循环。 3. 为什么小片子上多线程解码反而更慢? —— 因为线程启动和同步有固定成本,片子太短摊不开,成本和收益不成比例。

解出来的帧还不能直接上屏。下一节看 sws_scale——那个「顺带把缩放也做了」的转换器,以及它为什么常常比解码还贵。

相关笔记