【免费下载链接】ffmpeg-over-ip
Connect to remote ffmpeg servers
ffmpeg-over-ip 是一个让"无GPU的媒体服务器"远程调用"带GPU机器"上 ffmpeg 做转码的工具,其核心秘密藏在 fio 隧道层——一个约 2000 行的纯 C 模块。它通过拦截 ffmpeg 的 10 个 POSIX 文件操作(open、read、write、lseek、close、fstat、ftruncate、unlink、rename、mkdir),把每一次文件读写都封装成二进制消息,顺着一条 TCP 连接送回客户端执行。媒体文件因此永远不落地到服务器磁盘,也不需要 NFS/SMB 共享文件系统。🔌
为什么要给 ffmpeg 打一个"文件隧道"?
ffmpeg-over-ip 的典型部署是这样的:
| 角色 | 机器特点 | 负责的事 |
|---|---|---|
| 客户端 | 有媒体文件,没有 GPU | 假装自己是 ffmpeg,实际执行所有真实文件 I/O |
| 服务端 | 有 GPU(NVENC/QSV/VAAPI 等) | 运行打过补丁的 ffmpeg 做转码 |
问题在于:转码的 ffmpeg 运行在服务端,但输入/输出文件都在客户端。如果走传统方案,就得挂载 NFS/SMB 或做 PCIe 直通。fio 隧道层换了一种思路:
让 ffmpeg "以为"文件就在本地——它调用的
open()/read()其实被替换成了fio_open()/fio_read(),后者把请求发到网络上,由客户端去读真正的磁盘,再把数据发回来。
整个机制由三部分组成:
- 补丁:修改 ffmpeg 的
libavformat/file.c,把 10 个 POSIX 调用替换为fio_*等价函数,见 002-file-protocol.patch - C 隧道层:fio/fio.c(2036 行)+ fio/fio.h(43 行接口),负责消息编解码、虚拟文件描述符、读预取
- Go 协议层:internal/protocol/wire.go 定义两端共用的消息常量与信封格式
一个环境变量决定"隧道"还是"直通"
fio 层最巧妙的设计是零侵入:它是否生效,完全由环境变量FFOIP_PORT决定。
FFOIP_PORT未设置:所有fio_*函数直接透传给真实 POSIX 系统调用(passthrough 模式)FFOIP_PORT已设置:连接127.0.0.1:<端口>,进入隧道模式
这个连接建立逻辑在 fio.c 的 fio_init(延迟初始化,pthread_once保证只跑一次)。服务端 Go 进程在启动 ffmpeg 子进程时注入该环境变量,见 internal/process/process.go:
cmd.Env = append(cmd.Environ(), fmt.Sprintf("FFOIP_PORT=%d", port))好处很实际:同一份 ffmpeg 二进制,在没有FFOIP_PORT的环境里就是普通 ffmpeg;连接失败或端口非法时也会优雅降级为直通模式(fio.c#L1359-L1406)。🛡️
极简的二进制协议:1 字节类型 + 4 字节长度
fio 层与 Go 侧共用同一套协议常量。C 端定义在 fio.c#L26-L47,Go 端镜像在 wire.go#L37-L63:
| 消息 | C 常量 | 十六进制 | 对应操作 |
|---|---|---|---|
| 打开文件 | FIO_MSG_OPEN / MsgOpen | 0x20 | fio_open |
| 读取 | FIO_MSG_READ / MsgRead | 0x21 | fio_read |
| 写入 | FIO_MSG_WRITE / MsgWrite | 0x22 | fio_write |
| 定位 | FIO_MSG_SEEK / MsgSeek | 0x23 | fio_lseek |
| 关闭 | FIO_MSG_CLOSE / MsgClose | 0x24 | fio_close |
| 状态 | FIO_MSG_FSTAT / MsgFstat | 0x25 | fio_fstat |
| 截断 | FIO_MSG_FTRUNCATE / MsgFtruncate | 0x26 | fio_ftruncate |
| 删除 | FIO_MSG_UNLINK / MsgUnlink | 0x27 | fio_unlink |
| 重命名 | FIO_MSG_RENAME / MsgRename | 0x28 | fio_rename |
| 建目录 | FIO_MSG_MKDIR / MsgMkdir | 0x29 | fio_mkdir |
请求消息占用0x20–0x29,响应消息(*Ok,0x40–0x49)与之一一对应,外加一个统一的错误响应FIO_MSG_IO_ERROR(0x4F)。
每个消息都是一个信封(envelope),结构只有 5 字节头部:type(1) + payload_len(4, 大端) + payload,编解码函数见 encode_envelope / decode_envelope。
值得注意的是协议刻意做了平台无关化:
- open 的 flags 被映射成"canonical"值(如
FIO_O_CREAT 0x0040、FIO_O_TRUNC 0x0200,fio.c#L49-L54),因为 Linux 和 Windows 的O_CREAT数值不同 - 错误码也做了归一化,只保留 10 个与 Linux 编号一致的 errno(errno_to_wire / errno_from_wire),例如
ENOENT=2、EACCES=13 - 全部整数按大端手工拼字节(put_u16/put_u32/put_u64),不依赖
htonl,Windows 下同样工作
虚拟文件描述符表:从 10000 开始的 fd
ffmpeg 拿到的是一个整数文件描述符。fio 层返回的 fd 一律从FIO_VFD_BASE = 10000起分配(fio.h#L20-L21),最多 256 个(FIO_MAX_FILES)。
为什么要用这么大的数字?因为小于 10000 的 fd 一定是真实的内核 fd——比如 ffmpeg 的pipe:协议复制 stdin/stdout、fd:协议直接使用传入的 fd。这些必须走真实系统调用。判断逻辑就一行(is_real_fd):
static inline int is_real_fd(int fd) { return fd >= 0 && fd < FIO_VFD_BASE; }这是补丁开发中踩过的坑:早期版本会把所有低于FIO_VFD_BASE的 fd 一律拒绝,导致 ffmpeg 的 stdin/stdout 转发全部报EBADF,后来改为直通真实 syscall 修复(见 CHANGELOG.md 的 "real kernel fds" 记录)。
每个虚拟 fd(fio_vfd_t结构,fio.c#L150-L168)维护两套偏移量:
logical_offset:ffmpeg 视角的逻辑读位置remote_offset:远端文件指针的真实位置
两者不一致时,下一次 I/O 前会自动发一次 SEEK 对齐(vfd_sync_remote_offset)。这就是"本地 seek 尽量免费、远端 seek 尽量少发"的基础。
单读线程 + 64 个挂起槽位:异步但不复杂
并发模型非常朴素,只用 pthread,没有任何依赖库:
- 1 个 reader 线程(reader_thread_func):死循环读取网络信封,按
req_id把响应存进挂起槽位(pending slot),然后广播条件变量 - 64 个挂起槽位(
FIO_MAX_PENDING):每个正在飞行的请求占一个槽,响应到达后等在那里的调用方被唤醒 - 发送互斥锁:所有请求串行发送,
req_id单调递增(16 位)
核心原语是 send_and_wait:send_async发送请求并立刻返回槽位索引,wait_pending_response在该槽上pthread_cond_wait。对 ffmpeg 来说这是同步阻塞调用,但对 fio 层而言,响应可以被另一个请求抢先到达——这为"异步预取"留出了空间(下一节)。🎯
性能是 fio 层的重头戏:读预取 + 两级缓存 + 异步预读
如果每次read()都要一次网络往返,转码会慢得无法使用。fio 层用三层策略把往返次数压到最低,所有阈值集中在 fio.c#L84-L93:
1. 自适应读预取(read-ahead)
ffmpeg 的 AVIO 层常按 32 KiB 小块拉取数据。fio 层在fio_read里发现实际请求量小于预取量时,会多读:
#define FIO_INITIAL_READAHEAD_BYTES (512 * 1024) /* 初始 512 KiB */ #define FIO_SMALL_FILE_READAHEAD_BYTES (1280 * 1024) /* 小文件上限 1.25 MiB */ #define FIO_DEFAULT_READAHEAD_BYTES (2 * 1024 * 1024) /* 默认 2 MiB */ #define FIO_MAX_READAHEAD_BYTES (16 * 1024 * 1024) /* 上限 16 MiB */预取量从 512 KiB 起步,遇到顺序读就倍增(vfd_grow_read_ahead),上限 2 MiB;超过 1 GiB 的大文件放开到 16 MiB;小于 1 GiB 的小文件则压到 1.25 MiB 以内,避免白读。📈
2. 活动缓存 + 范围缓存
多读回来的数据不能丢,fio 层做了两级缓存:
- 活动读缓存(
read_cache):最近一次读响应整块持有,后续小read()直接从内存memcpy(vfd_copy_from_cache),零网络开销 - 范围缓存(
range_cache链表):seek 走远端后,活动缓存降级成范围缓存中的一块,上限 256 MiB(FIO_RANGE_CACHE_MAX_BYTES),只保留只读且未写脏、大小 ≤256 MiB 的文件(vfd_should_retain_read_cache)
于是 seek 到读过的区间(转码场景里重试、回读很常见)可以直接命中缓存,不发网络请求——fio_lseek会先查vfd_cache_contains,命中就只改logical_offset(fio.c#L1772-L1776)。
3. 异步预读(prefetch)
这是最"后台"的一招:当读到缓存末尾附近时,vfd_maybe_start_prefetch 会不等待地把下一个块的 READ 请求发出去,挂在prefetch_slot上。下一次fio_read到来时,若位置正好衔接,直接 vfd_take_prefetch_as_cache 把它变成缓存——往返延迟被完全隐藏在计算时间里。小文件在缓存读到 50% 位置就触发预读(FIO_SMALL_FILE_PREFETCH_PERCENT),大文件则到缓存边界才触发。
写路径:保守而直接
写操作没有任何预取:fio_write先取消预取、作废全部读缓存、把预取量复位,再同步远端偏移,然后一发一等(fio.c#L1665-L1724),写成功后置dirty标记。fio_fstat在脏状态下才真正上网查询,平时直接返回缓存的文件大小(fio.c#L1829-L1887)。
这两个旋钮都可以用环境变量调优(解析逻辑在 fio_init):
FFOIP_READAHEAD_BYTES:预取字节数,封顶 16 MiB,设为 0 关闭FFOIP_RANGE_CACHE_BYTES:范围缓存上限,封顶 256 MiB
补丁如何"神不知鬼不觉"地替换 10 个调用?
补丁 002-file-protocol.patch 只改一个文件:libavformat/file.c。替换关系一览:
| ffmpeg 原调用 | 替换为 | 补丁中的位置 |
|---|---|---|
read(c->fd, ...) | fio_read(c->fd, ...) | 002-file-protocol.patch#L17-L18 |
write(c->fd, ...) | fio_write(c->fd, ...) | 002-file-protocol.patch#L26-L27 |
close(c->fd) | fio_close(c->fd) | 002-file-protocol.patch#L57-L58 |
lseek(c->fd, ...) | fio_lseek(c->fd, ...) | 002-file-protocol.patch#L71-L72 |
fstat(c->fd, ...) | fio_fstat(c->fd, ...) | 002-file-protocol.patch#L66-L67 |
avpriv_open(...) | fio_open(...) | 002-file-protocol.patch#L117-L118 |
rename(...) | fio_rename(...) | 002-file-protocol.patch#L108-L109 |
unlink/rmdir | fio_unlink(...) | 002-file-protocol.patch#L93 |
mkdir | fio_mkdir(...) | 由 004-mkdir.patch 补充 |
补丁里还有不少标着// fio-safe注释的调用刻意不替换:fd:协议用的继承 fd、Android content 协议的 fstat、file_check的 access 检查等,都明确保留真实 syscall(002-file-protocol.patch#L36-L49)。这种"选择性替换"正是 2000 行 C 代码之外最容易被忽视的工程细节。
测试怎么信这 2000 行 C 代码?
fio 层配有 2220 行的单元测试 fio/fio_test.c,用 socketpair 伪造一条"网络",让 mock 服务线程在内存里驱动整个隧道路径(测试注入钩子见 fio_test_set_tunnel),覆盖:
- 信封/各请求类型的编解码往返
- 直通模式(
FFOIP_PORT未设置时原样透传,fio_test.c#L646-L652) - 读预取倍增、范围缓存命中、seek 缓存命中等性能路径
构建与验证入口在 fio/Makefile:
cd fio && make test它会依次跑普通单测、带 ASan+UBSan 的单测(Apple clang 上自动降级为 UBSan,原因写在 Makefile#L5-L17 的注释里)、clang 静态分析,跨平台还有 wire 协议一致性测试 tests/wire/run.sh 和集成脚本 tests/integration/test-fio-copy.sh、tests/integration/test-fio-ops.sh。仓库甚至有一个静态扫描脚本 tests/static-analysis/scan-raw-syscalls.sh 检查补丁文件里有没有漏网的裸 syscall。🧪
小结:2000 行 C 换来的架构自由
回看 fio 隧道层的设计取舍,可以总结成几句话:
- 零依赖:只用 pthread 和 socket,编译进 ffmpeg 不引入任何新库
- 零感知:ffmpeg 源码只改一个文件的 10 个调用点,且无
FFOIP_PORT时行为与原版完全一致 - 性能优先:512 KiB 起步的倍增预取、两级缓存、异步预读,把"每次 read 一次 RTT"变成"每几 MiB 一次 RTT"
- 文件不落盘:所有 I/O 都发生在客户端本地磁盘,服务端 GPU 机器上不留任何媒体文件
配合 docs/ 下的配置与排障文档(如 docs/troubleshooting.md),以及 README.md 中的架构示意,fio 层就是"一条 TCP 端口替代 GPU 直通 + 共享文件系统"这句话的技术底座。想深入源码,建议的阅读顺序是:fio.h(接口)→ 002-file-protocol.patch(替换点)→ wire.go(协议)→ fio.c(实现,按 A–K 字母分区组织,注释里标得清清楚楚)。
【免费下载链接】ffmpeg-over-ip
Connect to remote ffmpeg servers
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考