所有控制动作走同一条队列
想动播放器状态的有四路:界面线程点暂停、拖进度条;读线程报网络断开;解码线程报解不动要降级;宿主查进度、设倍速,来自随便哪条调用线程。四路分在四条线程上,都能直接写的话,「当前状态」出错时就说不清是谁改的。
所以规矩是:任何人都不直接改,想做什么就打包成一条命令,压进一条队列。队列只有消费者——命令线程,一条线程逐条执行。open、close、seek、play、pause、设倍速、切音轨、选字幕,全部走这条路。命令队列挂在播放器对象里,有上限;播放器对象一创建,命令线程就跟着起了。
这一步还定下一条更深的纪律:轨道对象、渲染对象的创建和销毁,也只发生在命令线程里。理由不复杂——销毁一条视频轨,要停解码线程、清两条队列、放 GPU 资源,这些动作和别的命令并发就没法补;串行化换来的确定性,比并发省下的那点时间值钱。
队列给的是顺序与串行:先来的先执行,不会有两条命令同时在改状态。但它只保证顺序,不保证执行完——入队等到队列有空位就返回,不等命令被执行;想知道结果,靠订阅状态通知或直接读当前状态,整条链上唯一带回执的只有字幕的加载与卸载。还有一条:入队成功不等于会被执行,执行体开头都有一道条件判断,不合时宜的命令——比如还没打开就发 pause——到了命令线程手里会被直接丢掉。顺序由队列保证;合不合时宜,由状态机在执行时判。
队列上只有一样东西能插队:关播。close 入队前先把整条队列清空,再把自己放进去——排着的 seek、切轨全部作废,它不排在任何人后面。
九个状态与它们之间的迁移
播放状态一共九个:none、opening、ready、playing、pause、seek、buffering、stopped、completed。它们之间会走的迁移,一条条数出来只有这些:

none → opening → ready → playing:起播这条顺路——open 受理后 IO 打开、报出轨道信息进 ready,第一帧渲染出来、渲染线程入队一条 Play,进 playing。打不开就直接跳到 stopped,不停在中间等。playing ⇄ pause与playing ⇄ buffering:前者是用户按的;后者要渲染取不到帧、包队列也空了才进,缓存够了回 playing。playing / pause / buffering → seek → playing:seek 是瞬时态,置入与置回在同一条命令里,所以不管从哪个状态进来,出去都是 playing。ready → seek → playing:断点续播,还没起播就要跳;ready 是特批允许直接进 seek 的唯一非播放态。playing / pause / buffering → stopped(close、缓冲看门狗超时、打开失败),playing / buffering → completed(尾包放尽)。
三条:第一,playing、pause、buffering 这三态是一个并集,只有落在里面,pause、seek、buffering 这类命令才有资格被执行;第二,seek 是给外面看的瞬时态,pause 与 buffering 是并列的中间态,两个都回 playing;第三,状态里没有 error 这一档——错误不走状态,走回调。
opening 与 ready:能取到数据与能出画面
这两个状态分的是两件事:能取到数据,和能出画面。
opening 是命令线程受理了 open、IO 正在打开的那一段:连协议、读容器头、找轨道。这一段会失败——IO 打开返回失败就直接进 stopped。打开期间命令线程也不肯久等:它的主循环末尾本来有一次短暂休眠(用来压住空转的 CPU);只要队列里有命令、或者状态是 opening,这一轮就跳过休眠、只让出时间片,保证这一段里随时接得住下一条命令。
ready 的由头在 IO 侧的回调上。IO 把轨道列表和时长交出来,播放器做三件事:把轨道描述挂到轨对象上——音频只挂当前选中的那条,超出的轨直接丢掉;记下总时长;入队一条 Ready 命令。命令线程执行到它,才置 ready、通知外部,然后启动各轨——注意顺序,先起解码线程,等解码信息返回,再起渲染线程。
所以 ready 的含义很具体:轨数、格式、时长都能查了,解码链挂上了,但一帧画面都没有——一个「准备就绪,还没有内容」的窗口。它有两个用处:一是断点续播,seek 就是在 ready 那一瞬间发出去的,ready 因此是唯一允许直接进 seek 的非播放态;二是它不判 buffering,首帧一到就直接进 playing,否则刚 ready 就报缓冲、干等数据。
buffering 是渲染线程报上来的
进 buffering 的唯一入口在渲染侧:渲染线程去取帧,没取到,这时候才回头看一眼两条包队列——音频和视频的包队列都空,才算「后面真没数据了」。只要包队列里还有存货,那是解码跟不上,不是断流,不进 buffering。而且只有 playing 态才上报。
IO 已经把「结束」报上来的情况要分岔:两条队列全空就该收尾,入队的是 Complete;IO 结束了、尾包还在管线里放(音频尾包能拖好几秒),那既不是卡顿也不是结束,什么都不做。
进了 buffering,命令线程做三件事。停渲染,让队列里的数据别再被消费,IO 与解码器照跑,继续把数据存够。时钟先锚到当前显示值再钉住——不锚,进度条会在卡顿这一下往回跳;不钉,它又会在卡顿期间按墙钟往前爬。最后置 buffering,看门狗归零开始计时。
恢复在命令线程的节拍里做,条件有三层。要求上次的状态是 playing——否则从暂停进来的 buffering 会自己恢复播放。要求视频或音频的队列缓存时长够(代码里叫 delayMs)。超过四倍速时只解 I 帧,判据换成「视频帧队列里有帧」。另外 IO 已报结束就无条件恢复,把尾包放完;不然 seek 落到离片尾不足两秒的地方,这个门永远不满足,只能等看门狗超时。
一直恢复不了,看门狗超时就直接关播放器,走 stopped。这个时长对两类源要放宽:torrent 是渐进下载源,seek 落进未下载区域要等片落地,放得更宽;http 直连有服务端的惩罚窗,也要放宽一档。
pause 与 seek 的停法:时钟、IO、渲染
pause 命令动三处:停时钟、停 IO、停渲染。停 IO 是两层——先在协议层叫一声,把「服务端别发了」这件事交给支持的协议(不支持就当没这回事),再把自己的读线程暂停。
解码器不在这三样里。渲染一停,帧队列很快填满,解码线程就卡在「入队」这一步,包队列跟着填满,读线程再被包队列顶住——IO 那头除了一声显式叫停,队列深度本身也是一道刹车。下一篇讲码流到帧这一路时会看到,视频那条帧队列浅、音频那条深,浅的那条刹车来得快,正好配这里。
seek 麻烦得多,因为它得动数据。入队那一步先跟队尾合并:已经排着一条 seek,只改它的目标位置,命令线程只执行最后那一下。真正的动作在命令线程里,顺序是这样:先给 IO 一个预告打断读,再把包队列置成关闭、撤掉背压——这两步不能反,反了快源会先狂奔几包。然后三道闸一起打上:包队列关闭、渲染暂停、解码线程暂停。等一小会儿,让在途的数据落进队列,再清空两条队列——不清,跳完还会播一段旧位置的数据。顺手作废「IO 已结束」的结论,清掉字幕侧的待播事件。接着通知 IO 跳到目标位;开了精确 seek,再给每条视频轨武装「丢弃到目标位」,第一个不早于目标的帧才恢复入队。最后时钟定位到目标位,三道闸按相反顺序放开。
整场 seek 里,状态停留在 seek 的时间,就是这条命令自己执行的那一小段——置入和置回在同一个函数体内,中间不等任何外部事件,跨不出命令边界。但这个状态并不等于「正在跳」:真正长命的是另一个标志,它从调用 seek 那一刻就置位,一直挂到真实位置追平目标(或超时兜底)才清;它挂着的这段时间里,查位置拿到的是跳转目标而不是真实位置,界面因此不会先跳到目标、再闪回落点。另有一处例外要照顾:buffering 态也允许 seek,那条路不走节拍里的恢复分支,时钟的闩没人解,位置会永久冻在目标位,所以 seek 末尾统一解一次闩。
错误不占状态,走两条回调
出错这件事没有专门的一档状态,因为错误不是一种状态。错误先到播放器手里,它自己消化一遍:能就地处理的就地处理,顺手把状态更新到该去的那一档;处理完再分两路回调上层——IO 错误走 onIoError,带错误码和消息;解码错误走 onDecodeError,带轨类型和结果码。停不停、要不要重试,由上层定。
源侧的错误码是分档的:负值是中断性错误(网络超时、网络关闭、URL 不支持、设备掉了、解码车道死),正值可以继续(文件结束、源无效、数据无效)。文件结束不算错误,播放器把它转进「完成」那条路;其余原样回调出去,状态不动。
解码侧的结果码区分超时、不支持、找不到、打开失败、配置缺失、数据错。有一种播放器不往上交:视频解码超时——硬解卡死这一类,它自己关播放器,收敛到 stopped。
「消化」还有一层意思:能判死就不重复报。解码车道从没出过一帧、连续喂包都失败(没有软解构建时遇到 AV1 就是这样),源侧就判这条车道死了,停喂、释放解码器,按源错误上报一次,不是每包报一次。
状态机自己也会收口:打开失败进 stopped、缓冲看门狗超时自己 close。
引擎 avox 在 GitHub 开源:github.com/xxxzhou/avox,这条命令队列和这套状态机都在里面。
音视频开发 播放器 C++ 多线程 状态机