想象一个繁忙港口。过去,货主每发一箱货,都要到港务大厅排队、填单,货物还要先搬进中转棚,再由叉车送上船。操作系统的网络 I/O 也像这样:应用每发一个请求,CPU 都要完成系统调用、内存拷贝、协议栈处理,网卡才能真正动起来。io_uring 改变了第一件事——它在港口门口放了一排自动收单柜:货主把订单塞进投递口,港口员工批量取走,完成后再把回执放进另一个柜子。而 uring_cmd 又给设备厂商开了一扇“专属作业窗口”,可以自定义装卸流程;registered buffers 让货主提前把仓库备案给港口;sendzc/recvzc 则让货物从“必须进中转棚”变成“仓库直接对接码头”。本文用港口物流这个比喻,把这条知识链讲清楚。

岗位关联

理解这条知识链,能帮你在高吞吐网络服务中定位拷贝与中断开销,设计更低延迟、更省 CPU 的 I/O 路径,这正是 AI 推理、分布式训练等场景的刚需。

目录
  1. 可视化演示
  2. io_uring 基础:港口双排收件柜
  3. uring_cmd 机制:设备专属作业窗口
  4. 注册缓冲区:货主提前备案仓库
  5. 发送零拷贝 sendzc:从仓库直达船边
  6. 接收零拷贝 recvzc:卸货直入备案仓库
  7. 性能与收益:高吞吐港口的账本
  8. 应用与限制:不是所有港口都能直靠
  9. AI 应用场景
  10. Python 演示
  11. 常见误区
  12. 面试题

可视化演示

展示向量v在方向u上的投影分解,通过步进方式依次呈现投影向量、垂直分量和完整正交分解过程。

本节配有交互动效(进入视口后自动加载,约 3KB)。

向量投影与正交分解 向量v 投影proj proj=v·u/|u|²·u 正交分解v=proj+perp

io_uring 基础:港口双排收件柜

老式港口里,货车司机每次运货都要去大厅排队、递单,等工作人员处理完才能走。应用频繁发起 I/O 时也是这样:每次 read/write 都是一次系统调用,像每票货都单独跑一趟窗口,CPU 白白消耗在“排队办单”上。

io_uring 在港口门口放了两个环形收件柜。发货方把订单写进 SQ(Submission Queue,提交队列),港口员工批量取走;完成后把回执塞进 CQ(Completion Queue,完成队列),发货方回头来取。用户态和内核态共享这片环形缓冲区,所以大部分时候连系统调用都省了。代价值从“每票一趟窗口”变成“一批一票窗口”。

回到港口:窗口排队消失了,司机不用每票都下车跑大厅。io_uring 的价值不只是“异步”,而是把办单成本摊薄到极致,为后面的零拷贝打好了地基。

T平均=T执行+T提交N\displaystyle T_{\text{平均}} = T_{\text{执行}} + \frac{T_{\text{提交}}}{N}
一次系统调用就像去窗口办一次单。N 越大,每票货摊到的“办单时间”就越少。
  1. T执行港口真正搬货、装卸的时间,也就是硬件和驱动完成 I/O 的时间,无法靠批量办单省掉。
    T执行T_{\text{执行}}
  2. T提交把一批订单投入柜子并通知内核的固定开销,可以看作“去一次窗口”的成本。
    T提交T_{\text{提交}}
  3. 除以 N一次批量提交处理 N 个请求,固定开销被 N 个请求平分,请求越多越划算。
    T提交N\frac{T_{\text{提交}}}{N}

uring_cmd 机制:设备专属作业窗口

港口除了普通集装箱,还有危险品、活物、冷链货物。通用流程办不了,港口便在码头边给设备商开专属窗口:你可以用自己的规章装卸,不必走通用单据。

在 io_uring 里,标准流程是内核按固定步骤处理 read/write。uring_cmd 打开另一条路:应用写一个特殊的 SQE(Submission Queue Entry,提交队列条目),内核不把它翻译成通用 I/O,而是直接转交给驱动程序,由驱动执行自定义命令。对 NVMe 这类设备,很多专有操作因此能绕过厚重的通用协议层。

港口里,司机不用先把活物装进普通集装箱再去冷库。设备商有了自己的作业窗口,io_uring 从“通用快递”升级成了“可定制快递”。

T专属=T入柜+T驱动\displaystyle T_{\text{专属}} = T_{\text{入柜}} + T_{\text{驱动}}
命令只要进一次柜子,剩下的由驱动直接处理;相比“应用→通用接口→设备协议→驱动”少了好几站。
  1. T入柜把 SQE 放进提交队列并唤醒内核,这道成本每批请求只付一次。
    T入柜T_{\text{入柜}}
  2. T驱动驱动执行设备自定义逻辑,例如 NVMe 透传命令,直接与硬件寄存器对话。
    T驱动T_{\text{驱动}}
  3. 省去的中间层通用协议解析、重复权限校验等不再逐命令发生,所以定制命令路径更短。
    省去=T通用协议+T重复校验\text{省去} = T_{\text{通用协议}} + T_{\text{重复校验}}

注册缓冲区:货主提前备案仓库

货主第一次合作时,把仓库位置、钥匙、货架图全部交给港务局备案。此后每次发货,港口直接派车去备案仓库提货,不用临时开箱验货、重新登记。

技术上也一样:io_uring_register(IORING_REGISTER_BUFFERS) 把用户态的一段内存提前交给内核,内核替你固定这些物理页并记录地址映射。后续 I/O 要用这片内存时,不用每次做页表映射与权限校验,这就是 registered buffers 的核心收益。

但请注意:备案仓库只解决“找货”问题,不代表货物不用搬。真正不搬运,要靠 sendzc/recvzc 把网卡直接接到备案仓库门口。

C节省=N×(T映射+T校验)\displaystyle C_{\text{节省}} = N \times (T_{\text{映射}} + T_{\text{校验}})
请求数 N 越大,省掉的重复开销越可观,就像同一间仓库被反复使用,备案费被摊得越来越薄。
  1. N复用同一批缓冲区的 I/O 请求数量。长连接、反复读写时,N 会很大。
    NN
  2. T映射每笔 I/O 临时把用户虚拟地址转成物理页列表所花的时间,相当于“临时翻仓库图纸”。
    T映射T_{\text{映射}}
  3. T校验检查缓冲区是否可访问、权限是否匹配,相当于“临时验货”。
    T校验T_{\text{校验}}

发送零拷贝 sendzc:从仓库直达船边

以前货物要先搬进中转棚,再由吊机装上船;现在备案仓库就在泊位旁,吊机直接从仓库凌空抓货上船。

sendzc 发送数据时,应用把数据放在 registered buffer 里。内核构造用于网络发送的 skb(Socket Buffer,套接字缓冲区)时,不再把数据搬到内核发送缓冲区,而是让 skb 直接引用用户页;网卡 DMA 控制器自己来这片内存取数,CPU 全程不碰数据。这就是“零拷贝发送”。

港口里,吊机(网卡)直接从备案仓库抓货,理货员(CPU)终于可以坐在旁边喝杯茶,这就是 sendzc 追求的极致。

CPU 拷贝旧=2,CPU 拷贝新=0\displaystyle \text{CPU 拷贝}_{\text{旧}} = 2,\qquad \text{CPU 拷贝}_{\text{新}} = 0
旧路径是“用户态→内核缓冲→驱动可读区域”;新路径是“用户页→网卡 DMA”。CPU 只负责指路,不再搬货。
  1. 旧方案两次拷贝第一次从应用缓冲区拷到 socket 发送队列,第二次在协议栈内部整理到驱动可读空间。
    拷贝1+拷贝2\text{拷贝}_{1} + \text{拷贝}_{2}
  2. 新方案零次 CPU 拷贝内核把用户页直接编进发送描述符,网卡 DMA 自行读取。
    DMA 读取=网卡搬运\text{DMA 读取} = \text{网卡搬运}
  3. 前提条件用户页必须一直有效且物理地址固定,这正是 registered buffers 提前备案的意义。
    前提=registered buffers\text{前提} = \text{registered buffers}

接收零拷贝 recvzc:卸货直入备案仓库

远洋船到港,以前货物先卸进中转棚,等卡车来了再搬走;现在吊机直接把货放进货主的备案仓库。网络收包也是同理:网卡收到数据后,不再先放进内核缓冲区再复制给应用。

recvzc 让网卡通过 DMA 把报文 payload 直接写入应用预先注册的内存区域,内核只负责把“数据到了”这个消息放进完成队列。应用从 CQ 里拿到地址后直接处理,数据一次都没有经过 CPU 搬运。

货物不落中转棚,直接进备案仓库。接收零拷贝最妙的地方是:数据在物理内存里生成时,已经到了它最终该去的地方。

T收包=T网卡+T通知+T业务\displaystyle T_{\text{收包}} = T_{\text{网卡}} + T_{\text{通知}} + T_{\text{业务}}
传统路径里还有一个 T_拷贝 项;recvzc 把这一项从等式里删掉了。
  1. T网卡DMA 把数据从网卡写入内存,这是硬件搬运,不消耗 CPU。
    T网卡=TDMAT_{\text{网卡}} = T_{\text{DMA}}
  2. T通知内核通过 CQ 告诉应用“货到了”,这段开销比完整系统调用小得多。
    T通知T_{\text{通知}}
  3. T业务应用处理数据本身,无法被 I/O 框架省掉。
    T业务T_{\text{业务}}

性能与收益:高吞吐港口的账本

港口吞吐量上去了,堵塞减少了,人力(CPU)也释放出来。但对一个每天只有几票货的小码头,建直通泊位可能得不偿失。

零拷贝路径最直接的收益是延迟和 CPU。一次复制 1GB 数据就少搬 1GB;系统调用次数降下来,上下文切换也减少。高吞吐网络服务中,每个数据包省下的几十纳秒,乘以每秒百万包就是一个质变。

收益公式里的每一项都不是免费午餐。备案仓库、专用吊机、系统改造都要成本,真正的收益要按“省多少、花多少”来算。

收益=S×(T拷贝+T系统调用)\displaystyle \text{收益} = S \times (T_{\text{拷贝}} + T_{\text{系统调用}})
S 越大,收益越明显。这解释了为什么大包、长连接、大批量场景最受益,而小包短连接往往不明显。
  1. S单个请求传输的数据量,比如 4KB 与 1MB 的差距。
    S=传输字节数S = \text{传输字节数}
  2. T拷贝每单位数据被 CPU 复制的耗时,零拷贝把它降为 0。
    T拷贝T_{\text{拷贝}}
  3. T系统调用每请求的固定开销,批量提交把它摊薄。
    T系统调用T_{\text{系统调用}}

应用与限制:不是所有港口都能直靠

直通泊位要求水深、岸桥、仓库位置全部到位。码头等级不够,船再先进也靠不了岸。

实际落地时,内核要支持 IORING_OP_SEND_ZC、IORING_OP_RECV_ZC 这类操作码,网卡驱动要实现零拷贝回调,硬件要支持 scatter-gather 或 DMA 到用户页。同时应用必须用 registered buffers 固定内存。并非随便一个 Linux 发行版或云主机都能直接启用。

新技术像新式吊机:港口管理者(开发者)要先确认水深和岸桥,再决定要不要全面换装。理解了限制,才能真正用好它。

收益=大包比例×复用率×硬件支持−预注册成本\displaystyle \text{收益} = \text{大包比例} \times \text{复用率} \times \text{硬件支持} - \text{预注册成本}
这是一个设计取舍:前置条件满足得越好,收益越正;条件不满足,结果可能为负。
  1. 大包比例数据量大的请求占多少,决定拷贝节省的绝对值有多大。
    大包比例∈[0,1]\text{大包比例} \in [0,1]
  2. 复用率同一注册缓冲区能被多少请求复用,决定预注册成本能否摊薄。
    复用率\text{复用率}
  3. 硬件支持驱动、网卡、内核版本如果不满足,这一项就是 0。
    硬件支持∈{0,1}\text{硬件支持} \in \{0,1\}
  4. 预注册成本注册缓冲区本身要建页表映射、固定物理页,这笔一次性开销必须被省下的费用覆盖。
    预注册成本\text{预注册成本}

AI 应用场景

大模型推理网关的高吞吐转发

阶段:模型推理服务部署与请求调度

解决的问题:AI 推理网关每秒要转发数万到数百万个请求,传统 epoll + read/write 模式会让 CPU 大量花在系统调用和内存拷贝上,推理延迟预算被网络损耗侵占。

具体案例:一个面向大模型的服务网关,响应体动辄几十 KB。用 io_uring 批量 accept/read/write,再用 sendzc 直接发送大响应,可降低 p99 延迟并释放 CPU 给模型推理使用。

分布式训练的梯度聚合通信

阶段:梯度同步与参数聚合

解决的问题:AllReduce 等集合通信需要传输大量张量,传统网络路径的 CPU 拷贝会拖慢多机同步,让 GPU 空转等待。

具体案例:在多节点训练集群中,梯度张量被反复写入同一批 registered buffers,配合支持零拷贝的网卡,每次 allreduce 的同步耗时明显下降,GPU 利用率提升。

高性能 AI 数据管道

阶段:训练数据读取与预处理

解决的问题:数据加载器从网络存储或 NVMe 读取样本时,每笔 I/O 都有系统调用和拷贝开销,成为训练管道的瓶颈。

具体案例:用 io_uring 批量读取样本文件,配合 registered buffers 把数据直接固定到内存池,再以零拷贝方式送往 GPU 预处理流水线,减少了数据管道的 CPU 占用。

Python 演示

python45 行
import mathimport random class CopyTransport:    def __init__(self, base=1.0, copy_cost=0.0002):        self.base = base        self.copy_cost = copy_cost        self.total_ms = 0.0     def send(self, size):        cost = self.base + size * self.copy_cost        self.total_ms += cost        return cost class ZeroCopyTransport:    def __init__(self, base=1.0, page_cost=0.05):        self.base = base        self.page_cost = page_cost        self.total_ms = 0.0     def send(self, size):        pages = math.ceil(size / 4096)        cost = self.base + pages * self.page_cost        self.total_ms += cost        return cost def simulate(packet_sizes):    old = CopyTransport()    new = ZeroCopyTransport()    old_times = []    new_times = []    for size in packet_sizes:        old_times.append(old.send(size))        new_times.append(new.send(size))    return old_times, new_times if __name__ == "__main__":    random.seed(42)    packets = [random.randint(1024, 65536) for _ in range(1000)]    old_times, new_times = simulate(packets)    old_total = sum(old_times)    new_total = sum(new_times)    print("old total: {:.2f} ms".format(old_total))    print("zero total: {:.2f} ms".format(new_total))    print("saving: {:.1f}%".format((1 - new_total / old_total) * 100))
目的:用成本模型模拟“传统搬运”与“零拷贝”两类网络路径的耗时差异。
  1. class CopyTransport:模拟传统路径,每字节都要付出复制成本。
  2. cost = self.base + size * self.copy_cost固定办单成本 + 按数据量增长的拷贝成本。
  3. pages = math.ceil(size / 4096)零拷贝按 4KB 页数计价,定位到页级 DMA。
  4. cost = self.base + pages * self.page_cost固定成本 + 每页少量硬件处理成本。
  5. for size in packet_sizes:遍历一批随机大小的数据包,模拟真实高低负载混合。
  6. old_times, new_times = simulate(packets)分别跑两种模型,得到延迟序列。
  7. print("saving: {:.1f}%".format(...))输出零拷贝相对传统的整体节省比例。

常见误区

误区io_uring 和 epoll 都是异步框架,用哪个都差不多吧?epoll 只是告诉你有事件,io_uring 应该也就是换个写法。

正解epoll 只是事件通知,真正的读和写还要自己再调 read/write;io_uring 则把提交与完成都放进环形队列,能批量提交、减少系统调用,还支持固定缓冲区与自定义命令。好比 epoll 只告诉你“船到了”,io_uring 连“卸货入仓”都替你安排好了。

为何常见:两者都用于高并发 I/O,看起来都在处理大量连接,初学者很容易把“通知框架”与“提交/完成框架”混为一谈。

误区只要用 `io_uring_register` 注册缓冲区,网络收发数据就不会拷贝了。

正解注册只是把内存固定好,避免每次映射校验。普通 read/write 路径仍可能发生内核与应用之间的复制。真正的零拷贝要用 sendzc/recvzc 这类让网卡 DMA 直接读写用户页的接口。备案仓库只是让港口认识了路,不等于货能直接上船。

为何常见:官方文档中 registered buffers 与零拷贝经常一起出现,人们容易把“前提”当成“结果”。

误区数据不拷贝了,协议栈是不是也可以不走?延迟应该无限接近零。

正解零拷贝只消掉数据搬运和部分系统调用,TCP 的拥塞控制、序号、ACK、重传等协议逻辑照样要跑,只是可能由内核或网卡硬件完成。吊机直接把货物从仓库抓上船,但报关、配载、航线计划一样不少。

为何常见:“零”字容易被绝对化,以为所有开销都消失了。

误区小包、短连接、多队列场景,用零拷贝也一定更优。

正解注册缓冲区、准备 skb、安排 DMA 都有固定成本。小包时省下的几次拷贝不足以覆盖新增开销;短连接又让预注册无法复用。大包、长连接、批量 I/O 才是甜区。

为何常见:基准测试多用大包长连接,人们容易把特定场景的收益外推到所有场景。

误区任何 I/O 只要改成 uring_cmd 就会变快。

正解uring_cmd 把 SQE 转给驱动执行,前提是驱动实现了对应命令。普通网络读写不经过这个机制;对没有专属命令的设备,它只是多绕一段路。港口不可能给每个设备都开专属窗口,只有真正有特殊工艺的货主才划算。

为何常见:看到“定制”功能就认为它能取代所有通用路径,忽视了驱动侧的巨大工作量。

面试题

Q1. 在 io_uring 中,用户提交请求和获取完成结果分别使用哪个队列?

单选题 · easy

  • A.SQ 与 CQ← 正确答案
  • B.CQ 与 SQ
  • C.RDMA 与 DMA
  • D.注册表与完成表

答案:A。io_uring 用 SQ(提交队列)接收用户的 I/O 请求,用 CQ(完成队列)返回结果。就像港口里订单投进 SQ,回执从 CQ 取走;CQ/SQ 顺序反了就完全对不上。

Q2. uring_cmd 的核心作用是什么?

单选题 · easy

  • A.让用户绕过内核直接控制网卡
  • B.让驱动可以接收并执行自定义命令← 正确答案
  • C.取代 TCP/IP 协议栈
  • D.自动完成内存拷贝

答案:B。uring_cmd 把特殊的 SQE 直接转交给驱动,由驱动执行自定义命令。它没有绕过内核,也不取代协议栈;港口只是给设备商开了专属作业窗口,不是取消码头管理。

Q3. registered buffers 主要解决什么问题?

单选题 · easy

  • A.网络拥塞
  • B.每次 I/O 重复的内存映射和校验开销← 正确答案
  • C.数据包乱序
  • D.磁盘坏道

答案:B。registered buffers 提前固定内存并记录映射,后续 I/O 不需要重复做映射与校验。这相当于货主提前备案仓库,每次发货不再开箱验货;它并不处理网络拥塞或数据包乱序。

Q4. 相比传统发送路径,sendzc 主要省去的是哪一步?

单选题 · mid

  • A.DNS 解析
  • B.CPU 把用户数据拷贝到内核发送缓冲区← 正确答案
  • C.网卡 DMA 读取
  • D.数据包分片

答案:B。传统发送要把用户数据复制到内核 socket 缓冲区,再由网卡 DMA 送走;sendzc 让网卡直接 DMA 用户页,省掉了 CPU 那次拷贝。DNS 解析和分片与这个环节无关。

Q5. recvzc 场景中,网卡收到数据后通过什么把 payload 放入注册内存?

单选题 · mid

  • A.中断
  • B.系统调用
  • C.DMA← 正确答案
  • D.memcpy

答案:C。recvzc 依赖网卡 DMA 把报文直接写入用户注册的内存区域。中断和系统调用只是通知机制,memcpy 是 CPU 拷贝,正是零拷贝想要避免的。

Q6. 下列哪种负载从零拷贝中获益最大?

单选题 · mid

  • A.大量 64 字节小包
  • B.少量大块数据且缓冲区可复用← 正确答案
  • C.每次新建连接的短请求
  • D.纯 CPU 计算

答案:B。大块数据让省下的拷贝量很大,缓冲区可复用让预注册成本被摊薄。小包短连接时,固定开销很容易超过省下的拷贝成本,纯 CPU 计算则根本不涉及网络零拷贝。

Q7. 使用 sendzc 需要满足哪些条件?

多选题 · mid

  • A.使用 registered buffers 固定用户内存← 正确答案
  • B.网卡与驱动支持 DMA 到用户页← 正确答案
  • C.必须先把数据复制到内核 socket 缓冲
  • D.通过 CQ 等待可安全重用缓冲区的通知← 正确答案

答案:ABD。sendzc 要求内存已被固定、驱动支持零拷贝发送,并且应用要等 CQ 通知后再复用缓冲区。选项 C 是传统路径的做法,正是 sendzc 要省掉的一步。

Q8. 关于 recvzc 的叙述,哪些正确?

多选题 · mid

  • A.需要使用 registered buffers 提供目标内存← 正确答案
  • B.网卡 DMA 可直接写用户内存← 正确答案
  • C.完成队列完全不需要
  • D.可省去内核缓冲区到用户缓冲区的复制← 正确答案

答案:ABD。recvzc 把数据通过 DMA 写入预注册内存,从而省去内核到用户的复制。完成队列仍然需要,因为它要告诉应用“数据已经就位”;没有 CQ,应用不知道何时可以处理数据。

Q9. 为什么小包短连接下零拷贝收益可能为负?

单选题 · hard

  • A.网卡无法处理小包
  • B.注册缓冲区和 skb 组装等固定成本超过省下的拷贝成本← 正确答案
  • C.小包不需要 TCP
  • D.小包会被 CPU 自动缓存

答案:B。零拷贝不是把成本变成零,而是用注册、DMA 准备等固定成本去换拷贝成本。小包省下的拷贝很少,短连接又让预注册无法复用,固定成本就可能超过收益。网卡并不是不能处理小包。

Q10. 关于 io_uring 与网络零拷贝的综合判断,正确的是?

多选题 · extreme

  • A.io_uring 通过批量提交摊薄系统调用开销← 正确答案
  • B.registered buffers 固定内存后仍可能发生数据拷贝← 正确答案
  • C.只要驱动支持,sendzc 可以让 CPU 不参与数据搬运← 正确答案
  • D.零拷贝可以消除所有网络延迟

答案:ABC。批量提交摊薄了系统调用成本;registered buffers 只是前提,不等于不拷贝;sendzc 确实把数据搬运交给 DMA。但零拷贝无法消除协议栈处理、线路传输和应用计算带来的延迟,所以 D 错误。