播放器的码流到帧链路

从零做一个全平台播放器·约 2798 字

框架和数据源都定下来,接着要定死的是数据在播放器里怎么走:io → 包队列 → 解码 → 帧队列 → 渲染。这条线看着是直的,跑起来两头都被拽着。链上有三个干活的角色,每一段都在干三件事:把上一条队列里的东西变成下一级、让两条队列的深度跟着变、以及出问题的时候把队列推成另一种样子。这篇就按这三段走一遍——IO、解码、渲染——最后落到一个问题上:帧队列空了,播放器凭什么判断是「真的没数据」还是「解码跟不上」。

一条链上的两级队列:包与帧

从包队列到帧队列:读线程解出包进包队列,解码线程解出帧进帧队列,渲染按节拍取帧;下面三格是两条队列的深度各自照着什么量,包括那个把压力一级级往回顶的方向

这条链是三个角色接起来的:读线程把地址变成包,解码线程把包变成帧,渲染线程把帧变成画面和声音。两级之间各排一条队列——包队列在解码之前,帧队列在解码之后。速度和速度的差就被这两条队列隔开了:快的先把活干完、堆在队列里,慢的按自己的节奏取。

队列都挂在轨上:包队列每条轨一条,帧队列也是,视频音频各管各的。这一点决定了后面所有事——压力按轨分开算,不在这条链上摊。

两条队列的深度各照着下游的短板量。包队列留得深,是给音视频不齐步留余地:起播时两条轨的数据不会齐步到,时间戳能错开一截,队列浅了就会为了等另一边而空转。视频那条帧队列反过来留得浅——硬解出来的帧背靠 GPU 资源,排太长,轮到渲染时资源可能已经被回收,画面就花了。音频那条留得宽,声音这条线最怕断。

队列自己的规矩只有一条:写满之后的入队不返回、也不丢数据,而是在条件变量上一直等到有空位。唯一能让它提前出来的是队列被关掉——关闭时置一个关闭位、唤醒所有等着的人;不设这一下,线程退出时就有人永远堵在写入里等空位。压力于是一级一级往回传:下游慢下来,它上一级的队列先满,那一级的生产者被堵住,再往上一级……一直传到读线程。整条链不用谁去通知谁,堵住本身就是信号。反方向是欠:上游供不上,下面几级依次空过去,渲染取不到帧。

链摆好了,下面三段从头走一遍。每一段都按同一个样子讲:它做什么、动的是哪条队列、出问题的时候两条队列会变成什么样。

IO 这一段:把地址变成包

IO 这一段的四种样子:正常时包队列有货、写满就堵在入队;读得慢时包队列常年空着;读不回来时不再产包、包队列一格格放空;读到头了不再补包,四条队列见底进完成态。帧队列那一列,后三种都是空的

读线程只做一件事:调底层那个读包的接口,把地址变成包,推进包队列。它不知道下游在干什么,也不问——放不进去就在入队上等。

等这件事的代价,取决于它什么时候被顶住。本地文件没有代价:读得飞快,循环末尾那次让步只是让出时间片,并没有睡,所以包队列很快就满、读线程随即堵在入队上,占的 CPU 还高。网络源不一样:队列满了、读线程不再调读包接口之后,TCP 的接收窗口会被没读走的数据填满,发送方被压住;再停下去,对端或者中间的设备有可能把这个不说话的连接判死——TCP 自己不会做这件事,得靠应用层的写超时或者中间设备的空闲超时。读超时救不了这个场面,它保护的是「正在读却读不回来」的调用,而堵在入队上的线程根本没在读,超时无从触发。这一层没有兜底:读线程一直堵在入队上,连着服务器的 socket 线程就可能被关掉。

两条腿连堵的地方都不同。ffmpeg 这条腿有自己的读线程,堵的是它;zlmediakit 那条腿不另起线程,包是在它的 socket 线程上回调进来的,队列满了堵的就是那个 socket 线程。

IO 出错的时候,事情反而简单。读循环里看着分支不少,归纳起来只有三档:接着读、读到头了、读不回来。读调用返回「再试一次」或者被 seek 打断,都算第一档,继续下一轮,队列这边什么也不发生;真正要收尾的是后两档。

读完是正常的:尾包可能还有几十秒没放完,所以源不退出读线程,只把「到尾了」记下来、报一次结束,然后继续停在那儿——这样后面要 seek,还有人来读。它在队列上的样子是:不再有新包补进来,已经在里面的包还能接着解、接着放,包队列和帧队列一格格放空,等四条队列都见底,播放器才进完成态。

出错是另一回事:读循环直接退出,不再产包,而播放器一个状态都不改——错误只是被记下来、再回调给上层,由上层决定怎么处置。真正的后果是间接来的,而且全落在两级队列上:不再产包,包队列一格格放空,解码跟着没得可解,帧队列也空;渲染取不到帧,而这时候两条包队列都是空的,于是按渲染那一段的判据进 buffering,再看门狗超时,最后关掉播放。所以一次网络断开,用户看到的不是「立刻报错」,而是「卡住不动了,过一会儿播放停掉」。

还有一档在更早的地方:源压根没打开成。这一段在读循环之前,走的是同一路错误报上去;只是队列从来没被灌过,没有放空这个过程。结束和错误分开报是故意的:错误该由上层拿主意,播放器不替上层决定。

解码这一段:把包变成帧

解码这一段的三种样子:正常时取走一包少一包、解出一帧多一帧;换道重试时包队列照旧供着、帧队列空掉,但播放器不报缓冲;判死退出时包队列关掉并清空,两条都空了才进缓冲

解码线程从包队列取包、解出帧、推进帧队列。帧队列满了它就堵在放帧那一步,取包的速度跟着降——背压在这一段的形态就是「不取了」。

解码出问题的时候,它先在自己这一级找补,不急着惊动上层。一帧都还没解出来的时候,连着几次失败就判这条解码路走不通——硬解建不出上下文、驱动迟迟不产帧都算在内。这时轨内换下一个解码器再试,把缓存的配置帧重放一遍,从头再解。换道不动包队列:包照旧供着,不关也不清,所以这一段里帧队列会空、渲染空转,但播放器不会报缓冲——包还堆在那儿,只是解码跟不上。用户最多看到画面重新接上。

一个个解码器都换遍了还是不行,才认输:关掉这条轨的包队列、排空里面的残包,把错误报给播放器,解码线程退出。这一步的顺序和队列直接相关。走到这里时包队列是满的——读线程一直在往里塞,只等解码来取;不先关掉清空,读线程就会永久堵在入队上,另一条轨的包也跟着断供,整条管线冻死。视频这条线死掉的时候音频还能接着走,靠的就是这一步先松开,所以必须先封队列,再报错退出,不能反过来。

报给播放器之后,处理和 IO 出错一样:播放器自己不改状态,把错误转给上层。例外只有一处,视频轨的解码超时会让它直接关掉重开。其余的解码失败,收尾还是绕回缓冲那条路:不再出帧、帧队列放空,包队列已经被清空了,两条都空,于是进 buffering,看门狗超时,关掉播放。

失败的判定也分得细:包本身就坏、解不出帧的不算失败,跳过这个包接着解;出过帧之后的偶发收帧失败也只记一条日志,不丢包、也不退出。只有一帧都没出来时的连续失败,才当这条道死了。

这条路的看门狗也只在第一帧出来之前有效:一直有包喂进来却迟迟不出帧,才判它死。出过帧之后看门狗就撤了,此后解码器要是安静地不再产帧、又不报错,就没谁专门盯着它——它剩下的指望是渲染那一段的判据,而那套判据正好看不到这一种。

渲染这一段:把帧变成画面和声音

渲染这一段:正常时帧队列有几帧存着;渲染慢了帧队列顶满、包队列跟着满,压力从这里起步;渲染比出帧快时帧队列空,这时分清是哪一种,靠的是包队列那一列

渲染线程按帧率节拍跑:到点取一帧,取不到就空转——画面上什么都没有,它也不会停下来等,下一拍照旧来取一次。它是这条链的最后一环,也是唯一一个会回头看队列的角色。

它比出帧慢的时候,压力从这里起步:帧队列先顶满,解码写完帧这一步被堵住、取包的速度跟着降,包队列再满,读线程就堵在入队那一步。两条轨顶住的速度不一样:视频那条帧队列浅,稍微一慢就顶住;音频那条深得多,要慢上一阵子才见满。

它比出帧快的时候,帧队列空下来,它空转。单看「帧队列空」下不了结论:前面两段里它已经出现过好几次——IO 读得慢会这样,IO 出错会这样,解码换道也会这样,空转只是几种不同的来路在渲染这一侧留下的同一个影子。要下结论,得回头看两条包队列:音频和视频的包队列都空,才算「后面确实没数据了」,进缓冲;只要包队列里还有存货,就只是解码跟不上,什么都不做。判据取的是包队列而不是帧队列,原因也在这里——帧队列空这件事,分辨不出上面是哪一种。

于是同样一眼看过去,结果差得很远。IO 读得慢和 IO 出错都会进缓冲,用户看到的是同一件事——画面停住不动,过一会儿播放没了;读完那一路在队列上看不出区别,靠队列外面的「IO 已结束」把它摘出来,直接进完成态。解码这一级更麻烦:停摆了包队列反而满着,这一眼看不见它。

音频那一路不按帧看:节拍归设备,能问到的只有设备还积着多少没放完;积压折算出来的时长长期和 PTS 对不上,调的是送出去的数据量,不动帧队列。按节拍对齐、落后了丢帧这一整套,都在音画同步那一篇,这里只交代它落在队列上的样子。

进了 buffering 之后做哪几件事、攒够多少缓存才恢复、看门狗超时怎么处置,命令队列那一篇讲过一遍,这里不重复。要补的是判据取的那个方向量出来的一件事:它管的是「生产停了」,不管「生产还在、消费停了」——解码停摆时包队列满着,在判据里看着像正常播放,而解码自己的看门狗出了首帧就撤了。这一档目前没有专门的兜底。

引擎 avox 在 GitHub 开源:github.com/xxxzhou/avox,这条链、两级队列和这套收尾路径都在里面。

音视频开发 播放器 C++ 多线程 架构设计