974 字
5 分钟
从 sendfile 到 io_uring:零拷贝的边界

“用 sendfile 就能零拷贝”是我见过最容易被过度承诺的一句话。它确实省掉了用户态和内核态之间的一次数据复制,但一条完整的发送路径上还有别的拷贝,而且 TLS 一介入,前面的优化往往全部失效。

普通写路径发生了什么#

write(fd, buf, n) 处理文件到 socket 的传输时,典型路径是:

  1. 内核把文件页读进 page cache(如果还没在);
  2. CPU 把数据从 page cache 复制到 socket 发送缓冲区;
  3. 网卡 DMA 把发送缓冲区搬走。

第 2 步是 write 路径里最贵的一次拷贝。sendfile(out_fd, in_fd, ...) 把这一步省掉,让内核直接把 page cache 里的页描述符挂到 socket 上,网卡 DMA 时再读。用户态缓冲区完全不参与。

off_t offset = 0;
while (offset < file_size) {
ssize_t n = ::sendfile(sock_fd, file_fd, &offset, file_size - offset);
if (n < 0) {
if (errno == EAGAIN) { /* 等可写 */ continue; }
break;
}
}

注意 sendfile 只对“文件到 socket”有意义。从 socket 到文件用 splice,两个 socket 之间的转发也可以用 splice 借助管道完成。

零拷贝会在哪里断掉#

TLS#

TLS 记录层必须对明文加密,密文只能由用户态或者内核 TLS 模块生成。普通 sendfile 看到的是文件字节,没法直接加密。所以一旦走 TLS,要么在用户态读出来加密,要么使用 kTLS 把加密下沉到内核。前者意味着至少一次用户态拷贝,后者需要网卡和内核支持,而且并非所有发行版都默认开启。

协议封装#

HTTP 响应要加头部,chunked 编码要给每块加长度前缀,WebSocket 要加帧头。这些都需要在数据前面拼字节。sendfile 只能发送一整段文件,不能顺手加头。常见做法是用 writev 把头部和数据拼成两个 iovec,这又回到了“数据在用户态”的路径。

分片与重传#

TCP 的发送缓冲区并不是最终形态,拥塞窗口、MSS 分片、重传都会在内核里重新组织数据。MSG_ZEROCOPY 把用户页直接交给内核,但要通过错误队列确认哪些页已经发完,复杂度和可移植性都要掂量。

io_uring 改变的是什么#

io_uring 不是“更快的 sendfile”,它改变的是提交和完成的方式:批量提交、减少系统调用、支持注册缓冲区。真正和零拷贝相关的是 IORING_OP_SEND_ZC 与注册缓冲区:

  • 注册缓冲区让内核提前锁定用户页,省掉每次提交时的 pin;
  • SEND_ZC 在完成事件里带一个通知,告诉你缓冲区什么时候可以复用;
  • 批量提交把多次 syscall 合并成一次 enter。

但它并没有消除“数据必须在某个地方被复制”的物理事实。省掉的是 syscall、上下文切换和一部分内存带宽。

怎么判断值不值得#

先量三个数:单次发送的字节数、每秒发送次数、以及 CPU 在 copy_user 相关符号上的占比。如果单次只有几 KB、次数又高,syscall 和锁的开销可能比拷贝本身更值得优化;如果是大文件下载,sendfile 的收益通常很明显。

Terminal window
perf top -p $(pgrep -n myserver)
# 关注 _copy_from_user / copy_user_enhanced_fast_string / tcp_sendmsg

如果 tcp_sendmsg 占比高,说明数据在用户态和内核态之间来回;如果 copy_user 占比高,才轮到考虑零拷贝。

结论#

零拷贝是一条有条件的最短路径:文件到 socket、无 TLS、无协议封装时最有效。一旦中间需要读、改、加密或者分片,拷贝就会以另一种形式回来。先确认瓶颈是不是拷贝,再决定用哪个 API,比背下所有“零拷贝”名词有用。

从 sendfile 到 io_uring:零拷贝的边界
/posts/cpp-zero-copy/
作者
fantasy-peak
发布于
2026-08-30
许可协议
CC BY-NC-SA 4.0