Skip to content

sendfile 零拷贝

零拷贝(Zero-Copy)的目标是消除数据在传输路径上的冗余内存拷贝。传统的数据传输路径——从磁盘文件到网络 socket——每个字节要经历四次拷贝:磁盘 DMA → 内核页缓存 → 用户态缓冲区(read 系统调用)→ 内核 socket 缓冲区(write 系统调用)→ 网卡 DMA。其中两次"内核 ↔ 用户态"的拷贝是纯粹的开销——数据只是路过用户态,用户程序通常并不修改它。零拷贝技术的本质是让数据不经过用户态,直接从内核的一处流转到另一处。

sendfile:文件到 socket 的直接通道

sendfile 是零拷贝最经典的实现,专门优化"读文件然后发给网络"这个高频场景(静态文件服务器、CDN 边缘节点)。

c
// 传统方式:read + write,四次拷贝
char buf[4096];
while ((n = read(fd, buf, sizeof(buf))) > 0)
    write(sockfd, buf, n);

// sendfile:文件到 socket,内核内完成
sendfile(sockfd, fd, &offset, filesize);

sendfile 的签名是"把 fd 的数据发送到 sockfd"——数据从页缓存直接进入 socket 的发送路径,完全不经过用户态。它要求数据源必须是支持 mmap 的文件(页缓存中的页面),目标必须是 socket——方向固定,不能反过来"网络收包写文件"(那是 splice 的领域)。

实现上,sendfile 的历史版本经历了从"内核内拷贝"到"真正零拷贝"的演进。早期实现(2.4 内核)在内核内做了一次页缓存 → socket 缓冲区的拷贝——省掉了用户态往返但不省内核内拷贝。现代实现(2.6+)配合网卡支持的 scatter-gather DMA,把页缓存的页面直接作为网卡 DMA 的描述符——数据从磁盘 DMA 到内存后,连内核内的拷贝也省掉了,CPU 只参与"组装 DMA 描述符"和"更新文件位置"。真正的数据搬运全部由 DMA 引擎完成。

splice:任意两端之间的零拷贝管道

sendfile 的方向限制(文件 → socket)在 splice 中被解除。splice 在两个文件描述符之间移动数据,其中至少一端必须是管道:

c
// 文件 → 管道
splice(file_fd, &off, pipefd[1], NULL, size, SPLICE_F_MOVE);
// 管道 → socket
splice(pipefd[0], NULL, sockfd, NULL, size, SPLICE_F_MOVE);

splice 的机制核心是页面引用传递而非数据拷贝。管道在内核中的缓冲区是页面的引用(struct pipe_buffer 持有 page 指针和偏移)——splice 往管道"写"数据时,实际是把源文件的页缓存页面引用挂到管道上,没有任何数据搬移;splice 从管道"读"到 socket 时,把页面引用从管道移交到 socket 的发送队列。数据页面本身在整个过程中一动不动。

这解释了 splice 的两个约束。一是为什么至少一端必须是管道——页面引用传递需要管道作为"页面的中转站"(没有其他内核对象支持这种语义)。二是为什么源端可能要求 SPLICE_F_MOVE 标志——页面引用移交要求源页面在移交后不再被修改,如果源文件被并发写入,引用语义失效,内核会回退到拷贝模式。理解"引用传递 vs 数据拷贝"的差异,是理解零拷贝性能收益边界的关键——数据要经过修改(压缩、加密、过滤)时,引用传递不可用,必须退化为拷贝。

mmap:另一种"零拷贝"思路

mmap 把文件映射到进程地址空间——访问映射区域的内存直接操作页缓存页面,省掉了 read 的"内核 → 用户态"拷贝。但它不是真正的零拷贝:进程通过内存访问数据(CPU 参与每一字节的读写),而 sendfile 的路径中 CPU 只设置 DMA 描述符。

mmap 在两种场景有优势:一是进程需要随机访问文件内容(sendfile 只能顺序流式),二是进程需要长时间反复使用同一份数据(映射一次,后续访问都是内存操作)。代价是 mmap 的页错误处理开销(首次访问每个页面触发一次缺页中断)和映射管理复杂度(文件截断时的 SIGBUS)。对"读文件 → 发网络"的流式场景,sendfile 在性能和简洁性上都完胜 mmap。

零拷贝的工程边界

零拷贝技术的收益前提是"数据不需要被处理"。静态文件服务器(Nginx 的 sendfile on)是完美场景——数据从磁盘原样到网络。但一旦中间有逻辑——TLS 加密(数据必须经 CPU 变换)、压缩、HTTP 头部注入、内容过滤——数据就必须经过用户态或内核态的变换步骤,sendfile 不可用。Nginx 处理 HTTPS 时默认关闭 sendfile 正是这个原因:TLS 加密要求数据经过加密模块,零拷贝路径被打破。

工程上判断是否能用零拷贝的问题清单:数据源是文件吗?目标是 socket 吗?数据需要被修改或检查吗?需要统计传输进度或做限速吗(sendfile 无用户态介入,进度统计受限)?答案全是"是"才考虑 sendfile;中间有变换就用传统 read/write 或 mmap 方案。

零拷贝与 io_uring 的关系值得一提:io_uring 优化的是"系统调用次数",零拷贝优化的是"数据拷贝次数"——两者正交且可以叠加。io_uring 的 send 操作可以携带 IOSQE_ASYNC 等标志配合注册缓冲区实现部分零拷贝,但完整的内核内数据直通(如 io_uring 的 splice 支持)仍在演进中。