Linux基础 -- 零拷贝之 splice
背景:
**`file_operations` 中的 `.splice_read` / `.splice_write`** 讲透是什么?
VFS 怎么调用?
内部“零拷贝”是如何通过 **pipe buffer** 完成的?
实现者应如何选用通用 helper,以及典型易错点与性能要点。
一、它们到底干什么(用户态视角 → 内核回调)
用户态有三兄弟系统调用:splice(2) / vmsplice(2) / tee(2)。核心目标:在内核里搬运数据,不经用户态缓冲复制。规则是:
-
splice(fd_in, …, fd_out, …, len, flags):至少一端必须是管道。- 非管道→管道:VFS 走
in->f_op->splice_read()(把“文件/套接字等”的数据送进管道)。 - 管道→非管道:VFS 走
out->f_op->splice_write()(把管道里的数据写到“文件/套接字等”)。 - 管道→管道:走内核的 pipe-to-pipe 快速路径(克隆/转移 pipe buffer 引用)。
- 非管道→管道:VFS 走
-
vmsplice:把用户页直接挂到管道(把用户页 pin 住,形成 pipe buffer)。 -
tee:在不复制底层页的情况下,把一端管道的 buffer 克隆到另一端管道(增加引用计数)。
因此 .splice_read / .splice_write 就是 “非管道端” 参与 splice 的两个钩子:
ssize_t (*splice_read)(struct file *in, loff_t *ppos,struct pipe_inode_info *pipe, size_t len,unsigned int flags);ssize_t (*splice_write)(struct pipe_inode_info *pipe, struct file *out,loff_t *ppos, size_t len, unsigned int flags);
- 返回实际搬运的字节数;推进
*ppos;遵循SPLICE_F_*标志(如NONBLOCK/MOVE/MORE/GIFT)。
二、设计思想:用“页引用的搬运”替代“数据复制”
核心是 pipe buffer 机制。管道里不是“字节流数组”,而是若干 struct pipe_buffer 单元,每个单元引用一块底层存储(页、页片段、skb 片段、设备私有描述等),并配有一套 ops 回调 指明如何 confirm / release / steal 这块数据。
零拷贝的达成方式:
- 从“非管道的输入对象”读取时(
.splice_read):
尽量直接把其底层页“借”给管道(把页或片段塞进管道 buffer),而不是复制到临时内核缓冲再写入。 - 向“非管道的输出对象”写入时(
.splice_write):
由输出对象尽量直接消费管道里的页引用(如 TCP 发送路径直接把页挂进 send queue),避免再次 copy。
若无法纯零拷贝(例如文件必须经页缓存压缩/解密等),仍可通过 页缓存页 → 作为 pipe buffer 的方式把“复制次数”最小化。
三、VFS 调用流程(高层)
-
用户调
splice()→ 内核do_splice():- 若
fd_in是管道:走do_splice_from(pipe → out)→ 调out->f_op->splice_write()。 - 若
fd_out是管道:走do_splice_to(in → pipe)→ 调in->f_op->splice_read()。 - 两端都是管道:用 pipe-to-pipe 专用路径克隆 buffer。
- 若
-
阻塞/非阻塞:看
SPLICE_F_NONBLOCK;管道满/空时可能-EAGAIN。 -
SPLICE_F_MOVE:尽量“移动”页所有权,不再保留原副本(能不能做到取决于两端实现)。 -
SPLICE_F_MORE:hint,后续还有数据(例如 TCP 可据此优化聚包/拥塞)。 -
SPLICE_F_GIFT:把页“赠与”对端,由对端负责释放(常见于vmsplice)。
四、文件系统/设备如何实现(选通用 helper)
很多对象不需要自己从零写 .splice_*,而是复用内核 helper:
- 普通文件(页缓存)读取 → 管道:
用generic_file_splice_read()(老接口)或后继等价实现(有些版本在mm/filemap.c/fs/splice.c内)。
它会从 page cache 拉页(必要时触发 read-in),把这些页作为 pipe buffer 塞进管道。 - 管道 → 普通文件写入:
用iter_file_splice_write()(内部走vfs_iter_write()),把ITER_PIPE迭代器里的数据喂给底层->write_iter,常能零拷贝消费。 - 套接字(TCP/UDP):
网络栈自己实现.splice_read/.splice_write,常走“零拷贝接收/发送”路径(如直接把 pipe buffer 变成内核 skb payload 或反之),最大限度地避免 copy。 - 字符/块设备:
如果驱动能导出页引用(DMA-able 页、bio 片段等),可定制自己的 pipe buffer ops 达到零拷贝;否则可退化为把数据 copy 进/出管道。
注意:若未实现
.splice_*,VFS 可能回退到基于read_iter/write_iter的通用 splice 实现(开销更大,但语义不变)。
五、内部关键细节(实现视角)
- 管道容量与反压:管道有固定 buffer 槽位数与总字节限制。写满则生产者睡眠;读空则消费者睡眠;
NONBLOCK则立刻-EAGAIN。 - pipe_buffer ops:每个 buffer 带有一组操作(如
pipe_buf_ops),定义了如何 pin/unpin 页、能否“偷页”(move/steal)、如何确认有效性(confirm)。零拷贝能否达成、能到什么程度,取决于这些 ops。 - 位置推进:VFS 负责维护并传递
*ppos;实现者务必在成功返回的字节数上推进它(或遵循 DIO 的位置语义)。 - 页生命周期:谁持有引用、何时 put,严格由 pipe buffer 的 ops/引用计数管理,避免过早释放或泄漏。
- 安全与一致性:若数据需要解密/校验/解压,通常发生在“源端→管道”这一边(
.splice_read);“管道→落地”的.splice_write便可零拷贝直落,提高吞吐。 - 大页/Folio:新内核以
folio为单位组合页;对 splice 的抽象不变,只是底层更高效地管理页组。
六、典型路径对比(例)
-
文件 → 管道(
splice_read)- 计算文件偏移对应的缓存页;2) 缺页读入;3) 将该页封装成
pipe_buffer(引用页,不复制数据);4) 更新*ppos与返回字节数。
- 计算文件偏移对应的缓存页;2) 缺页读入;3) 将该页封装成
-
管道 → TCP 套接字(
splice_write)- 从管道取出若干
pipe_buffer;2) 直接把其底层页挂进 skb(或通过sendpage/sendmsg零拷贝路径);3) 根据MORE/拥塞窗口决定批量提交;4) 发送并释放引用。
- 从管道取出若干
七、常见坑位与建议
- 必须处理
NONBLOCK:管道没空间/对端没就绪时要返回-EAGAIN,不能睡。 - 返回值语义:允许短写/短读;推进
*ppos与返回字节严格一致。 - 对齐/边界:块设备/直写路径可能要求对齐;不满足时要么退化 copy,要么报错。
- 页“移动”不是强制:
SPLICE_F_MOVE只是尽力尝试;做不到要优雅退化。 - 内存压力:长链路零拷贝也会消耗大量页引用;注意 backpressure,避免 OOM/泄漏。
- 加密/压缩顺序:通常 读端解密/解压 → 进管道 → 写端零拷贝落地;相反次序会导致不可零拷贝或安全风险。
八、实现者的“开箱即用”清单
-
普通文件:
- 读端:直接用
generic_file_splice_read(); - 写端:用
iter_file_splice_write()(要求实现好->write_iter)。
- 读端:直接用
-
设备/协议端:
- 能导出/接收页引用→定制 pipe_buf_operations 以实现真零拷贝;
- 否则先接入通用 helper,待性能稳定后再优化。
总结
`.splice_read` / `.splice_write` 是 VFS 为 `splice(2)` 提供的“**非管道端零拷贝**”钩子。
设计思想是**以管道为中心**,通过 **pipe buffer 对页的“引用转移/克隆”** 来搬运数据,避免用户态往返复制;
两侧对象只需实现“如何把自己的数据变成/消费成 pipe buffer”,其余由 VFS/pipe 框架负责队列、背压与生命周期管理。
