目录

Linux-32 io_uring 与零拷贝:少陷入一次,少复制一次

目录

上一篇讲 epoll 如何告诉应用“哪个 fd 现在可以读写”。这一篇继续沿着 IO 路径往下走:如果连每次提交 IO 都要一次系统调用,数据还要在用户态和内核态来回复制,能不能把这两部分固定成本也省掉?

答案不是一句“io_uring 就是异步 IO、sendfile 就是零拷贝”那么简单。它们分别优化两条不同的路径:

控制面成本:应用如何提交/收取 IO 请求?
  read() / write() / epoll_wait() -> 系统调用、参数检查、上下文切换

数据面成本:字节在 CPU 和内存之间搬了几次?
  磁盘 -> 内核缓冲 -> 用户 buffer -> socket buffer -> 网卡

io_uring 主要优化控制面;sendfilesplice 等主要优化数据面。遇到 TLS、压缩、协议解析或慢下游时,所谓“零拷贝”还会退回普通路径或只是减少其中一段复制。

先看五个问题:

  1. read/write 每次系统调用到底贵在哪里?io_uring 的 SQ/CQ 如何把提交和完成批量化?
  2. SQE/CQE 为什么要用共享环形队列?用户态和内核如何避免同时改同一个 head/tail?
  3. SQPOLL、固定 buffer、注册 fd 分别省掉哪部分成本?什么时候反而浪费 CPU?
  4. sendfilesplicecopy_file_range 各自连接什么对象、减少哪一次复制?
  5. Go 的 io.Copy 什么时候走 sendfile?TLS、限速、ReaderFromWriterTo 为什么会改变路径?

1. 一次普通 IO 的成本

1.1 read/write 不只是“调用一个函数”

以磁盘文件读入 socket 为例,朴素代码通常是:

char buf[128 * 1024];
ssize_t n = read(filefd, buf, sizeof buf);
ssize_t m = write(sock, buf, n);

数据大致经历:

磁盘 / page cache
       |
       | copy_to_user
       v
用户态 buf
       |
       | copy_from_user
       v
socket send buffer
       |
       v
TCP / NIC DMA

如果文件已经在页缓存里,磁盘不需要物理 IO,但 CPU 仍要执行两次跨边界复制,并为每个 syscall 做:

  • 从用户态切入内核态、再返回
  • 验证 fd、地址、长度和权限
  • 查找 file/socket 对象
  • 更新偏移、队列和引用计数
  • 处理阻塞、信号、cgroup 和审计路径

小 IO 高频调用时,固定成本可能比真正搬运的字节还大;大 IO 时,内存带宽和 cache 污染又会成为主成本。

1.2 批量是减少固定成本的第一步

即使不用 io_uring,也可以通过批量减少调用次数:

1000 次 write(4KB)
  -> 1000 次 syscall、1000 次参数检查、1000 次调度入口

1 次 writev(1000 个 iovec)
  -> 1 次 syscall,内核批量处理多个 buffer

readv/writev(scatter/gather)减少的是 syscall 次数和用户 buffer 拼接;它不一定消除内核与用户之间的复制。sendmmsg/recvmmsg 也把多个消息打包提交,适合 UDP 等场景。

io_uring 进一步把“准备请求”和“进入内核”拆开,让应用可以在用户态连续填写多个请求,再以一次或少量 syscall 通知内核。

1.3 非阻塞 + epoll 解决了什么,没解决什么

上一篇的 epoll 把“等待哪个 fd 就绪”变得高效:

epoll_wait() -> 返回 fd 已就绪
read(fd)     -> 仍要一次 syscall
write(fd)    -> 仍要一次 syscall

epoll 是 readiness 模型:应用得到通知后主动执行 IO。它减少了空 fd 扫描,却没有把具体读写请求提交给内核,更没有替应用搬运数据。高并发事件循环常见的 syscall 来源仍是:

  • accept/recv/send
  • epoll_wait/epoll_ctl
  • timerfd/eventfd
  • 文件 read/write/fsync

io_uring 试图让提交、等待和完成通知都变成共享队列操作。


2. io_uring:提交队列与完成队列

2.1 两个环形队列

用户通过 io_uring_setup() 创建 ring,再用 mmap() 把内核管理的区域映射到用户态。概念上有两个队列:

应用线程                         内核

SQ(Submission Queue)
  SQE 0: READV fd=3, buf=A  ---> 读取 SQE,提交到 IO 子系统
  SQE 1: SEND fd=8, buf=B   --->

CQ(Completion Queue)
  <--- CQE 0: user_data=A, res=4096
  <--- CQE 1: user_data=B, res=-EPIPE
  • SQE(submission queue entry):应用填写的请求描述
  • SQ(submission queue):指向待提交 SQE 的索引环
  • CQE(completion queue entry):内核写回的完成结果
  • CQ(completion queue):应用消费完成事件的索引环

SQE 里可以放 opcode、fd、buffer、长度、offset、flags 和 user_datauser_data 不由内核解释,应用可以放请求对象指针、ID 或 generation,用于把异步完成对应回自己的状态机。

2.2 一次批量提交

伪代码流程:

struct io_uring ring;
io_uring_queue_init(256, &ring, 0);

for (int i = 0; i < batch; i++) {
    struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
    io_uring_prep_read(sqe, fd[i], buf[i], len[i], off[i]);
    io_uring_sqe_set_data64(sqe, request_id[i]);
}

io_uring_submit(&ring);       // 可一次提交多个 SQE

while (need_more_completions) {
    struct io_uring_cqe *cqe;
    io_uring_wait_cqe(&ring, &cqe);
    handle(cqe->user_data, cqe->res, cqe->flags);
    io_uring_cqe_seen(&ring, cqe);
}

无论使用 liburing 还是直接 syscall,核心思想都一样:应用先在共享内存写请求,提交时批量通知内核;完成结果也批量放回共享内存,应用不必为每个结果单独 syscall。

2.3 共享 ring 如何避免双方踩 head/tail

SQ 和 CQ 不是“用户态和内核同时随便写一个数组”。它们有生产者/消费者边界:

SQ:
应用生产 SQE / 更新 tail
内核消费 SQE / 更新 head

CQ:
内核生产 CQE / 更新 tail
应用消费 CQE / 更新 head

每一方只修改自己拥有的索引,另一方只读取;在发布 tail/head 前后需要内存序,确保“内容先写完,再让消费者看见索引”。可以把它看成单生产者/单消费者环形队列的变体。

应用:
  填 SQE
  store-release(SQ tail)

内核:
  load-acquire(SQ tail)
  读取已发布 SQE
  执行 IO
  填 CQE
  store-release(CQ tail)

应用:
  load-acquire(CQ tail)
  读取完整 CQE

实际 ring 还有 SQ array、CQ overflow、并发提交和内核版本差异;不要手写同步协议,优先使用 liburing 或语言绑定。

2.4 io_uring 不等于每个请求都不进内核

默认模式下,应用仍要调用 io_uring_enter() 告知内核有新 SQE,或等待 CQE。它减少的是:

  • 多个请求合并为一次 enter
  • SQE/CQE 参数和结果通过映射内存传递
  • 完成结果不必逐个 syscall 返回

如果每次只提交一个请求、每次都同步等待,一个 io_uring 程序可能和普通 syscall 差别不大,甚至因 ring 管理增加开销。


3. io_uring 的提交与等待模式

3.1 io_uring_enter 的两个角色

一次 io_uring_enter 可以用于:

提交:告诉内核 SQ 中有 N 个新请求
等待:要求至少有 M 个 CQE 后再返回
提交 + 等待:批量提交后直接等完成

应用可以让一个线程生产 SQE,另一个线程消费 CQE;也可以单线程批量提交和处理。并发场景要自己保证 SQE 不被重复使用、请求对象生命周期覆盖整个异步操作。

3.2 SQPOLL:让内核线程替你观察 SQ

启用 IORING_SETUP_SQPOLL 后,内核创建一个 polling thread,持续查看 SQ。应用只要把 SQE 写入共享 ring 并更新 tail,内核线程就能发现并提交;部分情况下可以减少应用线程调用 io_uring_enter 的次数。

普通:
应用填 SQE -> enter syscall -> 内核读取 SQ

SQPOLL:
应用填 SQE -> 更新 tail -> 内核 polling thread 自动读取

代价也很直接:

  • polling thread 持续占用 CPU,即使没有 IO
  • NUMA/CPU affinity 不合适会增加 cache 传递
  • 权限、idle/非 idle 行为和内核版本需要验证
  • 低延迟、高 IO 速率才可能摊平轮询成本

“少 syscall”不等于“少 CPU”。空闲服务启用 SQPOLL 可能是反优化。

3.3 IOPOLL:设备完成也主动轮询

IORING_SETUP_IOPOLL 主要面向支持轮询完成的块设备(常见是特定 NVMe/O_DIRECT 路径),应用/内核不依赖普通中断等待完成。它可能降低中断延迟,但会持续占用 CPU,并有严格的文件系统/设备条件。

SQPOLL 是轮询提交队列;IOPOLL 是轮询设备完成,两者不要混淆。

3.4 注册 fd、buffer 与 personality

默认每个请求都可能需要引用/验证 fd、pin 用户页、准备 IO 上下文。io_uring 支持预注册资源:

register files:预先登记一组 fd
register buffers:预先登记固定 buffer

请求使用 index -> 内核少做重复查找/锁定

固定 buffer 需要 pin 住物理页,消耗内存和 RLIMIT_MEMLOCK 等资源;注册 fd 会让关闭/替换生命周期更复杂。它适合长期运行、高吞吐、固定连接/缓冲池,不适合每个短请求都注册注销。

3.5 共享 ring 的生命周期

ring 的 mmap 区、SQE、buffer、user_data 指向的请求对象都必须活到内核完成 CQE。错误的释放顺序可能造成:

应用释放 request / buffer
  -> 内核稍后完成 IO
  -> 写入已复用的地址或读取旧 buffer
  -> 数据损坏 / use-after-free

异步 API 的第一安全规则是:提交成功不等于使用结束,CQE 才是资源可回收边界。 取消请求也不代表已经完成;要等待取消的 CQE/最终完成,并设计请求 generation 防止迟到结果作用到新对象。


4. io_uring 的操作模型与错误边界

4.1 一个 ring 可以提交的不只是 read/write

常见 opcode 包括:

类别 典型操作 意义
文件 READ/WRITE/READV/WRITEV 普通或 vectored 文件 IO
网络 ACCEPT/CONNECT/RECV/SEND 提交 socket 操作
readiness POLL_ADD/POLL_REMOVE 监听 fd 可读写等状态
时间 TIMEOUT/TIMEOUT_REMOVE completion 驱动的超时
控制 ASYNC_CANCEL 尝试取消已提交请求
同步 FSYNC 提交文件同步
零拷贝相关 SPLICE/SEND_ZC 依内核/协议能力优化数据路径

这让一个系统可把 accept、recv、send、timeout、文件 IO 的完成统一放在 CQ 中。统一不等于简单:每个 opcode 的成功长度、-errno、取消和链接语义都不同。

4.2 CQE 的 res 不是布尔值

CQE 的 res 常见含义:

res >= 0:成功的返回值
  READ/RECV:实际读到的字节数,0 通常是 EOF
  WRITE/SEND:实际写入的字节数,可能短写
  ACCEPT:新的 fd
  FSYNC:通常 0 表示成功

res < 0:负 errno
  -EAGAIN、-ECANCELED、-ETIME、-EPIPE、-EBADF ...

不能把“有 CQE”理解为“请求成功”,也不能把一次成功 write 理解为全部 buffer 已发送。异步 API 仍要求正确处理 partial IO、EOF、超时与重试,区别只是错误在 CQE 中返回,而不是 syscall 返回。

io_uring 可把多个 SQE 链接:

READ file -> WRITE socket -> FSYNC file

普通 link 中,前一请求失败时后续请求可能被取消;hardlink 可改变失败传播策略。它有助于表达部分依赖,但不自动让多请求成为 ACID 事务:

  • 已执行的 write 不会因为后续失败自动回滚
  • 网络 send 无法撤回已发字节
  • 文件系统持久化顺序仍要按具体操作和错误处理验证

ASYNC_CANCEL 也是请求,不是即时打断按钮。目标可能已完成、正在设备中、无法取消或竞争完成;应用必须处理“原请求完成”和“取消请求完成”以任一顺序到达。

4.4 multishot:一个 SQE 对应多个 CQE

某些操作支持 multishot,例如接收多个连接/数据报或持续 poll:提交一次 SQE,随后可得到多个 CQE,直到最后一个 CQE 不再带 IORING_CQE_F_MORE

1 个 multishot ACCEPT SQE
  -> CQE: fd=101, F_MORE
  -> CQE: fd=102, F_MORE
  -> CQE: fd=103, no F_MORE  (结束)

它减少重复提交,但把生命周期变成“最后 CQE 才能回收 SQE 的关联状态”。每个 CQE 都要检查 flags,不要按一请求一结果的旧思维写状态机。

4.5 安全历史提醒

io_uring 的灵活性意味着内核暴露了大量复杂异步对象交互。早期内核版本曾出现多项严重安全漏洞,部分容器平台因此限制或禁用非特权 io_uring_setup。这不是“io_uring 天生不安全”,而是提醒:

  • 使用受支持、及时更新的内核
  • 在容器/多租户环境确认 seccomp、LSM、系统策略是否允许
  • 不要为绕过安全策略而寻找替代入口
  • 只授予应用需要的 fd、buffer 和资源上限

性能接口也属于攻击面;生产启用前要经过内核版本、权限模型、资源限制和故障测试评估。


5. 零拷贝:先问“哪一次复制没了”

5.1 四种常被混在一起的“零拷贝”

① DMA:设备与内核页之间直接搬运
   磁盘/NIC DMA 通常本来就不经过 CPU copy

② 减少 user <-> kernel copy:
   sendfile/splice 让页缓存页直接进入 socket 路径

③ scatter/gather:
   writev/sendmsg 避免应用先拼成连续大 buffer

④ NIC offload / MSG_ZEROCOPY:
   尽量避免内核把用户 buffer 复制到发送页,但需要等完成通知

它们优化的位置、约束和生命周期完全不同。说“零拷贝”时必须回答:源在哪里、目的在哪里、少的是 CPU copy、syscall,还是 DMA?

5.2 普通文件发送的两次 CPU copy

file page cache
  | copy_to_user
  v
user buffer
  | copy_from_user
  v
socket send buffer
  | NIC DMA
  v
network

sendfile(out_socket, in_file, ...) 让内核直接把文件页缓存页引用/映射进 socket 发送路径:

file page cache
  | page reference / pipe-like transfer
  v
socket/TCP send path
  | NIC DMA
  v
network

通常省掉的是 file page cache 到用户 buffer、用户 buffer 到 socket buffer 的两次 CPU copy;它不是磁盘直接把数据“穿过 CPU”送到网卡,也不保证所有内核、NIC、协议都走完全相同的页引用路径。

5.3 sendfile 的边界

Linux sendfile 最常见组合是普通文件 → socket。它适合静态文件下载、对象存储代理的未变换内容等。它不适合或会失去优势的情况:

  • 应用必须修改字节:压缩、转码、替换、协议封装
  • TLS 在用户态加密:发送前必须取得明文并加密成密文
  • 源不是受支持的 regular file
  • 需要复杂跨平台行为

内核 TLS(kTLS)可以让部分 TLS 发送加密留在内核,从而重新打开某些零拷贝机会,但 cipher、内核、库和协议路径都有条件,不能只因启用了 TLS 就假定 sendfile 仍有效。


6. splice、tee 与 copy_file_range

6.1 splice:在 fd 之间搬页引用

splice() 以 pipe 为中介,在支持的 fd 之间移动数据:

file -> pipe -> socket
socket -> pipe -> file

pipe 在这里不是让数据进入用户态的 read/write 缓冲,而是内核中一组 pipe buffer/page 引用。常见用法:

splice(filefd, NULL, pipefd[1], NULL, len, 0);
splice(pipefd[0], NULL, sockfd, NULL, len, 0);

它可以构建代理、转发和内核内数据管道;但 API 更复杂,要处理 pipe 容量、短传输、阻塞/nonblocking、异常和可移植性。

6.2 tee:复制引用,不复制页面

tee() 在两个 pipe 之间复制 pipe buffer 的引用:

input pipe --tee--> audit pipe
     |
     +-----------> main pipe

它适合一份流同时送多个消费者的内核管道场景。数据页不会因为 tee 被立即复制,但每个消费者仍必须消费引用,慢消费者会制造背压和内存占用。

6.3 copy_file_range:文件到文件的内核复制

copy_file_range() 针对文件到文件的复制:

copy_file_range(srcfd, &src_off, dstfd, &dst_off, len, 0);

同一文件系统上,它可能利用 extent clone/reflink 或内核内部 copy,避免用户态 buffer;跨文件系统、特殊文件或某些内核版本可能返回错误、退化或语义有限。应用必须处理短复制与 fallback read/write,不能把它当跨文件系统永远零拷贝的保证。

cp --reflink=auto 和 Copy-on-Write 文件系统上的克隆还可能做到“元数据级复制”:新文件先共享 extent,写时才复制。这比数据搬运更少,但语义依赖文件系统,不是 copy_file_range 的通用必然结果。

6.4 sendfile、splice、copy_file_range 对比

API 常见方向 用户态数据 buffer 典型用途
sendfile file → socket 不需要 静态文件网络发送
splice fd ↔ pipe ↔ fd 不需要 代理、流转发
tee pipe → pipe 不需要 一份流分支
copy_file_range file → file 不需要 文件复制/克隆优化
read/write 任意支持 fd 需要 通用 fallback、需要变换

“无需用户 buffer”不等于无任何内核复制;数据路径依设备、文件系统、socket、协议和内核版本决定。正确做法是将它们看作减少拷贝机会的专用 API


7. MSG_ZEROCOPY:用户 buffer 的生命周期交换

7.1 它省的是什么

普通 send() 要把应用用户 buffer 复制到内核 socket buffer,调用返回后应用即可复用原 buffer。MSG_ZEROCOPY 尝试让内核直接引用用户页面,由 NIC DMA 发送,避免那次 copy。

代价是:调用返回不代表内核已不再使用 buffer。应用必须从 socket error queue 接收完成通知,确认对应范围已释放后才能复用/释放 buffer。

sendmsg(MSG_ZEROCOPY) returns
  -> 用户 buffer 仍被内核/NIC 引用
  -> 稍后从 error queue 得到 completion
  -> 此时才能归还 buffer 给内存池

7.2 不是小包优化

注册/锁定页、completion、error queue 和复杂生命周期有固定成本。小消息通常 copy 更快;它更适合大块、高吞吐发送,并需要基准验证。内核也可能在某些情况下退回 copy 路径,但仍会产生相关 completion 语义。

MSG_ZEROCOPYsendfile 解决的源不同:前者源是用户 buffer,后者源是文件页缓存。两者都不代表“网络端到端没有复制”。


8. TLS、压缩与协议变换:零拷贝的硬边界

8.1 一旦改字节,就必须有新字节

文件原文 ----> TLS encrypt ----> 密文
          或 -> gzip ---------> 压缩后的新字节
          或 -> JSON rewrite -> 新字节

若应用必须读出内容、检查、修改并产生不同输出,就不可能把原文件页原封不动挂到 socket。至少在变换点需要 CPU 处理和新的输出 buffer。

这并不是 zero-copy “失效”,而是数据语义改变了;没有任何内核 API 能省去生成新字节这个事实。

8.2 kTLS 不是一个开关

kTLS 把部分 record encryption 移到内核,可能让应用在 TLS 连接上使用更多内核发送优化。但它依赖:

  • TLS 版本、cipher 和库支持
  • send/record 边界和错误处理
  • 内核版本与 NIC offload 能力
  • renegotiation、key update、协议功能等限制

很多 HTTP/TLS 栈仍以用户态 TLS 为主。启用 kTLS 前应先用 profile 证明 CPU copy/加密是热点,再验证正确性、可观测性和真实吞吐,不要把“静态文件 + HTTPS”自动等同“sendfile 零拷贝”。

8.3 缓存、压缩和网络三者的取舍

静态资源服务常有三种路径:

未压缩文件 -> sendfile                 少 CPU copy,网络字节多
预压缩文件 -> sendfile                 少 CPU,网络字节少,需要存多份
动态压缩   -> read + compress + write  CPU 高,可能节省带宽

哪条更好取决于 CPU、网卡、文件命中率、客户端能力、TLS 和 CDN。先选正确缓存/压缩策略,再看是否值得为最后两次 copy 引入专用路径。


9. Go 的 io.Copy:接口简单,路径按类型分派

9.1 首先看 WriterTo/ReaderFrom

io.Copy(dst, src) 不保证永远走一个内部循环。它会优先尝试:

src 实现 io.WriterTo
  -> src.WriteTo(dst)

否则 dst 实现 io.ReaderFrom
  -> dst.ReadFrom(src)

否则 -> 通用 Read buffer -> Write 循环

因此调用点看起来相同,实际路径由源/目的的动态类型决定。包装一层 bufio.Reader、限速 reader、hash reader、gzip writer 或 metrics wrapper,都可能改变接口实现和优化路径。

9.2 什么时候可能走 sendfile

在 Linux 上,Go 标准库的 *os.File*net.TCPConn 等组合会尽量利用平台支持的高效路径,其中可能包括 sendfile。典型候选是:

io.Copy(tcpConn, file)

但它取决于 Go 版本、源/目的具体类型、fd 状态、文件类型和错误 fallback。以下常让路径退化为用户态 copy:

  • 目的不是普通 TCP connection
  • HTTPS:crypto/tls.Conn 需要用户态加密
  • 中间有 gzip、限速、hash、内容修改等 wrapper
  • 源不是普通文件,或平台/API 不支持
  • 发生短传输、错误或特殊选项时 fallback

不能通过“用了 io.Copy”就断言使用了 sendfile。要用 tracing、性能测试或查看对应 Go 版本源码验证。

9.3 io.CopyBuffer 不一定更快

io.CopyBuffer 允许提供复用 buffer,适合通用 fallback 路径减少分配;但若 src 实现 WriterTodst 实现 ReaderFrom,传入 buffer 可能根本不参与。它也不会强制走或禁止 sendfile。

优化前先 profile:若瓶颈在 TLS 加密、磁盘、网络、handler 或对象分配,手调 buffer 可能没有意义。

9.4 Go HTTP 文件服务的真实边界

http.ServeFile/ServeContent 要处理 range、conditional request、MIME、HEAD、错误和可能的 wrapper。是否走零拷贝会受 HTTP 协议层、ResponseWriter 类型、TLS、HTTP/2、压缩中间件以及 Go 版本影响。

对大文件下载,先测:

  • plain HTTP 与 HTTPS 吞吐/CPU
  • 是否启用了压缩、代理、CDN
  • pprof 中 copy、TLS、syscall、page fault 的占比
  • 客户端实际读取速度与发送队列积压

不要把某一条内核优化当作文件服务设计的中心。


10. 线上评估:先证明瓶颈,再换模型

10.1 io_uring 不适合的典型情况

以下情况通常不该为了“先进”立刻切 io_uring:

  • 每次只有一个小请求并同步等待,无法批量
  • 真正瓶颈是数据库、TLS、压缩、应用锁或下游排队
  • 现有 epoll/event loop 已稳定,改造会引入大量生命周期风险
  • 内核版本/容器策略不满足需要的 opcode 或注册资源能力
  • 团队缺少处理 CQE 错误、取消、buffer ownership 的测试体系

选择 IO 模型是系统架构决策,不是将 read 替换成另一个 API 的局部微优化。

10.2 什么信号说明值得评估

系统调用 profile:
  recv/send/accept/epoll_wait 占明显 CPU
  且请求可以批量/流水线化

内存 profile:
  user<->kernel copy / buffer 分配成为热点
  且数据不需要改写

延迟 profile:
  syscall 固定开销显著,设备/网络本身并未饱和

先用测量确定是控制面还是数据面问题:

# 进程的 syscall/CPU 热点
sudo perf record -F 199 -g -p "$PID" -- sleep 30
sudo perf report

# 看系统调用频率(短时,strace 会扰动性能)
sudo strace -ff -c -p "$PID"

# IO 吞吐与设备队列
pidstat -d -p "$PID" 1
iostat -xz 1

strace -c 得到的是被追踪后系统的行为,适合发现数量级,不适合精确 benchmark。生产更适合 perf、eBPF 和应用指标。

10.3 io_uring 可观测性

# 本机内核是否导出了 io_uring tracepoints
sudo perf list | grep -i io_uring
sudo ls /sys/kernel/tracing/events/io_uring 2>/dev/null

# 用 trace-cmd/perf record 记录(名称随内核变化)
sudo perf record -e io_uring:* -a -- sleep 10
sudo perf script

关注:提交量、CQE 延迟、CQ overflow、取消率、-EAGAIN/-ECANCELED、固定资源占用、队列深度和尾延迟。只看 IOPS 增加可能掩盖取消风暴、buffer 泄漏或 P99 恶化。

10.4 零拷贝怎么验证

验证不应只 grep “sendfile”:

# 直接观察 syscall 路径(受 Go 版本/实现影响)
sudo strace -ff -e trace=sendfile,splice,copy_file_range,read,write -p "$PID"

# 检查 CPU profile 是否真的从 copy/memmove 转移
sudo perf top -p "$PID"

# 同时对照吞吐、P99、RSS、发送队列和网络重传
ss -tinp
pidstat -r -u -p "$PID" 1

sendfile 出现并不等于端到端更快:TLS 可能仍是热点,慢客户端会让 socket 发送队列持有更多页,内存压力可能上升。指标必须包括 CPU、带宽、内存、延迟和错误率。


11. 常用命令知识点扩展

11.1 工具与参数

短参 长参 英文全称 作用
strace -c strace --summary-only summary only 汇总 syscall 次数/时间
strace -f strace --follow-forks follow forks 跟踪线程/子进程
strace -e strace --trace=EXPR trace expression 过滤 sendfile/splice 等调用
perf record -g perf record --call-graph call graph 记录调用栈
perf list 无统一长参 list events 列出本机性能事件
iostat -x iostat --extended extended statistics 显示队列/利用率等扩展磁盘指标
iostat -z iostat --zero omit zero devices 隐藏空闲设备
pidstat -d 无统一长参 disk I/O statistics 按进程显示 IO

11.2 用 strace 区分路径

# 文件到 HTTP 客户端的进程,短时观察路径
sudo strace -ff -ttT -p "$PID" \
  -e trace=sendfile,splice,copy_file_range,read,readv,write,writev,sendto,sendmsg

# io_uring 程序的 setup/enter/register 轮廓
sudo strace -ff -ttT -p "$PID" \
  -e trace=io_uring_setup,io_uring_enter,io_uring_register

看不到 sendfile 不代表性能差,可能是 TLS、压缩或实现选择使用户态 copy 更合理;看到 io_uring_enter 也不说明每个请求都批量,仍要看一次 enter 提交多少 SQE 和 CQE 延迟。


12. 面试题

Q:io_uring 相比 epoll 主要减少什么?

epoll 优化很多 fd 的 readiness 等待,但事件返回后应用仍逐个调用 read/write/accept。io_uring 用共享 SQ/CQ 把请求描述和完成结果放入映射内存,可批量提交和批量收取,减少每个请求的 syscall、参数拷贝与进入内核固定成本。它不自动减少数据 copy,也不保证每次操作都无需 io_uring_enter。

Q:SQ/CQ 为什么需要 release/acquire 内存序?

生产者必须先完整填入 SQE/CQE 内容,再发布 tail;消费者看到 tail 后才能读取完整条目。release store 保证此前内容写入先可见,acquire load 保证随后读取不会越过该发布。双方各自只修改一侧 index,避免数据竞争;否则消费者可能看到更新后的 tail 却读到半初始化请求。

Q:io_uring 的 CQE 为什么不能简单当“成功回调”?

CQE 的 res 是 syscall 风格结果:read/recv 可短读或 EOF,write/send 可短写,负数是负 errno;取消、timeout 和链接请求也会产生不同结果。一个 CQE 只说明操作达到完成状态,应用仍需按 opcode 处理部分结果、错误、重试、资源释放和状态机推进。

Q:SQPOLL 和 IOPOLL 有何区别?

SQPOLL 用内核 polling thread 观察 submission queue,减少应用为通知新 SQE 调用 enter 的次数;IOPOLL 面向支持轮询完成的块设备路径,减少等待设备中断的延迟。二者都可能持续耗 CPU,适合条件不同,不能因名字相似混为一谈。

Q:注册 buffer/fd 为什么可能更快,也可能更危险?

它们减少每请求的 fd 查找、用户页 pin/验证和准备成本,适合长期高吞吐固定资源;但注册 buffer 会固定物理页并消耗 memlock,注册 fd 增加 close/替换生命周期复杂度。异步请求完成前资源不能回收,错误处理不当会造成 pin 泄漏、use-after-free 或错误 fd 复用。

Q:取消一个 io_uring 请求后能立刻释放 buffer 吗?

不能。取消本身也是异步请求,目标可能已完成、正在执行或不能取消;原操作与取消结果的 CQE 可任意先后到达。只有确认目标请求不再可能访问 buffer 后才能回收,通常要把最终 CQE 与 generation/ownership 协议作为边界。

Q:sendfile 省掉了什么复制?

普通 file→socket 路径通常从 page cache copy 到用户 buffer,再 copy 到 socket send buffer。sendfile 允许文件页直接参与 socket 发送路径,通常省两次 user/kernel CPU copy;NIC DMA、文件系统、TCP 和设备内部仍有自己的数据移动,因此不能理解成字节完全不动。

Q:splice 与 copy_file_range 的区别?

splice 通过 pipe 在支持的 fd 间移动内核页/pipe buffer 引用,适合 file/socket/pipe 的流转发;copy_file_range 面向 file→file,可能利用文件系统内部 copy 或 reflink。两者都需处理短传输、文件系统/对象限制和 fallback,不能承诺所有场景零拷贝。

Q:为什么 TLS 会破坏普通 sendfile 路径?

用户态 TLS 需要把明文文件字节读到用户态并生成不同的密文,再发送密文;原 page cache 页不能原封不动送给 socket。kTLS 在有限条件下能把部分加密下沉内核,但依赖 cipher、内核和库支持。内容压缩、转码、模板渲染同理:一旦输出字节变了,就必须产生新 buffer。

Q:MSG_ZEROCOPY 的代价是什么?

它尝试避免把用户 buffer copy 到内核发送页,但内核/NIC 在 send 返回后仍可能引用这些页面。应用必须从 error queue 接收完成通知才能复用 buffer,并承担 page pin、completion、错误和内存池生命周期复杂度。小包的固定成本通常不值得,适合大块高吞吐并经测试的路径。

Q:Go io.Copy 是否一定走 sendfile?

不一定。io.Copy 会优先使用源的 WriterTo 或目的的 ReaderFrom,动态类型与 Go 版本决定后续路径。*os.File*net.TCPConn 在 Linux 上可能利用 sendfile;TLS Conn、gzip/限速/hash wrapper、非普通文件或特殊错误通常会走用户态 copy/fallback。应通过源码、strace 和基准确认,不能仅由调用名称推断。

Q:选择 io_uring 前应先验证什么?

先证明控制面 syscall 开销是热点且能批量/流水线化,确认内核版本、容器权限、opcode 和资源限制支持;设计 CQE 错误、短 IO、取消、buffer/fd 生命周期、overflow 和降级路径;再以吞吐、P50/P99、CPU、RSS、错误率和故障恢复做对照。若瓶颈在 TLS、数据库或业务锁,换 IO 模型通常不会改善结果。


小结

  • 普通 IO 的成本分为控制面(syscall/参数/调度入口)和数据面(CPU copy/cache 带宽);io_uring 与零拷贝分别针对不同部分
  • io_uring 用共享 SQ/CQ 实现批量提交和完成,生产者/消费者通过 head/tail 所有权与 release/acquire 保证正确发布
  • 默认 io_uring 仍需要 enter;单请求同步等待可能没有收益,SQPOLL/IOPOLL 也可能以 CPU 换延迟
  • CQE 是 syscall 风格结果,不是成功回调;短 IO、负 errno、取消、multishot 和资源生命周期必须纳入状态机
  • 异步请求只有最终 CQE 才能回收相关 buffer/fd/request;取消不是立即安全点
  • sendfile、splice、copy_file_range、MSG_ZEROCOPY 分别减少不同位置的复制或 syscall,不能笼统称为“完全零拷贝”
  • TLS、压缩、转码和协议改写必须产生新字节,是零拷贝的物理边界;kTLS 只在受限条件下改变路径
  • Go io.Copy 按 WriterTo/ReaderFrom 动态分派,可能利用 sendfile,也可能因 TLS/包装器退化;必须测量验证
  • 是否采用 io_uring 要由 profile、内核/权限、批量机会、生命周期复杂度与端到端指标决定,而不是技术潮流

下一篇讲 一个包在内核里的旅程 —— 从网卡 DMA、中断、NAPI、sk_buff、协议栈到 socket 接收队列,再解释 TCP 半连接/全连接队列、SYN flood 与 TIME_WAIT 为什么无法简单删掉。