从内核态/用户态看零拷贝为什么快

要理解零拷贝技术 / io_uring 为什么快,首先需要理解现代操作系统中的用户态和内核态。

为什么要有内核态和用户态

划分内核态和用户态,本质上是为了解决两个核心矛盾:安全抽象

安全

如果没有这种划分,任何一个程序的 Bug 都可能直接改写其他重要的数据,如驱动程序。在没有划分的系统上,一个写越界就有可能导致奇怪的异常表现。我使用过的一个嵌入式平台,如果读写非法内存,并不会有来自系统的拦截,而是总线会直接卡死,严重的甚至会导致硬件异常。

划分了内核态和用户态后,用户态程序只能看到自己的虚拟地址空间,无法触碰内核或其他程序的内存。这就保证了即使某一个用户态程序崩溃,系统内核仍然可以稳定运行。

抽象

实现抽象本身是不需要内核态和用户态的划分的,但如果希望抽象是强制性的、通用的,就需要依赖于用户态和内核态的隔离。

可以这样来理解:

  • 抽象本质上只是提供一个接口:即使没有内核态,也能用库来实现抽象,比如提供一个 network_read() 来进行网卡读取。
  • 软约束与硬约束:但这只是一种软约束,开发者只要愿意,完全可以跳过 network_read(),自己写一段代码来直接操作网卡,这段代码中的一个 Bug 就可以毁掉整个系统的硬件状态。
  • 内核态的硬约束:但如果是基于内核态的抽象,内核使用了 CPU 的物理特性,将硬件操作指令设置为“特权指令”,普通程序就没有权限触碰硬件了,只能把需求提交给内核。

正是这种划分让硬件抽象变成了强制的,因为没有内核的允许,任何程序都无法绕过抽象直接操作物理设备。

零拷贝为什么快

有了对内核态和用户态划分的理解,就很容易能明白为什么零拷贝技术可以显著提升性能了。

在传统 I/O 中,如果要读写数据,数据需要在内核态和用户态之间拷贝。比如读数据,用户态程序没办法直接读,只能请求内核读取,内核读取完成后,再复制到用户态程序的缓冲区中。

除了多次数据复制带来的损耗,状态切换也需要时间:每一次读写,CPU 都需要在内核态和用户态之间切换,这种上下文切换的开销有时候甚至超过了逻辑处理时间。

零拷贝正是消除了这些“需要 CPU 参与的、跨越内核态和用户态边界的拷贝”(并不是完全不拷贝)。有多种实现零拷贝的方案,目前最前沿的是固定缓冲区 (Fixed Buffers) 方案,这也是 io_uring 采用的。

io_uring 的固定缓冲区

初始化

在用户态预先分配好一块内存,调用 io_uring_register 告诉内核:这块内存我专门用来做 I/O,请你提前帮我完成内存锁定 (Memory Pinning) 以保证物理地址的恒定,并完成页表映射。内核此时就已经知道了这些用户态内存的物理地址,这就意味着这块内存成了内核态和用户态之间的“中立区”,双方都能直接操作。

内核处理

内核收到请求后,直接指挥硬件(如网卡、磁盘),利用 Direct DMA 技术让硬件直接将数据从设备(或内核 Ring Buffer)传输到预先注册的用户态 Buffer 物理地址。

这里体现了零拷贝:数据没有先复制到内核态缓冲区,再由 CPU 复制到用户态,而是直接通过 DMA 传输到了用户态程序能够直接读取的区域中。

完成通知

内核完成写入后,会在 完成队列 (CQ, Completion Queue) 中放入一个结果。程序通过轮询或系统调用查看该队列,一旦看到任务完成,就可以直接从 Buffer 里读取数据了。