问题引入
用 mpv video.mp4 播放本地文件,中途按 q。退出几乎在瞬间完成------用户看到的是"快",但真正值得琢磨的是背后的设计问题 :demux 线程可能正阻塞在 read() 或 recv() 上,播放器凭什么既能随时打断它,又能在平时不空转浪费 CPU?
如果自己写一个播放器,最省事的做法是把文件路径或 URL 直接丢给 FFmpeg:它自己 fopen() 打开、自己读、自己用 poll(fds, nfds, 100) 短超时轮询 interrupt_cb 来响应取消。能工作------但你一旦把 I/O 完全交给 FFmpeg,就同时失去了对"读"的三样控制:
- Peek(偷看但不消费) :FFmpeg 的
AVIOContext是线性流,读过的字节就没法回头看了。但格式探测需要"读前 4 字节看魔数,不对就换下一种格式重试"。每次重试都要重新fopen()+read()?mpv 用一个"只读不消费"的 peek 就解决了。 - 替换协议实现 :
avio_open("https://...")用 FFmpeg 自带的 HTTP 栈。但 mpv 有自己的 libcurl 集成、连接复用、cookie 管理。替换 I/O 回调就能把 FFmpeg 的所有格式支持和 mpv 的网络栈对接起来。 - 事件驱动取消 vs 轮询取消 :FFmpeg 靠
poll(100ms)短超时轮询检查interrupt_cb。mpv 把它替换成cond_broadcast事件唤醒------不是在比谁退出快零点几秒,而是这是一种零 CPU 浪费、延迟确定的取消模型。
这三个问题的答案都藏在 mpv 的 stream 层 里。它把一切 I/O 抽象成策略模式、用环形缓冲区减少系统调用、用 mp_cancel + interrupt_cb 把取消变成事件驱动操作。
本文是 mpv Demux 系列的 I/O 基座篇。文中
stream_t的fill_buffer回调会被后续 Demux 初始化和 Demux 运行时反复用到。
1. stream_t --- mpv 版的 AVIOContext
1.1 策略模式:一切 I/O 都是 fill_buffer
和 demuxer_desc_t 一样,stream_t 也是策略模式的教科书用例。它把文件、HTTP、FTP、SFTP、管道、内存 buffer 等所有 I/O 来源统一成一个接口:
c
// stream/stream.h
typedef struct stream {
// ★ 策略接口:每个具体实现提供自己的 fill_buffer/seek/control/close
int (*fill_buffer)(struct stream *s, void *buffer, int max_len);
int (*seek)(struct stream *s, int64_t pos);
int (*control)(struct stream *s, int cmd, void *arg);
void (*close)(struct stream *s);
int64_t pos; // 当前字节位置
int eof;
char *url; // 原始 URL
char *mime_type; // HTTP 时有效
bool seekable : 1;
bool is_network : 1; // 网络流?
// ★ 环形缓冲区字段
uint8_t *buffer;
unsigned int buf_cur; // 当前读取位置
unsigned int buf_end; // 缓冲区有效数据末尾
unsigned int buf_start; // 最早有效字节
unsigned int buffer_mask; // buffer 大小 - 1(2 的幂,用于 & 取模)
void *priv; // 具体实现的私有数据
} stream_t;
断点建议 :在
stream_read_unbuffered(stream/stream.c:510)设条件断点s->url 包含 "mp4",观察fill_buffer首次被调用时的调用栈和max_len值。
1.2 stream_list[] --- 协议注册表
stream_create() 遍历 stream_list[],通过 match_proto 匹配 URL 前缀:
c
// stream/stream.c:66 --- 真实注册表
static const stream_info_t *const stream_list[] = {
&stream_info_mpv, // mpv:// → 内部 pseudo-stream
&stream_info_avdevice, // avdevice:// → 摄像头/采集卡
&stream_info_memory, // memory:// → 内存 buffer
&stream_info_null, // null:// → /dev/null
&stream_info_mf, // mf:// → 图片序列
&stream_info_edl, // edl:// → EDL 播放列表
&stream_info_file, // file:// 或本地路径 → open/read/seek/lseek
&stream_info_slice, // slice:// → 字节范围切片
&stream_info_fd, // fd:// → 已打开的文件描述符
&stream_info_cb, // 回调流 (libmpv custom stream)
&stream_info_curl, // ★ http:// https:// → libcurl
&stream_info_ffmpeg, // ★ rtsp:// rtmp:// 等 → FFmpeg 协议处理
&stream_info_ffmpeg_unsafe,// 同上但允许危险来源
NULL
};
stream_create_with_args 遍历逻辑(stream/stream.c:441):
c
int stream_create_with_args(struct stream_open_args *args, struct stream **ret) {
for (int i = 0; i < MP_ARRAY_SIZE(stream_list); i++) {
r = stream_create_instance(stream_list[i], args, ret);
if (r == STREAM_OK) break; // ★ 成功
if (r == STREAM_NO_MATCH) continue; // 协议不匹配
if (r == STREAM_UNSAFE) continue; // 危险来源,跳过
break;
}
}
断点建议 :在
stream_create_with_args的 for 循环设断点,观察https://example.com/video.mp4如何在stream_info_curl匹配之前先被前面的条目NO_MATCH跳过。
一个 URL 走完的路径:
"https://example.com/video.mp4"
→ stream_info_mpv: "https" 不是 "mpv" → NO_MATCH, continue
→ stream_info_avdevice: NO_MATCH, continue
→ ... (7 个 continue)
→ stream_info_curl: "http" 匹配!→ libcurl 打开
→ fill_buffer = curl_fill_buffer(等 curl 线程喂数据)
→ is_network = true, seekable = true
"rtsp://192.168.1.1/stream"
→ stream_info_curl: "rtsp" 不匹配 → NO_MATCH
→ stream_info_ffmpeg: "rtsp" 匹配!
→ fill_buffer = mp_lavf_fill_buffer → avio_read()
→ is_network = true, seekable = false
"/home/user/video.mp4"(本地文件)
→ stream_info_file: 本地路径 + stat() 成功 → OK!
→ fill_buffer = read() 系统调用
→ seek = lseek()
2. 环形缓冲区:完整生命周期
stream_t 内部有一个环形缓冲区(默认 128KB),所有 fill_buffer 调用都经过它。三个索引 buf_start、buf_cur、buf_end 是单调递增的"虚拟位置"------永不回退 。物理下标 = 虚拟值 & buffer_mask。
断点建议 :在
stream_read_more(stream/stream.c:530)设条件断点forward > 0,在ring_copy(stream/stream.c:247)设断点pos > 131071(观察回绕后的物理映射)。
2.1 创建
stream_create_instance 末尾 → stream_resize_buffer(s, 0, 0)
→ new = MPMAX(0, 128KB) = 128KB
→ new = 131072 (向上取 2 的幂)
→ talloc 分配 131072 字节
→ buffer_mask = 131071 (= 0x1FFFF)
状态: buffer=[131072 字节全零], buf_start=0, buf_cur=0, buf_end=0
2.2 第一次读取:触发 fill_buffer
FFmpeg 调 avio_read(pb, buf, 4096) → mp_read → stream_read_partial(s, buf, 4096):
buf_cur == buf_end == 0 → 缓冲区空!
→ stream_read_more(s, 1) ← 请求填 1 字节,但实际会多读
stream_read_more 内部:
forward = MPMAX(1, 128KB/2) = 65536 // ★ 最少读 64KB!
read = 131072 - (0 + 0) = 131072 // 整个 buffer 全空
pos = 0 & 131071 = 0
→ stream_read_unbuffered(s, buffer[0], 131072)
→ fill_buffer → read(fd, buf, 131072)
→ 从磁盘读出 131072 字节
→ buf_end = 0 + 131072 = 131072
回到 stream_read_partial:
ring_copy(s, buf, 4096, buf_cur=0) → 从 buffer[0] copy 4096 字节
→ buf_cur = 4096
状态: buf_start=0, buf_cur=4096, buf_end=131072
2.3 后续读取:零系统调用
stream_read_partial(s, buf, 4096) ← 第 2 次
buf_cur=4096, buf_end=131072 → forward_avail=126976,足够!
ring_copy(s, buf, 4096, 4096)
→ buf_cur = 8192 ★ 零系统调用!
连续 32 次 4KB 读取后,buf_cur 追上 buf_end(都等于 131072),缓冲区空。
2.4 回绕:覆盖旧数据
stream_read_more(s, 1)
buf_old = MPMIN(131072 - 0, 65536) = 65536 // ★ 保留最后 64KB 历史
read = 131072 - (65536 + 0) = 65536 // 只剩 64KB 空位
pos = 131072 & 131071 = 0 // ★ 131072 & 0x1FFFF = 0,回到 buffer[0]!
→ stream_read_unbuffered(s, buffer[0], 65536)
→ read(fd, buffer[0], 65536) // 新 64KB 从 buffer[0] 写
→ buf_end = 131072 + 65536 = 196608
→ buf_end - buf_start = 196608 >= 131072
→ buf_start = 196608 - 131072 = 65536 // ★ 丢弃最早 64KB
状态(回绕后):
buf_start = 65536, buf_cur = 131072, buf_end = 196608
逻辑视图:
65536 131072 196608
│ [65KB历史] │ [64KB新数据] │
←── 有效范围 ──────────────→
物理布局:
buffer[0..65535] = 新数据(虚拟位置 131072..196607)
buffer[65536..131071] = 历史数据(虚拟位置 65536..131071)
断点建议 :在此状态下调
ring_copy(s, buf, 4096, buf_cur=131072),单步进入观察pos=131072 > buffer_mask=131071时走第二分支memcpy(buf, buffer[0], 4096)------虚拟位置 131072 映射到物理位置 0。
2.5 销毁
free_stream(s) → s->close(s) → talloc_free(s)
→ s->buffer 作为 talloc 子对象自动 free
2.6 核心设计总结
| 时刻 | 操作 | 系统调用? |
|---|---|---|
| 首次 4KB 读 | 缓冲区空 → fill_buffer → read(fd, 131072) |
✅ 1 次 |
| 第 2~32 次 4KB 读 | 缓冲区命中 | ❌ 0 次 |
| 第 33 次 | 缓冲区空 → fill_buffer → read(fd, 65536) |
✅ 1 次 |
| Peek / 缓存内 seek | 操作缓冲区 | ❌ 0 次 |
三个"虚拟索引"的本质 :buf_start、buf_cur、buf_end 是单调递增的逻辑字节偏移量,永不回退。物理下标 = 虚拟值 & buffer_mask。这让 ring_copy 和 stream_read_more 的实现都是简单区间运算,无需每次读写后检查回绕。
unsigned int 不会溢出吗?
三个字段都是 unsigned int(32 位 ≈ 4GB 上限),而缓冲区最大 512MB。一直播放会超出吗?答案在 stream_read_more 末尾的定期归一化 (stream/stream.c:575):
c
s->buf_end += read; // ① 加上新数据量
if (s->buf_end - s->buf_start >= buf_alloc) { // ② 缓冲区满了?
s->buf_start = s->buf_end - buf_alloc; // ③ 丢弃最早的
if (s->buf_start >= buf_alloc) { // ④ 值太大了?
s->buf_start -= buf_alloc; // ★ 整体下调!
s->buf_cur -= buf_alloc;
s->buf_end -= buf_alloc;
}
}
关键在 ④ :buf_start 永远不会超过 buf_alloc * 2。当它达到 buf_alloc 时,三个索引同时减去 buf_alloc ------它们振荡在 [0, 2 × buf_alloc) 范围内。对于最大缓冲区 512MB,就是 [0, 1GB),不到 unsigned int 上限(~4GB)的 1/4。
此外 stream_read_more 开头有断言(stream/stream.c:555):
c
mp_assert(s->buf_cur < buf_alloc * 2); // 永远 < 2 × 缓冲区大小
mp_assert(s->buf_end < buf_alloc * 2);
mp_assert(s->buf_start < buf_alloc); // 永远 < 缓冲区大小
用具体数字验证:128KB 缓冲区,3 小时 1080p 视频(约 10GB 数据读取):
buf_alloc = 131072, buf_alloc * 2 = 262144
第 N 次 fill_buffer 后:
buf_start = 120000(接近 buf_alloc)
buf_end = 250000(接近 buf_alloc * 2)
下一次 fill_buffer:
buf_end += 65536 → 315536
buf_start = 315536 - 131072 = 184464
buf_start (184464) >= buf_alloc (131072) → 触发归一化!
buf_start = 184464 - 131072 = 53392
buf_cur -= 131072
buf_end = 315536 - 131072 = 184464
→ 三个值回到 [0, 262144) 区间,永不超过 unsigned int 上限
归一化是等价的 :因为 ring_copy 的物理寻址是 pos & buffer_mask,(pos - N) & mask == pos & mask 当 N 是 buf_alloc 的整数倍时(即当 buf_alloc 是 2 的幂时,(x - buf_alloc) & (buf_alloc - 1) = x & (buf_alloc - 1))。所以整体减 buf_alloc 不影响物理映射,只是让虚拟值保持在安全范围内。
3. stream_read_peek --- 不消费数据的读
3.1 实现
只有两行(stream/stream.c:650):
c
int stream_read_peek(stream_t *s, void *buf, int buf_size) {
stream_peek(s, buf_size); // ① 确保缓冲区有足够数据
return ring_copy(s, buf, buf_size, s->buf_cur); // ② copy,但不推进 buf_cur
}
stream_peek 内部循环调 stream_read_more(s, forward_size) 直到有足够数据或 EOF。ring_copy 从 buf_cur copy------不修改 buf_cur。数据还在缓冲区,下次正常读还能读到。
断点建议 :在
stream_read_peek设断点,调用后检查s->buf_cur是否未变。
3.2 真实调用场景
全仓库 18 处调用,分为三类:
| 类别 | 调用方 | 干什么 |
|---|---|---|
| 格式探测 | demux_lavf.c、demux_mkv.c、demux_libarchive.c、demux_edl.c、demux_cue.c、demux_mf.c、demux_playlist.c、stream_libarchive.c |
读前 N 字节做文件头魔数匹配 |
| EOF 探测 | demux_mkv.c:2388、demux_disc.c:347、demux_playlist.c:173、ebml.c:193 |
stream_read_peek(s, &(char){0}, 1) --- 偷看 1 字节,返回 0 就是 EOF |
| BOM 跳过 | stream/stream.c:808(stream_skip_bom) |
读前 4 字节检查 UTF-8/UTF-16 BOM |
4. 替换 FFmpeg 的 I/O:demux_open_lavf 适配层
4.1 如何替换
demux_open_lavf(demux_lavf.c:1310)在创建 AVFormatContext 后,用自己的回调替换 I/O:
c
// demux_lavf.c:1310 --- 节选
void *buffer = av_malloc(lavfdopts->buffersize); // 默认 32768 字节
priv->pb = avio_alloc_context(buffer, lavfdopts->buffersize,
0, demuxer,
mp_read, // ★ 读回调
NULL, // 写回调(只读)
mp_seek); // ★ seek 回调
priv->pb->read_seek = mp_read_seek; // ★ 字节级 seek
priv->pb->seekable = demuxer->seekable ? AVIO_SEEKABLE_NORMAL : 0;
avfc->pb = priv->pb; // 挂到 AVFormatContext
// 同时设置中断回调
avfc->interrupt_callback = (AVIOInterruptCB){
.callback = interrupt_cb,
.opaque = demuxer,
};
三个回调的内部实现------全部转发到 mpv 的 stream 层:
| 回调 | 触发场景 | 内部实现 |
|---|---|---|
mp_read |
avformat_open_input / avformat_find_stream_info / av_read_frame |
stream_read_partial(priv->stream, buf, size) |
mp_seek |
avio_seek() 跳转到指定字节偏移 |
stream_seek(priv->stream, offset) |
mp_read_seek |
某些格式优化路径的"先 seek 再读"原子操作 | stream_seek + stream_read_partial |
💡
avio_alloc_context的第四个参数opaque被设为demuxer。FFmpeg 调用mp_read(opaque, buf, size)时,回调内部通过(struct demuxer *)opaque强转拿到priv->stream,从而访问 mpv 的 stream 层。这是 FFmpegAVIOContext自定义 I/O 的标准模式。
4.2 为什么要替换?
FFmpeg 默认的 AVIOContext 行为是:如果不给 pb,它就用 avio_open() 自己调 fopen()。三个问题:
- URL 协议不匹配 :
avio_open("https://...")用 FFmpeg 自带的 HTTP 栈,mpv 有自己的 HTTP 实现和连接复用策略 - 取消模型不对 :FFmpeg 原生网络 I/O 通过
ff_network_wait_fd_timeout()做poll(fds, nfds, 100)短超时,每次超时后检查interrupt_cb。问题不是"慢"------而是它在浪费 CPU 做无意义的轮询 :即使没有人按 q,demux 线程也要每 100ms 醒来一次检查取消标志。mpv 替换 I/O 后变成事件驱动------取消信号不来,线程就安静休眠;信号一到,cond_broadcast立即唤醒(详见 §5) - 无法 peek :
stream_read_peek需要"读但不消费"------FFmpeg 的线性 AVIO 不支持(见 §3)
4.3 两个例子
例一:avformat_open_input 读 MP4 文件头
avformat_open_input(&avfc, "video.mp4", NULL, NULL)
│
├─ avio_read(pb, buf, 4096) ← 读前 4KB,找 ftyp/moov
│ └─ mp_read(demuxer, buf, 4096)
│ └─ stream_read_partial(stream, buf, 4096)
│ └─ stream->fill_buffer → read(fd, buf, 4096) or recv(sockfd)
│
├─ avio_seek(pb, moov_offset, SEEK_SET) ← 跳到 moov box
│ └─ mp_seek → stream_seek → lseek or HTTP Range
│
└─ avio_read(pb, buf, moov_size) ← 读 moov 内容
└─ mp_read(...)
本地 SSD 还是太平洋对岸的 HTTP 服务器------FFmpeg 调的都是同一个 mp_read,底层是 read() 还是 recv() 对 FFmpeg 完全透明。
例二:运行时 av_read_frame 读一帧
c
// demux_lavf.c: demux_lavf 的 read_packet 实现(简化)
static bool demux_lavf_read_packet(demuxer_t *demuxer, demux_packet_t **pkt) {
AVPacket avpkt;
int ret = av_read_frame(priv->avfc, &avpkt);
if (ret < 0) return false;
// av_read_frame 内部:avio_read(pb, ...) → mp_read → stream_read_partial
// stream 的环形缓冲区一次读了 64KB+,
// 后续 av_read_frame 零系统调用直接命中
*pkt = new_demux_packet_from_avpacket(avpkt);
return true;
}
断点建议 :在
demux_lavf_read_packet设断点,对比第 1 次和第 30 次av_read_frame时stream_read_partial是否触发fill_buffer(只有第 1 次触发)。
5. interrupt_cb + mp_cancel 取消链
5.1 mp_cancel --- 级联取消令牌
c
// misc/thread_tools.h:45
struct mp_cancel; // 内部:bool triggered + mp_cond + 回调 + parent 指针
void mp_cancel_trigger(struct mp_cancel *c); // 触发取消(沿 parent 级联)
bool mp_cancel_test(struct mp_cancel *c); // 非阻塞检查
void mp_cancel_set_parent(struct mp_cancel *slave, struct mp_cancel *parent);
5.2 AVIOInterruptCB --- FFmpeg 的轮询式中断
c
typedef struct AVIOInterruptCB {
int (*callback)(void*); // 返回 1 = 中断
void *opaque;
} AVIOInterruptCB;
FFmpeg 在每次可能阻塞的 I/O 前后调用此回调。返回 1 → 立即中止并返回 AVERROR_EXIT。
5.3 mpv 的接入
只有一行(demux_lavf.c:912):
c
static int interrupt_cb(void *ctx) {
return mp_cancel_test(((struct demuxer *)ctx)->cancel);
}
5.4 完整取消流程
🌐 stream (TCP socket) 🧩 FFmpeg (demux 线程) 🔴 demuxer->>cancel 🔴 mp_cancel (根) 🧵 主线程 👆 用户 🌐 stream (TCP socket) 🧩 FFmpeg (demux 线程) 🔴 demuxer->>cancel 🔴 mp_cancel (根) 🧵 主线程 👆 用户 #mermaid-svg-0gx0NvKtYR773RIc{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-0gx0NvKtYR773RIc .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-0gx0NvKtYR773RIc .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-0gx0NvKtYR773RIc .error-icon{fill:#552222;}#mermaid-svg-0gx0NvKtYR773RIc .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-0gx0NvKtYR773RIc .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-0gx0NvKtYR773RIc .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-0gx0NvKtYR773RIc .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-0gx0NvKtYR773RIc .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-0gx0NvKtYR773RIc .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-0gx0NvKtYR773RIc .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-0gx0NvKtYR773RIc .marker{fill:#333333;stroke:#333333;}#mermaid-svg-0gx0NvKtYR773RIc .marker.cross{stroke:#333333;}#mermaid-svg-0gx0NvKtYR773RIc svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-0gx0NvKtYR773RIc p{margin:0;}#mermaid-svg-0gx0NvKtYR773RIc .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-0gx0NvKtYR773RIc text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-0gx0NvKtYR773RIc .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-0gx0NvKtYR773RIc .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-0gx0NvKtYR773RIc .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-0gx0NvKtYR773RIc .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-0gx0NvKtYR773RIc #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-0gx0NvKtYR773RIc .sequenceNumber{fill:white;}#mermaid-svg-0gx0NvKtYR773RIc #sequencenumber{fill:#333;}#mermaid-svg-0gx0NvKtYR773RIc #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-0gx0NvKtYR773RIc .messageText{fill:#333;stroke:none;}#mermaid-svg-0gx0NvKtYR773RIc .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-0gx0NvKtYR773RIc .labelText,#mermaid-svg-0gx0NvKtYR773RIc .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-0gx0NvKtYR773RIc .loopText,#mermaid-svg-0gx0NvKtYR773RIc .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-0gx0NvKtYR773RIc .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-0gx0NvKtYR773RIc .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-0gx0NvKtYR773RIc .noteText,#mermaid-svg-0gx0NvKtYR773RIc .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-0gx0NvKtYR773RIc .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-0gx0NvKtYR773RIc .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-0gx0NvKtYR773RIc .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-0gx0NvKtYR773RIc .actorPopupMenu{position:absolute;}#mermaid-svg-0gx0NvKtYR773RIc .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-0gx0NvKtYR773RIc .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-0gx0NvKtYR773RIc .actor-man circle,#mermaid-svg-0gx0NvKtYR773RIc line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-0gx0NvKtYR773RIc :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} av_read_frame() 阻塞中 recv 还在阻塞,interrupt_cb 不会调 直到 stream 层打断 recv... avio_read() → mp_read() → stream_read_partial() recv(sockfd) 阻塞中... 按 q mp_cancel_trigger(playback_abort) 级联触发 demuxer->>cancel FFmpeg 轮询 interrupt_cb interrupt_cb() → mp_cancel_test(cancel) return 1 (已取消!) av_read_frame 返回 AVERROR_EXIT
5.5 interrupt_cb 在"两次系统调用之间" 调用
以下是 FFmpeg
aviobuf.c中fill_buffer及网络底层retry_transfer_wrapper的高度简化版心智模型 。真实源码中interrupt_cb并非简单地夹在read_packet两侧,而是深埋在while (poll/select)重试循环中------这里把它抽象成"I/O 前检查一次,I/O 后检查一次",便于理解取消的时机窗口。
c
int avio_read(AVIOContext *s, unsigned char *buf, int size) {
while (total_read < size) {
// ① ★ I/O 前检查 interrupt_cb
if (s->interrupt_callback.callback(...)) return AVERROR_EXIT;
// ② ★ 真正读 → mpv 已替换:mp_read → stream_read_partial → fill_buffer
int ret = s->read_packet(s->opaque, buf + total_read, size - total_read);
// ③ ★ I/O 后检查 interrupt_cb
if (s->interrupt_callback.callback(...)) return AVERROR_EXIT;
if (ret <= 0) break;
total_read += ret;
}
return total_read;
}
interrupt_cb 在 ① 和 ③ 被调用,在 ② 执行期间不被调用。 这决定了取消被响应的时间窗口取决于 ② 的单次阻塞时长。
- FFmpeg 原生 :② 是
ff_network_wait_fd_timeout(),内部poll(fds, nfds, 100)短超时------每次 poll 返回后检查interrupt_cb。轮询的本质是即使没人按 q,线程也要每 100ms 醒来一次,浪费 CPU。 - mpv 替换后 :② 是
stream->fill_buffer。不同 stream 实现采用不同的事件驱动策略(详见 §5.6~§5.7)------不轮询,取消信号到达即唤醒。
5.6 本地文件:fill_buffer 的两条路径
stream_file.c:117 的 fill_buffer 有两个分支------use_poll 决定走哪条:
c
static int fill_buffer(stream_t *s, void *buffer, int max_len) {
struct priv *p = s->priv;
#ifndef _WIN32
// ─── 路径 A: use_poll=true (socket/FIFO) ───
if (p->use_poll) {
int c = mp_cancel_get_fd(p->cancel); // ★ cancel_fd(pipe 读端)
struct pollfd fds[2] = {
{.fd = p->fd, .events = POLLIN}, // 文件 fd
{.fd = c, .events = POLLIN}, // cancel_fd
};
poll(fds, c >= 0 ? 2 : 1, -1); // ★ 同时等两个
if (fds[1].revents & POLLIN) return -1; // 取消了!
}
#endif
// ─── 路径 B: use_poll=false (常规本地文件) ───
for (int retries = 0; retries < MAX_RETRIES; retries++) {
int r = read(p->fd, buffer, max_len);
if (r > 0) return r; // 读到数据 → 返回
// 检测文件是否在播放期间被追加(如 tail -f 场景)
int64_t size = get_size(s);
if (p->regular_file && size > p->orig_size && !p->appending) {
MP_WARN(s, "File is apparently being appended to, will keep "
"retrying with timeouts.\n");
p->appending = true; // ★ 触发追加模式
}
if (!p->appending || p->use_poll) // ← ★ 关键行
break; // 常规文件: true → break!
if (mp_cancel_wait(p->cancel, RETRY_TIMEOUT)) // 追加模式: 200ms 超时重试
break;
}
return 0;
}
use_poll 什么时候为 true?
在 open_f() 中(stream_file.c:367),只有非普通文件才设 use_poll=true:
c
// socket、FIFO、pipe → use_poll=true
is_sock_or_fifo = S_ISSOCK(st.st_mode) || S_ISFIFO(st.st_mode);
if (is_sock_or_fifo || check_stream_network(p->fd))
p->use_poll = true;
poll 对常规文件无效 ------POSIX 规定 poll 在常规文件上永远立即返回 POLLIN(数据或 EOF 都是"可读"),无法用于阻塞等待。mpv 只在 socket/FIFO 上用 poll(cancel_fd) 实现事件驱动取消。
路径 B 的取消逻辑(use_poll=false,最常见场景)
对于常规本地文件(如 video.mp4),第 145 行是关键:
c
if (!p->appending || p->use_poll) break;
// !false || false = true → break → 返回 0
fill_buffer 内部没有任何取消机制。 但这不是问题------read() 在本地磁盘文件上几乎瞬时返回(内核 page cache),不存在"阻塞等数据"。取消靠的是上层 FFmpeg 的 interrupt_cb ------它在 avio_read() 的循环中每次 mp_read 返回后检查(§5.5 的 ① 和 ③)。
取消链路(常规文件):
按 q → mp_cancel_trigger → demuxer->cancel = true
demux 线程:
av_read_frame() → avio_read() 循环:
① interrupt_cb → mp_cancel_test → return 1 → AVERROR_EXIT ✅
如果正好在 ② 里: fill_buffer → read() (瞬时返回) → ③ interrupt_cb → AVERROR_EXIT ✅
时延 ≈ 一次 read() 调用的时间 + 两次函数调用开销。
路径 B 中 mp_cancel_wait 谁在用?
只在文件被追加写入 时(appending=true)生效------比如播放一个正在被录制的文件。read() 读到末尾返回 0,但文件还在增长,于是进入 200ms 超时重试循环。这个循环可被取消信号打断------mp_cancel_wait 内部是 cond_timedwait,被 mp_cancel_trigger 触发后立即返回。
5.7 网络流:分协议讨论
| 协议 | stream 实现 | 取消机制(微观唤醒方式) |
|---|---|---|
| HTTP(S) | stream_curl(libcurl) |
demux 线程等 mp_cond_wait,curl 线程做 I/O。取消 = cond_broadcast + curl_multi_wakeup------纯用户态唤醒,无需轮询 |
| RTSP / RTMP | stream_ffmpeg(FFmpeg 协议) |
fill_buffer → avio_read() 阻塞在 FFmpeg 内部 socket。取消靠 interrupt_cb 在每次 poll(100ms) 超时后检查 |
裸 recv(无超时机制) |
无 | 无限期阻塞 :对端静默(拔网线、进程假死且未发 FIN/RST)时 recv() 永远不返回。tcp_retries2 仅控制发送端未确认数据的重传次数,接收端不适用 |
注:表格描述的是取消机制的微观唤醒方式 ,不是端到端退出时间。实际的进程退出还包括线程 cleanup、内存释放等开销,两个播放器这部分差不多。mpv 的
cond_broadcast优势不在"快零点几秒",而在不浪费 CPU 做轮询且取消延迟确定(不受 poll 超时周期影响)。
HTTP 快车道:libcurl 生产者-消费者模型
🧵 Demux 线程 🧵 Curl 线程
curl_fill_buffer() curl_multi_perform()
→ mp_cond_wait(&cond) → recv(sockfd)
被 cond_broadcast 唤醒 → write_callback
→ memcpy 从 ring buffer 读 → 写 ring buffer
→ cond_broadcast
取消链路(stream_curl.c:695):
c
static void on_cancel(void *ctx) {
atomic_store(&p->aborted, true); // 设标志
mp_cond_broadcast(&p->cond); // ★ 唤醒 fill_buffer
curl_multi_wakeup(p->ctx->multi); // ★ 唤醒 curl 线程
}
demux 线程的 fill_buffer 从来不碰 socket------取消是纯用户态 cond_broadcast。
RTSP 慢车道:走 FFmpeg 协议
stream_ffmpeg 的 fill_buffer 实际是 avio_read(),阻塞在 FFmpeg 内部 socket。取消只能靠 interrupt_cb 在 I/O 之间轮询------时延取决于 socket 何时返回。
5.8 mp_cancel 级联结构
mpctx->playback_abort (根)
├── demuxer->cancel ← FFmpeg interrupt_cb 检查
│ └── stream->cancel ← fill_buffer 检查
├── dispatch 的 cancel
└── 各 filter 的 cancel
任意一个退出路径 → mp_cancel_trigger → 整棵树被触发 → 所有等 I/O 的线程同时唤醒。
6. 总结:三条通用方法论
-
环形缓冲区 + 虚拟索引 = 零回绕检查 :
buf_start/cur/end单调递增,物理下标 =& buffer_mask。一次fill_buffer读 64KB+,后续小读全部零系统调用。这个设计可以直接搬到任何需要缓冲 I/O 的场景。 -
I/O 替换 = 事件驱动取消 vs 轮询取消 :FFmpeg 的
interrupt_cb靠poll(100ms)短超时轮询。mpv 替换 I/O 后实现事件驱动------socket/FIFO 用poll(cancel_fd)双路等待、常规文件的read()本身不阻塞(取消由上层interrupt_cb保证)、HTTP 用cond_broadcast零轮询唤醒。关键不是谁快,而是轮询浪费 CPU、事件驱动不浪费。 -
策略模式 × 2 :stream 层和 demux 层都是策略模式------加新协议只需写一个
stream_info_t,框架自动完成协议匹配、缓冲、取消集成。
7. 动手验证
打开你的终端,跑两条命令感受一下环形缓冲区和取消机制的存在。
验证一:观察环形缓冲区的创建和 seek 行为
bash
mpv --msg-level=file=trace --vo=null --ao=null test_clock.mp4 2>&1 | head -30
这行命令在做什么 :--msg-level=file=trace 把 stream_file 模块的日志开到 trace 级别。注意模块名是 file(对应 stream_info_file.name),不是 stream。
你会在输出中看到什么:
[file] Opening /path/to/test_clock.mp4
[file] resize stream to 131072 bytes, drop 0 bytes ← ★ 环形缓冲区创建 128KB
[file] Stream opened successfully.
[file] seek request from 0 to 0 ← demux 层反复 seek
[file] seek request from 32768 to 346756 ← 跳到文件尾读 moov
[file] stream level seek from 131072 to 346756 ← 触发真正的 stream_seek
resize stream to 131072 bytes 这一行就是 §2.1 讲的缓冲区创建------128KB 向上取 2 的幂得到 131072。seek request from x to y 记录 demux 层对 stream 层的每次定位请求。stream level seek 意味着 fill_buffer 的位置变更------demux 跳过了一层缓存(已有数据),请求 stream 层直接 lseek 到新位置。
fill_buffer本身没有 trace 日志------它是裸read()系统调用,mpv 不打日志。要量化"一次fill_buffervs 多次av_read_frame"的差距,用验证二的strace/fs_usage。
验证二:量化"一次 fill_buffer vs 多次 av_read_frame"的差距
bash
# Linux: strace 只追踪 read() 系统调用
strace -e read mpv --vo=null --ao=null --start=10 --length=1 video.mp4 2>&1 | wc -l
# macOS: fs_usage 按进程名过滤(需要 sudo,不需要关 SIP)
sudo fs_usage -w -f filesys mpv
# 另一个终端跑: mpv --vo=null --ao=null --start=2 --length=1 test_big.mp4
# 在 fs_usage 输出中 grep test_big 即可统计读操作
这行命令在做什么 :strace -e read 追踪 read() 系统调用,fs_usage -f filesys mpv 按进程名过滤所有文件操作。--vo=null --ao=null 禁用视频/音频输出,--start=2 --length=1 播 1 秒视频。
你会在输出中看到什么 :视频文件上只有 2 次 read() 系统调用 (每次对应一次 fill_buffer → 环形缓冲区填充)。fs_usage 的实际输出如下(已精简,只保留 test_big.mp4 相关行):
16:36:58.192976 open F=10 test_big.mp4 ← ① 打开视频文件
16:36:58.193744 RdData[A] B=0x1000 test_big.mp4 ← ② 磁盘读 4KB(格式探测)
16:36:58.193817 RdData[A] B=0x1f000 test_big.mp4 ← ③ 磁盘读 124KB
16:36:58.193828 read F=10 B=0x20000 ← ④ ★ read(128KB) 系统调用!
0x1000+0x1f000=0x20000=131072
...(~35ms 后,demux 继续消费数据,缓冲区再次空了)...
16:36:58.229576 RdData[A] B=0x10000 test_big.mp4 ← ⑤ 磁盘读 64KB
16:36:58.229579 RdData[A] B=0x10000 test_big.mp4 ← ⑥ 磁盘读 64KB
16:36:58.229590 read F=10 B=0x20000 ← ⑦ ★ 第二次 read(128KB)!
- ④ 就是 §2.2 讲的
read(fd, buf, 131072)------环形缓冲区首次填充,0x20000= 131072 = 128KB,和代码完全一致 - ②③ 是内核为满足 ④ 而发起的两次物理磁盘读取(
RdData[A]= 异步磁盘 I/O),0x1000 + 0x1f000 = 0x20000 - ⑤⑥⑦ 是第二次
fill_buffer:demux 消费完第一批数据后又调了一次read(128KB) - 总共 2 次
read()系统调用 ------而av_read_frame在 1 秒 30fps 下约 30 次。环形缓冲区把系统调用减少约 15 倍
macOS 没有
strace,但fs_usage按进程名过滤(sudo fs_usage -w -f filesys mpv)即可。上面贴的真实输出就是这样抓到的------过滤test_big就能看到两次read(0x20000)和各自对应的RdData拆分。不需要关 SIP。
千万不要用grep "stream level seek"来统计算 ------stream level seek日志只在发生随机跳转 (lseek)时打印,比如 demux 跳到文件尾读 moov。正常顺序播放时,环形缓冲区耗尽后是直接read()往后读,不触发lseek,也就不会打印这行日志。用它统计会把真实的fill_buffer次数严重低估。
验证三:两种取消模型的本质差异------轮询 vs 事件驱动
前面 §5 讲了 mpv 和 FFmpeg 在取消机制上的架构差异,这里用手边的代码直接对照。
步骤一:找到 FFmpeg 的轮询取消
在 FFmpeg 源码中搜索 ff_network_wait_fd_timeout(FFmpeg 原生 HTTP 的网络等待函数),你会看到类似这样的模式:
c
// FFmpeg 原生 HTTP 的取消模型(概念性示意)
while (!done) {
int ret = poll(fds, nfds, 100); // ① 等 100ms 或数据到达
if (ret < 0) break;
if (interrupt_cb(opaque)) return EXIT; // ② poll 返回后检查取消
if (ret > 0) {
read_data(); // ③ 读数据
if (interrupt_cb(opaque)) return EXIT; // ④ 读完再检查
}
}
本质:轮询。即使没有人按 q,这个循环每 100ms 也要醒来一次------浪费 CPU。
步骤二:找到 mpv 的事件驱动取消
只有三行(stream/stream_curl.c:695):
c
static void on_cancel(void *ctx) {
atomic_store(&p->aborted, true); // 设标志
mp_cond_broadcast(&p->cond); // ★ 唤醒阻塞在 cond_wait 的 demux 线程
curl_multi_wakeup(p->ctx->multi); // ★ 唤醒阻塞在 recv 的 curl 线程
}
本质 :事件驱动。取消信号不来,demux 线程在 cond_wait 上安静休眠,zero CPU。信号一到,cond_broadcast 立即唤醒------没有轮询周期限制。
步骤三:用 slow_server.py 观察 demux 线程的阻塞状态
先新建 slow_server.py,用 Python 标准库实现一个"先快后慢"的 HTTP 服务------前 100KB 全速发送(让 moov 解析通过,播放启动),之后就长时间休眠(模拟网络断开,迫使 demux 线程阻塞等数据):
python
#!/usr/bin/env python3
"""前 100KB 全速,之后 hang 30 秒------迫使播放器进入 Buffering 状态。"""
import http.server, socketserver, time, sys, os
BURST, HANG = 100 * 1024, 30
PORT = 8765
class H(http.server.SimpleHTTPRequestHandler):
def copyfile(self, src, dst):
total = 0
try:
while True:
buf = src.read(4096)
if not buf: break
dst.write(buf)
total += len(buf)
if total > BURST:
time.sleep(HANG) # ★ burst 之后"断网"
except (BrokenPipeError, ConnectionResetError):
pass
if __name__ == '__main__':
port = int(sys.argv[1]) if len(sys.argv) > 1 else PORT
if len(sys.argv) > 2: os.chdir(sys.argv[2])
socketserver.TCPServer.allow_reuse_address = True
with socketserver.ThreadingTCPServer(("", port), H) as httpd:
print(f"🐢 http://localhost:{port} (burst {BURST//1024}KB, hang {HANG}s)")
httpd.serve_forever()
然后开两个终端:
bash
# 终端 1:启动服务
python3 slow_server.py 8765
# 终端 2:用大文件测试(需要 >500KB,确保 burst 覆盖不全)
mpv --vo=null --ao=null http://localhost:8765/test_big.mp4
你会看到:burst 阶段快速加载 → Cache: 0.0s → (Buffering)。此时 demux 线程正阻塞在 cond_wait 上,等 curl 线程喂数据。按 q------cond_broadcast 唤醒它,零轮询开销。如果换成 ffplay,同样的 (Buffering) 状态,interrupt_cb 要等 poll(100ms) 下一次超时才会被检查到。
关键认知 :mpv 的优势不是"退出快几毫秒"------进程退出还包括线程 cleanup、内存释放等,两者这部分开销差不多。mpv 的真正优势是零 CPU 轮询:demux 线程不需要每 100ms 醒来检查"取消了吗",而是安静休眠直到事件到达。
pkill 的行为等价于按 q :pkill 发 SIGTERM → mpv 的信号处理函数调 mp_abort_playback_async → mp_cancel_trigger → 整条取消链被触发。
8. 唠两句
本节最反直觉的一点:常规本地文件的取消,靠的不是 fill_buffer 内部的 poll(cancel_fd),而是上层 FFmpeg 的 interrupt_cb。 poll 在普通文件上永远立即返回------这是 POSIX 的规定,不是 mpv 的选择。mpv 的聪明之处在于:它不跟 POSIX 较劲,直接把取消硬仗交给 FFmpeg 的 avio_read 循环去解决------每次 read() 回来后检查一次 interrupt_cb。因为 read() 本身够快,这个方案的时延不比 poll(cancel_fd) 差。
真正需要 poll(cancel_fd) 的是 socket 和 FIFO(use_poll=true)------这些"文件"的 read() 真的会卡住。而追加写入中的常规文件(appending=true)走第三条路------mp_cancel_wait 的 200ms 超时循环。
三种文件类型,三条取消路径,但 mpv 把它们全塞进同一个 fill_buffer 函数里。这就是"C 语言的策略模式"------不用虚表,用 if 分支 + 字段 flag 照样把复杂度关在门里。