[mpv架构] (二) 为什么 mpv 要把 FFmpeg 的 I/O 换掉?

问题引入

mpv video.mp4 播放本地文件,中途按 q。退出几乎在瞬间完成------用户看到的是"快",但真正值得琢磨的是背后的设计问题 :demux 线程可能正阻塞在 read()recv() 上,播放器凭什么既能随时打断它,又能在平时不空转浪费 CPU?

如果自己写一个播放器,最省事的做法是把文件路径或 URL 直接丢给 FFmpeg:它自己 fopen() 打开、自己读、自己用 poll(fds, nfds, 100) 短超时轮询 interrupt_cb 来响应取消。能工作------但你一旦把 I/O 完全交给 FFmpeg,就同时失去了对"读"的三样控制:

  1. Peek(偷看但不消费) :FFmpeg 的 AVIOContext 是线性流,读过的字节就没法回头看了。但格式探测需要"读前 4 字节看魔数,不对就换下一种格式重试"。每次重试都要重新 fopen() + read()?mpv 用一个"只读不消费"的 peek 就解决了。
  2. 替换协议实现avio_open("https://...") 用 FFmpeg 自带的 HTTP 栈。但 mpv 有自己的 libcurl 集成、连接复用、cookie 管理。替换 I/O 回调就能把 FFmpeg 的所有格式支持和 mpv 的网络栈对接起来。
  3. 事件驱动取消 vs 轮询取消 :FFmpeg 靠 poll(100ms) 短超时轮询检查 interrupt_cb。mpv 把它替换成 cond_broadcast 事件唤醒------不是在比谁退出快零点几秒,而是这是一种零 CPU 浪费、延迟确定的取消模型。

这三个问题的答案都藏在 mpv 的 stream 层 里。它把一切 I/O 抽象成策略模式、用环形缓冲区减少系统调用、用 mp_cancel + interrupt_cb 把取消变成事件驱动操作。

本文是 mpv Demux 系列的 I/O 基座篇。文中 stream_tfill_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_unbufferedstream/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_startbuf_curbuf_end 是单调递增的"虚拟位置"------永不回退 。物理下标 = 虚拟值 & buffer_mask

断点建议 :在 stream_read_morestream/stream.c:530)设条件断点 forward > 0,在 ring_copystream/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_readstream_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_bufferread(fd, 131072) ✅ 1 次
第 2~32 次 4KB 读 缓冲区命中 ❌ 0 次
第 33 次 缓冲区空 → fill_bufferread(fd, 65536) ✅ 1 次
Peek / 缓存内 seek 操作缓冲区 ❌ 0 次

三个"虚拟索引"的本质buf_startbuf_curbuf_end 是单调递增的逻辑字节偏移量,永不回退。物理下标 = 虚拟值 & buffer_mask。这让 ring_copystream_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_copybuf_cur copy------不修改 buf_cur。数据还在缓冲区,下次正常读还能读到。

断点建议 :在 stream_read_peek 设断点,调用后检查 s->buf_cur 是否未变。

3.2 真实调用场景

全仓库 18 处调用,分为三类:

类别 调用方 干什么
格式探测 demux_lavf.cdemux_mkv.cdemux_libarchive.cdemux_edl.cdemux_cue.cdemux_mf.cdemux_playlist.cstream_libarchive.c 读前 N 字节做文件头魔数匹配
EOF 探测 demux_mkv.c:2388demux_disc.c:347demux_playlist.c:173ebml.c:193 stream_read_peek(s, &(char){0}, 1) --- 偷看 1 字节,返回 0 就是 EOF
BOM 跳过 stream/stream.c:808stream_skip_bom 读前 4 字节检查 UTF-8/UTF-16 BOM

4. 替换 FFmpeg 的 I/O:demux_open_lavf 适配层

4.1 如何替换

demux_open_lavfdemux_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 层。这是 FFmpeg AVIOContext 自定义 I/O 的标准模式。

4.2 为什么要替换?

FFmpeg 默认的 AVIOContext 行为是:如果不给 pb,它就用 avio_open() 自己调 fopen()。三个问题:

  1. URL 协议不匹配avio_open("https://...") 用 FFmpeg 自带的 HTTP 栈,mpv 有自己的 HTTP 实现和连接复用策略
  2. 取消模型不对 :FFmpeg 原生网络 I/O 通过 ff_network_wait_fd_timeout()poll(fds, nfds, 100) 短超时,每次超时后检查 interrupt_cb。问题不是"慢"------而是它在浪费 CPU 做无意义的轮询 :即使没有人按 q,demux 线程也要每 100ms 醒来一次检查取消标志。mpv 替换 I/O 后变成事件驱动------取消信号不来,线程就安静休眠;信号一到,cond_broadcast 立即唤醒(详见 §5)
  3. 无法 peekstream_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_framestream_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.cfill_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:117fill_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_bufferavio_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_ffmpegfill_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. 总结:三条通用方法论

  1. 环形缓冲区 + 虚拟索引 = 零回绕检查buf_start/cur/end 单调递增,物理下标 = & buffer_mask。一次 fill_buffer 读 64KB+,后续小读全部零系统调用。这个设计可以直接搬到任何需要缓冲 I/O 的场景。

  2. I/O 替换 = 事件驱动取消 vs 轮询取消 :FFmpeg 的 interrupt_cbpoll(100ms) 短超时轮询。mpv 替换 I/O 后实现事件驱动------socket/FIFO 用 poll(cancel_fd) 双路等待、常规文件的 read() 本身不阻塞(取消由上层 interrupt_cb 保证)、HTTP 用 cond_broadcast 零轮询唤醒。关键不是谁快,而是轮询浪费 CPU、事件驱动不浪费

  3. 策略模式 × 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=tracestream_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_buffer vs 多次 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 的行为等价于按 qpkill 发 SIGTERM → mpv 的信号处理函数调 mp_abort_playback_asyncmp_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 照样把复杂度关在门里。

相关推荐
开开心心就好4 小时前
文件批量重命名工具简单好用支持规则改名
java·开发语言·b树·ocr·excel·音视频·kmeans
EasyDSS13 小时前
视频直播点播平台EasyDSS一体化融媒体平台:直播、点播、会议与集群对讲的一站式管理
音视频·媒体
奈斯先生Vector14 小时前
告别工具碎片化:基于 Nano Banana 全模态 AI 聚合架构搭建“文本-图像-视频”自动化协同生产线
运维·数据库·人工智能·架构·自动化·aigc·音视频
OpenCut19 小时前
OpenCut丨视频后期剪辑!AI 一键清理视频画面文字水印!
人工智能·音视频
hans汉斯20 小时前
计算机科学与应用|改进MeanShift算法在智能监控视频中的应用研究
图像处理·人工智能·功能测试·深度学习·算法·音视频
dongge101 天前
018 ADAT接口
网络·音视频·硬件工程·视频
开开心心就好1 天前
批量图片OCR识别重命名工具完全免费
随机森林·智能手机·ocr·电脑·word·音视频·最小二乘法
俊哥工具1 天前
文件批量转换工具 本地运行更快更安全
运维·服务器·django·音视频·tornado
weixin_495248402 天前
短剧视频翻译配音指南:古装、甜宠与悬疑如何本地化?
音视频