[mpv架构] (三) mpv 怎么实现 MP4 秒开?

问题引入:还没解码,mpv 是怎么秒懂轨道的?

当敲下 mpv video.mp4,回车。在第一个画面出现在屏幕上之前,mpv 已经做了一系列精密的初始化:打开文件、探测格式、创建 FFmpeg 上下文、解析轨道------这些在 demux 线程启动之前完成。选轨和建解码器则在 demux 线程启动之后、预读开始之前。

你品品这个细节:mpv 甚至还没开始解码,就已经知道这个文件有几条视频轨、各自的分辨率和帧率、每条音轨的采样率。它是怎么在几百毫秒内完成这些判断的?

一个自然的猜测是:这还不简单,avformat_open_input() 之后,使用 avformat_find_stream_info() 探测流信息、av_find_best_stream() 选轨、stream_component_open() 打开解码器并起独立解码线程。所以粗粒度上,mpv 和 ffplay 是同一副骨架:demux 线程 + 解码线程 + 队列。

那本文还能讲什么?------差距不在流程长短,而在 mpv 把"打开"这个动作拆得更细、更可控 。ffplay 把这一大坨交给了 FFmpeg 黑盒,mpv 却自己搭了一条五层的链,并且让它跑在一个短暂的 Opener 线程 上(§1.1)。全文就一条主线:把"打开"拆成"静态架构"和"动态时序"两条,各看三个设计点

  • 静态架构 (§1-§2):三线程模型、核心数据结构、五层初始化链------重点在 d_thread/d_user 这个"用拷贝换锁"的设计(§2.2);
  • 动态时序 (§3):以 MP4 为例,把"秒开"的真相落回 moov 容器------重点在 skipinfo 这个"用静态表换秒开"的设计(§3.2)。

1. 架构骨架:三线程与数据结构

1.1 三线程模型:Opener 桥接 / Demux 生产者 / 主线程消费者

打开一个文件,涉及的线程是三个,不是两个。先看总架构:
#mermaid-svg-rgGGLbQfE5PmSrEs{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-rgGGLbQfE5PmSrEs .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-rgGGLbQfE5PmSrEs .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-rgGGLbQfE5PmSrEs .error-icon{fill:#552222;}#mermaid-svg-rgGGLbQfE5PmSrEs .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-rgGGLbQfE5PmSrEs .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-rgGGLbQfE5PmSrEs .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-rgGGLbQfE5PmSrEs .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-rgGGLbQfE5PmSrEs .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-rgGGLbQfE5PmSrEs .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-rgGGLbQfE5PmSrEs .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-rgGGLbQfE5PmSrEs .marker{fill:#333333;stroke:#333333;}#mermaid-svg-rgGGLbQfE5PmSrEs .marker.cross{stroke:#333333;}#mermaid-svg-rgGGLbQfE5PmSrEs svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-rgGGLbQfE5PmSrEs p{margin:0;}#mermaid-svg-rgGGLbQfE5PmSrEs .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-rgGGLbQfE5PmSrEs .cluster-label text{fill:#333;}#mermaid-svg-rgGGLbQfE5PmSrEs .cluster-label span{color:#333;}#mermaid-svg-rgGGLbQfE5PmSrEs .cluster-label span p{background-color:transparent;}#mermaid-svg-rgGGLbQfE5PmSrEs .label text,#mermaid-svg-rgGGLbQfE5PmSrEs span{fill:#333;color:#333;}#mermaid-svg-rgGGLbQfE5PmSrEs .node rect,#mermaid-svg-rgGGLbQfE5PmSrEs .node circle,#mermaid-svg-rgGGLbQfE5PmSrEs .node ellipse,#mermaid-svg-rgGGLbQfE5PmSrEs .node polygon,#mermaid-svg-rgGGLbQfE5PmSrEs .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-rgGGLbQfE5PmSrEs .rough-node .label text,#mermaid-svg-rgGGLbQfE5PmSrEs .node .label text,#mermaid-svg-rgGGLbQfE5PmSrEs .image-shape .label,#mermaid-svg-rgGGLbQfE5PmSrEs .icon-shape .label{text-anchor:middle;}#mermaid-svg-rgGGLbQfE5PmSrEs .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-rgGGLbQfE5PmSrEs .rough-node .label,#mermaid-svg-rgGGLbQfE5PmSrEs .node .label,#mermaid-svg-rgGGLbQfE5PmSrEs .image-shape .label,#mermaid-svg-rgGGLbQfE5PmSrEs .icon-shape .label{text-align:center;}#mermaid-svg-rgGGLbQfE5PmSrEs .node.clickable{cursor:pointer;}#mermaid-svg-rgGGLbQfE5PmSrEs .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-rgGGLbQfE5PmSrEs .arrowheadPath{fill:#333333;}#mermaid-svg-rgGGLbQfE5PmSrEs .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-rgGGLbQfE5PmSrEs .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-rgGGLbQfE5PmSrEs .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-rgGGLbQfE5PmSrEs .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-rgGGLbQfE5PmSrEs .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-rgGGLbQfE5PmSrEs .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-rgGGLbQfE5PmSrEs .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-rgGGLbQfE5PmSrEs .cluster text{fill:#333;}#mermaid-svg-rgGGLbQfE5PmSrEs .cluster span{color:#333;}#mermaid-svg-rgGGLbQfE5PmSrEs div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-rgGGLbQfE5PmSrEs .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-rgGGLbQfE5PmSrEs rect.text{fill:none;stroke-width:0;}#mermaid-svg-rgGGLbQfE5PmSrEs .icon-shape,#mermaid-svg-rgGGLbQfE5PmSrEs .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-rgGGLbQfE5PmSrEs .icon-shape p,#mermaid-svg-rgGGLbQfE5PmSrEs .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-rgGGLbQfE5PmSrEs .icon-shape .label rect,#mermaid-svg-rgGGLbQfE5PmSrEs .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-rgGGLbQfE5PmSrEs .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-rgGGLbQfE5PmSrEs .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-rgGGLbQfE5PmSrEs :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 🔒 demux_internal(in->lock 保护)
🧵 Demux 线程(生产者)
🧵 主线程(消费者)
接管 demuxer → demux_start_thread()
demux_start_prefetch() 启动预读
add_packet_locked()
有新数据
唤醒主循环
demux_read_packet_async()
start_open() 创建 opener 线程
atomic_store(open_done) → 返回 demuxer
🧵 Opener 线程(短暂)
demux_open_url()
stream_create() + demux_open()
play_current_file()
open_demux_reentrant()
f_demux_in filter
f_decoder_wrapper
demux_thread()
read_packet() → desc->read_packet()
demux_lavf / demux_mkv / ...
demux_stream × N
demux_queue\[\] → packet 链表
wakeup_cb → 唤醒主循环

核心设计 :demux 线程是纯生产者(从文件/网络读 packet 塞队列),主线程是纯消费者(通过 filter 取 packet)。两者经由 demux_internal.lock + 条件变量 + wakeup 回调协同。中间的 opener 线程 是短暂的"桥接线程"------它执行耗时的网络 I/O 和格式探测,避免阻塞主线程;完成后通过 atomic_store(&open_done, true) + mp_wakeup_core 通知主线程,随后立即终止。opener 线程终止后才启动 demux 线程,两者无并发。

用一条时序图把三个线程的交接看清楚:
🧵 Demux 线程 🧵 Opener 线程 🧵 主线程 🧵 Demux 线程 🧵 Opener 线程 🧵 主线程 #mermaid-svg-wC1iV19XO2tUKszY{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-wC1iV19XO2tUKszY .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-wC1iV19XO2tUKszY .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-wC1iV19XO2tUKszY .error-icon{fill:#552222;}#mermaid-svg-wC1iV19XO2tUKszY .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-wC1iV19XO2tUKszY .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-wC1iV19XO2tUKszY .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-wC1iV19XO2tUKszY .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-wC1iV19XO2tUKszY .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-wC1iV19XO2tUKszY .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-wC1iV19XO2tUKszY .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-wC1iV19XO2tUKszY .marker{fill:#333333;stroke:#333333;}#mermaid-svg-wC1iV19XO2tUKszY .marker.cross{stroke:#333333;}#mermaid-svg-wC1iV19XO2tUKszY svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-wC1iV19XO2tUKszY p{margin:0;}#mermaid-svg-wC1iV19XO2tUKszY .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-wC1iV19XO2tUKszY text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-wC1iV19XO2tUKszY .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-wC1iV19XO2tUKszY .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-wC1iV19XO2tUKszY .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-wC1iV19XO2tUKszY .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-wC1iV19XO2tUKszY #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-wC1iV19XO2tUKszY .sequenceNumber{fill:white;}#mermaid-svg-wC1iV19XO2tUKszY #sequencenumber{fill:#333;}#mermaid-svg-wC1iV19XO2tUKszY #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-wC1iV19XO2tUKszY .messageText{fill:#333;stroke:none;}#mermaid-svg-wC1iV19XO2tUKszY .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-wC1iV19XO2tUKszY .labelText,#mermaid-svg-wC1iV19XO2tUKszY .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-wC1iV19XO2tUKszY .loopText,#mermaid-svg-wC1iV19XO2tUKszY .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-wC1iV19XO2tUKszY .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-wC1iV19XO2tUKszY .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-wC1iV19XO2tUKszY .noteText,#mermaid-svg-wC1iV19XO2tUKszY .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-wC1iV19XO2tUKszY .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-wC1iV19XO2tUKszY .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-wC1iV19XO2tUKszY .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-wC1iV19XO2tUKszY .actorPopupMenu{position:absolute;}#mermaid-svg-wC1iV19XO2tUKszY .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-wC1iV19XO2tUKszY .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-wC1iV19XO2tUKszY .actor-man circle,#mermaid-svg-wC1iV19XO2tUKszY line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-wC1iV19XO2tUKszY :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 阶段 0-2 并行:opener 干活,主线程 mp_idle(§1.3) 阶段 2.5 交接 阶段 3-5 主线程 阶段 6-7 并行 play_current_file() → start_open(url)mp_thread_create(open_demux_thread)while(!open_done) mp_idle()demux_open_url() → stream_create() → demux_open()open_given_type() × N(§2)atomic_store(&open_done, true) + mp_wakeup_core()MP_THREAD_RETURN() → 终止mpctx->>demuxer = open_res_demuxer → cancel_open()demux_start_thread()demuxer_select_track × 2 → 建 decoder/filter/AO/VOdemux_start_prefetch() --- reading=trueread_packet() → add_packet_locked()wakeup_cb() → mp_wakeup_core()demux_read_packet_async() → 解码 → 渲染

把七个阶段压成一张流水线表,作为后面两章的地图(每阶段的关键动作,§2/§3 会展开):

阶段 线程 执行函数 关键动作
0 主线程 start_open() 创建 opener 线程,进入 mp_idle 等待
1 Opener demux_open_urlstream_create 打开底层 I/O(fd / socket)
2 Opener demux_openopen_given_typedesc->open 格式探测 + 建流(§2)
2.5 Opener→主 atomic_store(&open_done) + mp_wakeup_core 交接,opener 终止
3 主线程 demux_start_thread demux 线程启动(reading=false → 休眠)
4 主线程 demuxer_select_track × 2 选轨 + 建 decoder/filter/AO/VO
5 主线程 demux_start_prefetch reading=true → 唤醒 demux 线程
6 Demux read_packetadd_packet_locked 生产 packet
7 主线程 run_playloop → 拉帧 → 解码渲染 第一帧画面

1.2 核心数据结构:一张图读懂

直接背结构体是低效的------先看这一张包含关系图,它比任何代码块都能说明问题(demuxer_t 是门面,demux_internal 才是引擎):

复制代码
demuxer_t(对外句柄,主线程视角)
  ├── desc       ★ 策略接口(demuxer_desc_t:open/read_packet/seek/close)
  ├── stream     ★ 底层 I/O
  ├── cancel     ★ 取消令牌
  ├── streams[] / duration / filetype / metadata   ← 探测结果
  └── in ──▶ demux_internal(真正的引擎)
               ├── d_thread / d_user   ★ 双指针(生产者/消费者,§2.2)
               ├── lock / wakeup / thread / reading
               ├── sh_stream[] ← 每条轨道的元信息(codec、分辨率...)
               │     └── ds (demux_stream) ← 消费状态(读头、EOF)
               │           └── queue ← 当前范围的 packet 链表
               ├── ranges[] ← seek 后可复用的缓存区间
               └── eof / min_secs / max_bytes / seeking ...

(完整字段见 demux/demux.h:224demux/demux.c:165demux/demux.c:379,这里只留本文会用到的。)

策略接口 demuxer_desc_t 值得单独看一眼------它是整个解封装层的多态核心(demux/demux.h:133):

c 复制代码
typedef struct demuxer_desc {
    const char *name;   // 如 "lavf", "matroska", "rawaudio"
    const char *desc;   // 如 "libavformat"
    int  (*open)(struct demuxer *demuxer, enum demux_check check);
    bool (*read_packet)(struct demuxer *demuxer, struct demux_packet **pkt);
    void (*seek)(struct demuxer *demuxer, double rel_seek_secs, int flags);
    void (*close)(struct demuxer *demuxer);
    void (*switched_tracks)(struct demuxer *demuxer);
} demuxer_desc_t;

1.3 mp_idle:网络加载时 UI 零冻结的秘密

三线程模型里最容易被低估的是"主线程等待时在做什么"。opener 线程在网络 I/O 上卡住时(可能数秒到数十秒),主线程并不是在 sleep------它在 open_demux_reentrant 的等待循环里跑一个微型事件循环(player/loadfile.c:1351):

c 复制代码
while (!atomic_load(&mpctx->open_done)) {
    mp_idle(mpctx);      // ← 不是 sleep!是一个微型事件循环
    if (mpctx->stop_play)
        mp_abort_playback_async(mpctx);
}

mp_idleplayer/playloop.c:1326)每轮迭代执行 11 个步骤:

c 复制代码
void mp_idle(struct MPContext *mpctx) {
    handle_dummy_ticks(mpctx);        // 1. 虚拟 tick
    handle_clipboard_updates(mpctx);  // 2. 剪贴板
    mp_wait_events(mpctx);            // 3. ★ 阻塞等待 → cond_timedwait
    mp_process_input(mpctx);          // 4. 处理键盘/鼠标输入(如按 q 退出)
    handle_option_callbacks(mpctx);   // 5. 选项变更回调
    handle_command_updates(mpctx);    // 6. 异步命令完成通知
    handle_update_cache(mpctx);       // 7. 缓存状态
    handle_cursor_autohide(mpctx);    // 8. 光标自动隐藏
    handle_vo_events(mpctx);          // 9. 窗口事件(resize/expose/close)
    update_osd_msg(mpctx);            // 10. OSD 消息
    handle_osd_redraw(mpctx);         // 11. OSD 重绘
}

mp_wait_events 内部是 mp_dispatch_queue_processpthread_cond_timedwait。Opener 线程完成时调用 mp_wakeup_coremp_dispatch_interrupt → 打断 cond_wait → 主线程被唤醒,检查 open_done 发现为 true,跳出循环。

如果没有 mp_idle 的这些步骤 ,在打开网络文件期间(可能耗时数秒到数十秒):窗口会冻结、按 q 无法退出、libmpv 客户端 API 全部阻塞。


2. 五层初始化链:从 URL 到解封装器匹配

play_current_file()desc->open() 返回成功,一共五层,全部发生在 opener 线程上(§1.1):

复制代码
① demux_open_url(url)      打开底层 I/O(stream_t)
② demux_open(s)            挑解封装器(check_levels × demuxer_list,§2.1)
③ open_given_type(desc)    分配 demuxer + d_thread/d_user 双指针(§2.2)
④ desc->open(d_thread)     多态:lavf → demux_open_lavf(§2.3)
⑤ demux_copy(d_user, d_thread)  单向同步,返回 d_user

2.1 探测调度:check_levels × demuxer_list 双层循环

第一层 demux_open_url 职责很单一:把 URL 变成 stream_t(stream 层),挂一个级联的取消令牌,然后全部交给 demux_opendemux/demux.c:3598)。stream_create 内部遍历 stream_list[] 做协议匹配。

第二层 demux_open 是"找对的人做对的事"的调度层(demux/demux.c:3511)。它不解析任何格式,只做一件事------双层循环,第一个成功就返回:

c 复制代码
// demux/demux.c:3511
static struct demuxer *demux_open(struct stream *stream, ...)
{
    const int *check_levels = d_normal;      // 默认: NORMAL → UNSAFE 两轮
    const struct demuxer_desc *check_desc = NULL;

    char *force_format = params ? params->force_format : NULL;
    if (force_format && force_format[0]) {
        check_levels = d_request;             // --demuxer=lavf → 只试 REQUEST 级
        if (force_format[0] == '+') {         // "+lavf" → FORCE 级
            force_format += 1;
            check_levels = d_force;
        }
        check_desc = find_desc_by_name(force_format);  // 只试这一个
    }

    // ★ 双层循环:外层安全门槛,内层能力注册表
    for (int pass = 0; check_levels[pass] != -1; pass++) {
        enum demux_check level = check_levels[pass];
        for (int n = 0; demuxer_list[n]; n++) {
            const struct demuxer_desc *desc = demuxer_list[n];
            if (!check_desc || desc == check_desc)
                demuxer = open_given_type(..., desc, stream, ..., level);
            if (demuxer) goto done;   // ★ 第一个成功就返回
        }
    }
done:
    return demuxer;
}

外层 check_levels 是一道安全门槛(数值越小越宽松,demux/demux.h:112):

等级 枚举值 语义 如何触发
FORCE 0 强制执行:跳过所有检测,直接创建 --demuxer=+lavf+ 前缀)
UNSAFE 1 模糊检测:允许弱特征匹配 默认第二轮(NORMAL 全失败后)
REQUEST 2 用户指定:信任用户对格式的判断 --demuxer=lavf
NORMAL 3 安全检测:严格文件头魔数匹配 默认第一轮

它防止 mpv 把 README.md 错误识别成媒体格式。

内层 demuxer_list 是能力注册表(demux/demux.c:72,12 个基础 + 2 个可选编译项):

c 复制代码
static const demuxer_desc_t *const demuxer_list[] = {
    &demuxer_desc_directory,   // 目录 → 自动播放列表
    &demuxer_desc_disc,        // 光盘(DVD/BD)
    &demuxer_desc_edl,         // EDL 播放列表
    &demuxer_desc_cue,         // CUE sheet
    &demuxer_desc_rawaudio,    // 裸音频流
    &demuxer_desc_rawvideo,    // 裸视频流
    &demuxer_desc_matroska,    // MKV / WebM(原生实现)
#if HAVE_LIBARCHIVE           // (可选编译项)
    &demuxer_desc_libarchive,
#endif
#if HAVE_SUBRANDR             // (可选编译项)
    &demuxer_desc_sbr,
#endif
    &demuxer_desc_lavf,        // ★ FFmpeg libavformat(300+ 格式)
    &demuxer_desc_mf,          // 图片序列
    &demuxer_desc_playlist,    // 播放列表(m3u/pls)
    &demuxer_desc_null,        // 空源
    &demuxer_desc_mpv,         // mpv 内部格式
    NULL
};

以本地 video.mp4 为例,执行轨迹是这样的:

复制代码
pass=0, level=NORMAL:
  directory → disc → edl → cue → rawaudio → rawvideo → matroska → lavf ✅
  (lavf_check_file 读 ftyp box → score=100 → 第 8 个命中)

前面 7 个 desc 返回 NO_MATCHlavf 兜底命中;两轮全失败才报错。为什么 mpv 不直接调 FFmpeg?因为解封装器不止 FFmpeg 一家------目录、光盘、播放列表、裸流、原生 MKV 都要共享同一套"探测 → 调度 → 线程包装 → 水位控制"框架。加新格式只需写一个新的 demuxer_desc_t 挂进 demuxer_list[] ,对比 GStreamer 的 GstElementFactory + GstPad 链 + 手动 buffer pool,接入成本更低。

2.2 线程安全的基石:d_thread/d_user 的拷贝与交接

这是亮眼的一处(demux/demux.c:3373)。第三层 open_given_type 的核心动作:

c 复制代码
static struct demuxer *open_given_type(..., const struct demuxer_desc *desc, ...) {
    // ① 分配 demuxer → 绑定策略接口
    struct demuxer *demuxer = talloc_ptrtype(NULL, demuxer);
    *demuxer = (struct demuxer) {
        .desc = desc,          // ★ 多态:desc->open 指向具体实现
        .stream = stream,      // ★ 底层 I/O
        .cancel = sinfo->cancel,
        ...
    };

    // ② ★★★ 创建 demux_internal + d_thread/d_user 双指针 ★★★
    struct demux_internal *in = demuxer->in = talloc_ptrtype(demuxer, in);
    *in = (struct demux_internal){
        .d_thread = talloc(demuxer, struct demuxer),  // ★ 生产者副本
        .d_user   = demuxer,                          // ★ 消费者即原对象
        ...
    };
    *in->d_thread = *demuxer;   // ★ 结构体值拷贝:内容相同,实例不同

    // ③ ★ 调用具体解封装器的 open() --- 多态关键(副作用全在 d_thread 上)
    int ret = demuxer->desc->open(in->d_thread, check);
    if (ret >= 0) {
        demux_init_cuesheet(in->d_thread);
        demux_copy(in->d_user, in->d_thread);   // ★ 单向同步
        return demuxer;   // ★ 返回的是 d_user(主线程视角)
    }
    // 失败 → demux_free 回收
}

用一张前后对比表看它的动作(表格按列自适应换行,窄屏也不串位):

❌ 如果没有 d_thread/d_user ✅ mpv 的实际做法
desc->open() 改的是 d_user(主线程可见的原对象) d_thread(一份副本)
副作用去哪了 永久留在主线程可见对象上 只落在副本上,主线程看不见
成功之后 无同步,状态已被污染 demux_copy(d_user, d_thread) 单向同步回来

为什么安全? 不是内存隔离,而是时序隔离:

  1. 初始化期间 :opener 独占 d_thread,主线程在 mp_idle 挂起(§1.3),没人碰 d_user
  2. demux_copy 之后 :opener 已终止,主线程接管 d_user------两者无并发;
  3. 运行期间 :demux 线程只碰 d_thread,主线程只碰 d_user(由 in->lock 协调)。

严谨一点*in->d_thread = *demuxer浅拷贝 ------streamcancel 等指针仍指向同一块内存。所以准确的说法是:

d_thread/d_user 实现的是访问职责隔离,而不是彻底的内存隔离。 它不是靠"两份独立数据"安全,而是靠"每一时刻只有一个人碰它"安全。

一个结构体拷贝 + 严格的生命周期交接,换掉了一把初始化锁。

2.3 策略接入:demux_lavf 对 FFmpeg I/O 与取消机制的接管

第四层是 desc->open 的多态落点------对 lavf 就是 demux_open_lavfdemux/demux_lavf.c:1310):

复制代码
demux_open_lavf(demuxer, check)
  ├─ lavf_check_file(demuxer, check)   // ★ 先探测格式(见 §3)
  ├─ avformat_alloc_context()           // 创建 AVFormatContext
  ├─ avio_alloc_context(..., mp_read, mp_seek) // ★ 替换 I/O
  ├─ avfc->interrupt_callback = interrupt_cb   // ★ 设置取消回调
  ├─ avformat_open_input(&avfc, ...)    // FFmpeg 解析容器头
  ├─ if (probeinfo) avformat_find_stream_info(avfc)  // §3.2
  ├─ add_new_streams(demuxer)           // AVStream[] → sh_stream[](§3.3)
  ├─ build_editions(demuxer)            // 章节 / 多版本
  └─ handle_stream_groups(demuxer)      // IAMF / tile grid 等分组

mpv 的 stream 层不能直接被 FFmpeg 用,avio_alloc_context 造三个回调把两者焊在一起(详细实现见上章):

回调 内部实现
mp_read(opaque, buf, size) stream_read_partial(((demuxer_t*)opaque)->priv->stream, buf, size)
mp_seek(opaque, pos, whence) stream_seek(...)
mp_read_seek(opaque, ...) stream_seek + stream_read_partial

取消回调:

c 复制代码
static int interrupt_cb(void *ctx) {
    return mp_cancel_test(((struct demuxer *)ctx)->cancel);
}

这是上一章的 payoff:I/O 的控制权在这一步从 FFmpeg 手里拿回了 mpv 手里 ------mp_read 走环形缓冲区(命中零系统调用),interrupt_cb 走事件驱动取消(按 q 不再靠 100ms 轮询)。第五层 demux_copy 再把探测结果单向同步回 d_user(§2.2)。

⚠️ 这里出现了 read_packet 生产 packet、demux_queue 队列和水位控制------那是运行时的事,暂时不展开。

2.4 一条链的四次交接

把 §1.1 的三线程和这一节连起来看,整条初始化链有四次跨线程交接,同步机制各不相同:

交接 生产者 消费者 同步机制
stream_t Opener(stream_create 主线程(通过 demuxer->stream Opener 终止后主线程才访问,无并发
demuxer_t Opener(open_given_type 主线程(open_res_demuxer → mpctx->demuxer atomic_store/load(&open_done) + Opener 终止
sh_stream[] Opener(desc->open 内部 demux_add_sh_stream 主线程(demux_copyd_user->streams demux_copyd_thread→d_user 单向同步
demux_packet Demux 线程(add_packet_locked 主线程(demux_read_packet_async in->lock mutex + in->wakeup cond + wakeup_cb

前三次都发生在 opener 线程终止前,靠"生命周期交接"无锁完成;第四次是运行期的事,才需要 in->lock


3. 秒开原理:从 MP4 容器特性到 skipinfo 优化

前两章解决了"找对 demuxer、安全初始化、接入 FFmpeg"。现在回答开头的问题:为什么 MP4 不用解码就知道一切参数?

3.1 FFmpeg 信息获取的三阶段模型

很多读者会把 av_probe_input_format2avformat_open_inputavformat_find_stream_info 混为一谈。它们拿到的信息完全不同:

复制代码
阶段                       返回值 / 填充的字段              I/O 量         获取的信息
──────────────────────────────────────────────────────────────────────────────────
av_probe_input_format2     AVInputFormat*                 前几 KB         "这是 MP4"(格式名 + score)
                           (priv->avif = &ff_mov_demuxer)                 仅格式名 + 置信度
──────────────────────────────────────────────────────────────────────────────────
avformat_open_input        AVFormatContext*                容器头部        codec_id, extradata(avcC/esds),
                           avfc->streams[].codecpar:                       宽高, 采样率, Profile, Level
                             .codec_id  = H264 / AAC                       + 帧索引(stco/stts/stsz)
                             .width/height = 1920/1080
                             .sample_rate = 48000
──────────────────────────────────────────────────────────────────────────────────
avformat_find_stream_info  同一个 avfc(原地修改!)         可能解码数帧    补充 codecpar 的空白字段:
                           avfc->streams[].codecpar:                        extradata(FLV/TS 缺)
                             .extradata ← 从解码帧提取 SPS/PPS              实际宽高、实际帧率

三个关键结论:

  1. priv->avif 只是指针,不是数据 。probe 只回答"用哪个工具解析"(&ff_mov_demuxer),工具本身不含 codec 参数,真正的解析发生在 avformat_open_input 里。
  2. find_stream_info 是原地修改 。它不创建新结构,而是填补 open_input 留下的空白字段(FLV 的 extradata、实际宽高、帧率等)。
  3. MP4/MKV 在阶段二就拿全了avcC/esds 里已编码全部 codec 参数,find_stream_info 即便运行,也会发现所有字段非空、直接返回。

表述收敛 :注意上面用的是"可能 解码数帧",不是"必然解码"------find_stream_info 会继续读取 packet 并根据格式补充 codecpar:现代 H.264/AAC FLV 直接从 Sequence Header 提取参数,不触发解码器;MPEG-TS / 老编码才需要真正送入 decoder 解码确认。所以它的代价主要是"额外读取",解码只是其中一种(更贵)的情形。

3.2 为什么 MP4/MKV 不需要 find_stream_info

lavf_check_filedemux/demux_lavf.c:424)在探测时顺带查一张静态表 format_hacks[]demux/demux_lavf.c:162),其中跟本文主线相关的有两行:

c 复制代码
{"mp4",      .skipinfo = true, .no_pcm_seek = true, .use_stream_ids = true},
{"matroska", .skipinfo = true, .no_pcm_seek = true, .use_stream_ids = true},

format_hacks 不只管 skipinfo,还承载各格式的兼容性策略------no_pcm_seekuse_stream_ids、HLS 的 no_stream、黑名单 BLACKLIST("tty") 等。本文只看和初始化性能直接相关的 skipinfo。)

决策发生在 demux_open_lavf 里、avformat_open_input 返回之后(demux/demux_lavf.c:1462):

c 复制代码
bool probeinfo = lavfdopts->probeinfo != 0;
switch (lavfdopts->probeinfo) {
case -2: probeinfo = priv->avfc->nb_streams == 0; break;  // 无流才探测
case -1: probeinfo = !priv->format_hack.skipinfo; break;   // ★ 默认: 听 format_hack 的
}
if (probeinfo)
    avformat_find_stream_info(avfc, NULL);

链路:format_hacks["mp4"].skipinfo=true → probeinfo=false → 跳过 avformat_find_stream_info()

为什么用静态表,而不是运行时检查 avfc 两个理由:

  1. 决策点更早skipinfolavf_check_file 阶段就定下了,那时 avfc 还没分配;
  2. "信息完整"很难定义extradata 非空 ≠ 信息完整,某些格式仍缺帧率、像素格式。哪些格式头部完整是已知且稳定的事实,一张编译时表比运行时试探可靠------本质是 mpv 开发者对 FFmpeg 各 demuxer 行为的经验编码。

哪些格式不能这么干 (头部不含完整 codec 参数,find_stream_info 得读 packet 甚至解码才能补全):

容器 头部是否含完整 codec 参数 find_stream_info 需要做什么
MP4 (moov) avcC/esds 已含分辨率、Profile、采样率 无需(skipinfo=true
MKV/WebM ✅ CodecPrivate 含完整 codec extradata 无需(skipinfo=true
FLV (H.264/AAC) ❌ 缺 codec extradata 读 Sequence Header 提取,不触发解码器
MPEG-TS ❌ PAT/PMT 仅 PID 和 stream_type 需读 + 解码数帧
HLS (m3u8) ❌ playlist 不含 codec 参数 需打开第一片 TS 并解码

唯一的例外是 AVIFdemux/demux_lavf.c:516):同为 ISOBMFF 家族,但 alpha 通道、HDR 元数据要解码后才能确定 → 覆盖为 skipinfo=false

收益量化

场景 skipinfo skipinfo
额外 I/O find_stream_info 可能数十次 av_read_frame
网络预读 ~moov 大小 moov + 数帧数据
首帧延迟 仅 open 耗时 弱网下可能 +2~10s

表述收敛 :这里省的是"find_stream_info 带来的额外 读取"------不是 MP4 打开完全没有 I/O。stream_read_peek 探测、avformat_open_input 读 moov 本身就有大量 I/O;只是"不再额外做那一次探测阶段的读取"。


3.3 MP4 实例走读:从 moov 到 sh_stream 的零额外 I/O 路径

抽象讲完了,落回一个具体的 video.mp4(H.264 + AAC),看 MP4 是怎么在阶段二把参数拿全的。

3.3.1 moov:一张自包含的"目录"

video.mp4 的典型布局 (fast-start):

复制代码
┌──────────────────────────────────────────┐
│ ftyp box          │ 文件类型标识 "mp42"    │  ← stream_read_peek 读这里(§3.1 阶段一)
├──────────────────────────────────────────┤
│ moov box          │ ★ 元数据容器           │
│  ├─ mvhd          │   全局时长、时间标尺     │
│  ├─ trak (视频)    │
│  │  └─ mdia/minf/stbl/
│  │      ├─ stsd/avcC  │ ★ H.264 SPS+PPS  │  ← 分辨率、Profile 全在这
│  │      ├─ stts        │ 帧→时间映射       │
│  │      ├─ stss        │ 关键帧索引        │
│  │      ├─ stsz        │ 每帧大小          │
│  │      └─ stco/co64   │ 每帧在 mdat 的偏移  │
│  └─ trak (音频)    │
│     └─ mdia/minf/stbl/
│          └─ stsd/esds │ ★ AAC AudioSpecificConfig │
├──────────────────────────────────────────┤
│ mdat box          │ 实际的 H.264/AAC 压缩数据  │
└──────────────────────────────────────────┘

关键点moov 是一个自包含的"目录"。只要读到 moov(fast-start 下通常在文件头 1-2MB 内),就拿到了宽、高、Profile、Level、采样率、所有帧的字节偏移(stco)和时间戳(stts)------这正是 find_stream_info 要解码数帧才能拿到的信息,MP4 把它写在头部了。

3.3.2 avformat_open_input 内部:FFmpeg mov demuxer 的 box 解析

demux_open_lavf() 调用 avformat_open_input(&avfc, "video.mp4", mov_demuxer, ...) 时,FFmpeg 的 mov demuxer(libavformat/mov.c)做 box 级解析:

复制代码
avformat_open_input()
  │
  ├─ ① avio_read(pb, 8) → mp_read → stream_read_partial
  │      读取 ftyp box header → "ftyp" + size → 确认是 ISOBMFF
  │
  ├─ ② mov_read_default() 递归解析 box 树
  │      ├─ mov_read_mvhd() → avfc->duration = mvhd->duration / timescale
  │      ├─ mov_read_trak() × 2 (视频 + 音频)
  │      │    └─ mov_read_stsd()  ★ 最关键的一步
  │      │         ├─ 视频轨: 读到 avcC box
  │      │         │   → codecpar->codec_id = H264, extradata = avcC
  │      │         │   → codecpar->width = 1920, height = 1080
  │      │         │   → codecpar->profile = 100 (High), level = 40 (4.0)
  │      │         │   ★ FFmpeg 解析 SPS 自动填充这些字段
  │      │         └─ 音频轨: 读到 esds box
  │      │             → codecpar->codec_id = AAC, extradata = ASC (2 字节)
  │      │             → codecpar->sample_rate = 48000, ch_layout = 2
  │      │         ├─ mov_read_stts/stss/stsz/stco → 帧→PTS、关键帧、大小、偏移
  │      │    └─ mov_read_udta() → metadata (title, artist, ...)
  │      └─ mov_read_mdat() → 记录位置,暂不读内容(读 frame 时才解析)
  │
  └─ ③ 返回成功,avfc->nb_streams = 2

这段解析期间的全部 I/O :只读了 moov box。fast-start 下 moov 紧跟在 ftyp 之后,一次顺序读取即可全部读入;环形缓冲区(02a 的 128KB buffer)可能只触发一次 fill_bufferread(fd)

3.3.3 avcC 里到底有什么

avcC box 的结构(ISO 14496-15):

复制代码
avcC box:
├── AVCProfileIndication = 100     → High Profile
├── AVCLevelIndication   = 40      → Level 4.0
├── lengthSizeMinusOne   = 3       → NAL 长度字段 4 字节
├── numOfSequenceParameterSets = 1
│   └── SPS NAL unit data:
│        ├─ profile_idc         = 100  → High
│        ├─ level_idc           = 40   → 4.0
│        ├─ pic_width_in_mbs    = 119  → (119+1)×16 = 1920
│        └─ pic_height_in_map_units = 67 → (67+1)×16 = 1088 → crop 到 1080
└── numOfPictureParameterSets = 1

FFmpeg 的 ff_h264_decode_seq_parameter_set() 在解析 extradata 时自动从 SPS 提取width = (pic_width_in_mbs+1)*16 - cropheightprofilelevel------全部在 avformat_open_input 阶段零额外 I/O 完成。

(SPS 的位级展开------(119+1)×16=1920(67+1)×16=1088 再 crop 到 1080,本文不展开。)

3.3.4 但 moov-at-end 怎么办?

注意:skipinfo 解决不了 moov 不在文件头的问题。

如果 MP4 的 moov 位于文件末尾(录制工具逐帧写入后最后写 moov),avformat_open_input() 本身就要先 seek 到文件尾读 moov

复制代码
ftyp → mdat(3GB) → moov
  avio_seek(pb, mdat_end) → 对 HTTP 发 Range 请求
  服务器不支持 Range → 只能等 3GB 传完 → 打开极慢
  → 读 moov → seek 回 mdat 起点

所以网络 MP4 的启动速度,首先取决于容器布局(fast-start),其次才是是否跳过 find_stream_info 。这正是 -movflags +faststart 存在的意义。

3.3.5 AVStream.codecpar → sh_stream 字段映射

avformat_open_input 返回后,demux_open_lavfadd_new_streams()demux/demux_lavf.c:895),把数据从 FFmpeg 结构拷到 mpv 结构:

AVStream.codecpar sh_stream.codec 例(video.mp4)
codec_id codec(名称) "h264" / "aac"
width / height disp_w / disp_h 1920 / 1080
sample_rate samplerate 48000
extradata / extradata_size extradata avcC / esds 原始字节
avg_frame_rate(来自 stts) fps 24.0
codec_tag codec_tag avc1

随后 demux_copy(d_user, d_thread) 把这份 sh_stream[] 单向同步到主线程可见的 demuxer_t(§2.2)------主线程拿到第一条 sh_stream 时,初始化就算完成了。

至此,MP4 的初始化全部完成lavf_check_file 读 ftyp 前几十字节 → avformat_open_input 读 moov(~100KB-1MB)→ add_new_streams 零 I/O 建 sh_stream → find_stream_info 被跳过 。唯一的风险是损坏的 MP4(avcC 和实际流不一致,极罕见),可用 --demuxer-lavf-probe-info=yes 强制启用验证。


4. 动手验证与设计总结

4.1 命令行日志追踪与验证

调试路线(正文不再散落断点框,统一在此):

复制代码
open_demux_thread (player/loadfile.c:1217)     ← 线程名 "opener"
  ↓ demux_open          (demux/demux.c:3511)    ← §2.1 双层循环
  ↓ open_given_type     (demux/demux.c:3373)    ← §2.2 d_thread/d_user
  ↓ demux_open_lavf     (demux/demux_lavf.c:1310) ← §2.3 FFmpeg 接入
  ↓ if (probeinfo)      (demux/demux_lavf.c:1469) ← §3.2 skipinfo 决策
  ↓ add_new_streams     (demux/demux_lavf.c:895)  ← §3.3 AVStream[] → sh_stream[]
  ↓ demux_start_thread  (demux/demux.c:1203)    ← 就绪后

实验 1 → 验证 demuxer_list[] 的匹配过程(对应 §2.1):

bash 复制代码
mpv --msg-level=demux=trace video.mp4 2>&1 | grep "Trying demuxer\|Detected file format"

你会看到:

复制代码
[demux] Trying demuxers for level=normal.
[demux] Trying demuxer: directory (force-level: normal)
[demux] Trying demuxer: disc (force-level: normal)
[demux] Trying demuxer: edl (force-level: normal)
[demux] Trying demuxer: cue (force-level: normal)
[demux] Trying demuxer: rawaudio (force-level: normal)
[demux] Trying demuxer: rawvideo (force-level: normal)
[demux] Trying demuxer: mkv (force-level: normal)
[demux] Trying demuxer: lavf (force-level: normal)   ← ★ 第 8 个命中!
[demux] Detected file format: mov,mp4,m4a,... (libavformat)

这就是 §2.1 双层循环的真实执行轨迹------前面 7 个 desc 返回 NO_MATCHlavf 第 8 个命中。Detected file format 这行告诉你 FFmpeg 内部的 probe 结果。如果改用 --demuxer=lavf,输出会变成 force-level: request 且只试 lavf 一个。

实验 2 → 验证 skipinfofind_stream_info 的影响(对应 §3.2):

bash 复制代码
time mpv --vo=null --ao=null --start=2 --length=1 video.mp4
time mpv --vo=null --ao=null --start=2 --length=1 --demuxer-lavf-probe-info=yes video.mp4

本地 SSD 小文件差距在几毫秒内;网络播放下强制启用 find_stream_info 会多读数帧(弱网下可能 +数秒延迟)。

注意:find_stream_info 是 FFmpeg 内部函数调用,不出现在 mpv/FFmpeg 日志里,无法用 grep"看到"它是否被跳过。skipinfo 的证据在源码 demux/demux_lavf.c:1462,不在日志。想直接观察,可以在 demux/demux_lavf.c:1469if (probeinfo) 设断点,对比 video.mp4(跳过)和 video.flv(执行)的 stream_tell() 差异。

4.2 架构哲学:用生命周期交接与拷贝替代加锁

回看全文,mpv 在"打开"这个动作上有三个价值点:

  1. 策略模式是骨架 (§2.1):demuxer_desc_t 的多态 open() 让 14 种解封装器共享同一套"探测 → 调度 → 线程包装 → 水位控制"框架;
  2. d_thread/d_user 是线程安全的基石(§2.2):一个结构体拷贝 + 严格的生命周期交接,换掉了一把初始化锁;
  3. skipinfo 是秒开的引擎 (§3.2):用一张编译时静态表,把"哪些容器头部信息完整"这个已知事实编码下来,砍掉 find_stream_info 的额外读取。

如果带走一个设计,那就是:不是所有线程安全问题都需要加锁 ------严格的生命周期交接 + 状态复制(d_thread/d_user),同样可以成为同步手段。什么时候该加锁、什么时候用交接,判据只有一条:共享状态的生命周期窗口是否真的错开了。

这种"用拷贝替代加锁"的哲学在 mpv 里不止出现这一次------mp_frame 在 filter 图里流转也是同样的思路。mpv 的每一个"省",都不是省在实现技巧上,而是省在"想清楚了谁在什么时刻碰什么数据"------想清楚之后,锁自然就少了。


📌 源码锚点:demux/demux.c:3511 (demux_open), demux/demux.c:3373 (open_given_type), demux/demux.c:3598 (demux_open_url), demux/demux.h:133 (demuxer_desc_t), demux/demux_lavf.c:1310 (demux_open_lavf), demux/demux_lavf.c:424 (lavf_check_file), demux/demux_lavf.c:162 (format_hacks), demux/demux_lavf.c:1462 (probeinfo), demux/demux_lavf.c:895 (add_new_streams), player/loadfile.c:1217 (open_demux_thread), player/loadfile.c:1351 (open_demux_reentrant 等待循环), player/playloop.c:1326 (mp_idle)

相关推荐
Dovis(誓平步青云)29 分钟前
同一条路线在手机和平板上怎样换一种排版
android·开发语言·数据库·智能手机·音视频
OPEN-F34 分钟前
C++进阶教程:文件流与字符串流
开发语言·c++·算法
Android打工人1 小时前
c++ 、java 融为一体2,c++ 实例化Android虚拟机直接运行java,c,java 一个进程号
android·java·c++
jimy11 小时前
std::move(a)本质是把a转换成右值引用类型
开发语言·c++
01_ice1 小时前
c++类和对象(中)
c++·算法
码匠许师傅1 小时前
【设计模式精讲】4.单例模式(Singleton)
c++·单例模式·设计模式
深念Y1 小时前
视频平台架构重构:从微服务到云原生
服务器·微服务·云原生·重构·架构·音视频·短视频
wind100321 小时前
VC++ 运行库 vcredist 安装失败|CLR 80131522 报错完整修复
开发语言·c++·游戏
OPEN-F8 小时前
C++进阶教程:继承与多态
开发语言·c++