-
Notifications
You must be signed in to change notification settings - Fork 163
架构设计
通过前面的播放器实现原理的介绍,我们可以初步总结出,一个播放器的主要核心模块:
- demux - 从输入文件分离出 audio packet 和 video packet 等等
- audio decode - 解码 audio packet
- video decode - 解码 video packet
- render - 负责 audio 和 video 的渲染
- adev - 抽象的 audio 输出设备
- vdev - 抽象的 video 输出设备
一开始我就是这样设想的,最初的设计中,demux + audio decode + video decode 这三个功能,放入了 fanplayer.cpp 中实现,render 的功能由 ffrender.cpp 来实现,然后是 adev.cpp 和 vdev.cpp。
看起来这几大模块似乎就足以实现完整的播放器功能了,而且看起来似乎也不需要什么多线程。基于 KISS 原则,我是极力避免用多线程去解决问题的。所以最初的架构设计,基本是就是一个主线程:
player_thread() {
while (1) {
packet = demux();
if (packet_is_audio(packet)) {
decode_audio(buffer, packet);
render_audio(buffer);
}
if (packet_is_video(packet)) {
decode_video(buffer, packet);
render_video(buffer);
}
}
}
这样的设计,看起来还算行,至少可以把音视频解码和输出,可以说是最简单的播放器。那么缺陷在哪里?
- 很难保证音视频回放的连续性
- audio 和 video 同步问题
- 帧率稳定性和均匀性的问题
首先 demux 出来的 packet 可能就没法保证 audio 和 video 的同步,比如 demux 出的 packet 序列如下:
音频采样率 44100 立体声,视频帧率 30fps。音频 pts 的 timebase 为 1/44100,视频 pts 的 timebase 为 1 / 1000
(pts 为回放时间戳,timebase 可以理解为单位)
audio packet pts 0 -> audio: 0ms - 46.4ms(计算公式 1000 * 2048 / 44100 = 46.4)
audio packet pts 2048 -> audio: 46.4ms - 92.8ms
audio packet pts 4096 -> audio: 92.8ms - 139.3ms(至此音频已经播放 139.3ms,视频还没开始)
video packet pts 0 -> video: 0ms - 33.0ms
video packet pts 33 -> video: 33ms - 66.0ms
video packet pts 66 -> video: 66ms - 99.0ms
video packet pts 100 -> video: 100ms - 133.0ms
video packet pts 132 -> video: 133ms - 166.0ms
...
这样看来,demux 出来的 packet,很可能 audio 与 video 之间的 pts 差距就非常大。
demux 本质上是磁盘 IO 或者网络 IO,要么耗时要么会阻塞线程。解码是非常耗时的,而且时间是不固定的(视频解码关键帧时也许耗时更多,非关键帧相对耗时较少)。渲染也是一个耗时的操作,视频渲染需要做像素格式转换,需要往屏幕绘制,很耗时但时间相对固定;音频渲染操作,只需要把 buffer 送给音频设备,不耗时但是可能会导致线程阻塞(阻塞可以理解为广义上的耗时)...
因此我们看到的问题就是,demux、解码、渲染这些操作,都可能非常耗时。为什么耗时是问题呢?因为视频播放,对时间非常敏感,对实时性要求也非常高。我们想想,如果视频帧率是 30fps,那么每一帧的时间只有 33ms。也就是说在 33ms 内,我们必须完成一帧视频的 demux、decoding、rendering;或者说这个线程的 while 循环必须平均每 33ms 就执行一次。但是在这种单线程架构中,如果其中任意一个环节耗时超过了 33ms,线程就没法持续地满足视频回放的吞吐量,就是没法做到连续播放的,更不要提音视频同步了。
解决办法:对于耗时的操作,有必要建立队列进行缓冲,同时开专门的线程进行处理。这个原理说白了就是流水线原理。最初的设计,流水线上每个工序只有一个人处理,也没有缓冲(只有一个产品在线上跑),假如某个工序很耗时,那么这个工序就必然拖慢下游的时间,从而拖慢整个流水线的时间。在耗时长的工序上,增加更多的人,增加缓冲,就能提高流水线的效率。队列就是用于衔接不同工序的缓冲,线程就是工位上做事情的人。耗时的任务,cpu 就会让线程执行更多的时间,直到线程把自己的队列都填满了,就进入阻塞。
因此,要实现一个播放器,我们必须使用多线程,使用多线程的目的,就是为了保证视频播放的连续性、帧率稳定和音视频同步。
基于这样的原理,我们可以做出新的架构设计:
- fanplayer 负责 demux 和 decode
- render 负责音视频的渲染工作
- adev & vdev 是抽象的音视频设备
- 多线程的架构总共五个线程(demux,adecode,vdecode,arender,vrender)
- packet 队列用于 demux 和 decode
- audio buffer 队列用于 audio decode 和 audio render
- video buffer 队列用于 video decode 和 video render
总结下来,我们必须要有 5 个线程,分别用于 demux, audio decode, video decode, audio render, video render。具体实现过程中,render 的线程是 adev/vdev 的内部实现,跟平台相关。fanplayer 的 windows 版本实现中,vdev 内部是有线程的,而 adev 的实现没有用到线程,但这个线程实际上是运行在 windows 操作系统音频驱动的内部的。所以我们也可认为只有 3 个线程,demux、audio decode 和 video decode,而 adev 和 vdev 是带缓冲队列的渲染设备。
线程之间的数据传递方式为:
- 每个线程就是一个独立的工作单元
- 线程之间通过队列传递数据
- 队列的上下游都是工作线程
- 上游的工作线程是队列的生产者
- 下游的工作线程是队列的消费者
总结来说,就是一个源头、五个线程、三个队列、两个设备。这就是 fanplayer 的最终架构。
fanplayer wiki