Linux I/O与Zero-copy

Posted by 皮皮潘 on 12-17,2021

背景简述

传统的Linux操作系统的标准 I/O 接口是基于数据拷贝操作的,即 I/O 操作会导致数据在操作系统内核地址空间的缓冲区和用户进程地址空间定义的缓冲区之间进行传输。设置缓冲区最大的好处是可以减少 I/O 的操作,如果所请求的数据已经存放在操作系统的高速缓冲存储器中,那么就不需要再进行实际的物理 I/O 操作;然而传统的 Linux I/O 在数据传输过程中的数据拷贝操作深度依赖 CPU,也就是说 I/O 过程需要 CPU 去执行数据拷贝的操作,因此导致了极大的系统开销,限制了操作系统有效进行数据传输操作的能力,这也引起了大量的对于I/O过程的优化

I/O模式

在Linux或者其他Unix-like操作系统中,I/O模式一般有三种:1. 程序控制I/O 2. 中断驱动I/O 3. DMA I/O,I/O模式控制了数据如何从硬件到内核空间缓冲区的过程,如下图所示:
image.png

程序控制I/O

这是最简单的一种 I/O 模式,也叫忙等待或者轮询:用户通过发起一个系统调用,陷入内核态,内核将系统调用翻译成一个对应设备驱动程序的过程调用,接着 不断循环去检查该设备,看看设备是否已经就绪,当设备就绪后CPU会把数据从硬件拷贝到内核空间缓冲区,然后再从内核空间缓冲区拷贝到用户态缓冲区最后切回用户态。

中断驱动I/O

这种I/O模式,在程序控制I/O的基础上引入了中断,也即CPU不需要轮询等待设备就绪了,而是在设备就绪之后主动发送中断信号给CPU,然后CPU会把数据从硬件拷贝到内核空间缓冲区,然后再从内核空间缓冲区拷贝到用户态缓冲区最后切回用户态。

DMA I/O

并发系统的性能高低究其根本,是取决于如何对 CPU 资源的高效调度和使用,而回头看前面的中断驱动 I/O 模式的流程,可以发现从硬件到内核空间缓冲区的数据拷贝工作都是由 CPU 亲自完成的,也就是说在这个阶段中 CPU 是完全被占用而不能处理其他工作的,因此这里明显是有优化空间的;第二阶段的数据拷贝是从内核缓冲区到用户缓冲区的,由于该操作都是在主存里,所以这一步只能由 CPU 亲自完成,但是第一阶段的数据拷贝,是硬件到内存之间的数据传输,这一步并非一定要 CPU 来完成,可以借助 DMA 来完成从而减轻 CPU 的负担。通过DMA,整个拷贝过程无须 CPU 参与,数据直接通过 DMA 控制器进行快速地移动拷贝,将数据从硬件拷贝到内核指定的内核空间的缓冲区中,使得CPU可以节省计算资源去做其他工作。

BIO与NIO的区别

BIO在设备没有准备好的时候(磁盘还在寻址,Socket还没有接收到消息,写缓存满了等情况)会将当前线程阻塞直到数据准备完毕,此时底层会有具体的I/O模式(程序控制IO,中断IO以及DMA IO),但是不管是哪种具体的I/O模式,此时的阻塞其实只是当前线程的阻塞,CPU会随之进行上下文切换去执行其他线程,直到对应的数据准备完成使得CPU收到中断,也就是说CPU还是在忙碌工作的并没有闲下来(除了程序控制I/O),只是工作的目标不是原来那个线程了。NIO和BIO的区别并不在于底层具体的I/O模式,而是在于当数据没有准备好时,IO调用会不会阻塞当前线程,NIO会让调用立即返回一个代表空的状态,然后当前线程会继续工作去执行其他逻辑,因此没有上下文切换发生,从而节省了大量的开销。不管是NIO还是BIO,在没有采用Zero Copy的技术下,都需要让CPU参与将内核态的内存数据拷贝到用户态的内存下,至于数据是怎么从设备到内核态的内存的,就取决于之前说的具体的I/O模式了,不过目前一般都是基于DMA实现也即DMA控制器自动地把数据拷贝到内核态的内存。

Zero-copy

Linux 中传统的 I/O 读写是通过 read()/write() 系统调用完成的,read() 把数据从存储器 (磁盘、网卡等) 读取到用户缓冲区,write() 则是把数据从用户缓冲区写出到存储器,一次完整的读磁盘文件然后写出到网卡的底层传输过程往往需要触发4次用户态和内核态的上下文切换,2次DMA拷贝,2次CPU拷贝,虽然通过引入DMA拷贝可以把原来的4次CPU拷贝压缩2次DMA拷贝+2次CPU拷贝大幅度地节省了CPU的资源浪费,但是CPU拷贝依然是代价很大的操作,因此业界也继续研究如何进一步优化,来降低甚至完全避免CPU拷贝,也即Zero-copy

Zero-copy的核心思路主要有以下三种:

  1. 减少甚至避免用户空间和内核空间之间的数据拷贝
  2. 绕过内核直接I/O给硬件
  3. 对于内核缓冲区和用户缓冲区之间的传输拷贝进行定制化的优化

下文主要对于第一种思路以及具体的实现进行讲解

mmap()

mmap也即内存映射(memory map)可以把用户进程空间的一段内存缓冲区直接映射到文件内存所在的内核缓冲区上,也即使得它们不同的多级页表中的不同的虚拟地址指向相同的物理地址,具体可以参照之前的博文
mmap在Zero-copy中的应用就是在一次读写过程中,用mmap替换原先的read操作,通过这种方式主要有两个优点:

  1. 节省内存空间,因为用户进程上的这一段内存是虚拟的,并不真正占据物理内存,只是映射到文件所在的内核缓冲区上,因此可以节省一半的内存占用
  2. 省去了一次 CPU 拷贝,对比传统的 Linux I/O 读写,数据不需要再经过用户进程进行转发了,而是直接在内核里就完成了拷贝

所以使用 mmap() 之后的拷贝次数是 2 次 DMA 拷贝,1 次 CPU 拷贝,加起来一共 3 次拷贝操作,比传统的 I/O 方式节省了一次 CPU 拷贝以及一半的内存,不过因为 mmap() 也是一个系统调用,因此用户态和内核态的切换还是 4 次。

sendfile()

在 Linux 内核 2.1 版本中,引入了一个新的系统调用 sendfile():

#include <sys/sendfile.h>
 
ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count);

从功能上来看,这个系统调用将 mmap() + write() 这两个系统调用合二为一,实现了一样效果的同时还简化了用户接口。
基于 sendfile(), 整个数据传输过程中共发生 2 次 DMA 拷贝和 1 次 CPU 拷贝,这个和 mmap() + write() 相同,但是因为 sendfile() 只是一次系统调用,因此比前者少了一次用户态和内核态的上下文切换开销。sendfile() 相较于 mmap() 的另一个优势在于数据在传输过程中始终没有越过用户态和内核态的边界,因此极大地减少了存储管理的开销。

sendfile() with DMA Scatter/Gather Copy

通过引入一个新硬件上的支持, 可以把仅剩的最后一次 CPU 拷贝也给抹掉:Linux 在内核 2.4 版本里引入了 DMA 的 scatter/gather -- 分散/收集功能,并修改了 sendfile() 的代码使之和 DMA 适配。scatter 使得 DMA 拷贝可以不再需要把数据存储在一片连续的内存空间上,而是允许离散存储,gather 则能够让 DMA 控制器根据少量的元信息:一个包含了内存地址和数据大小的缓冲区描述符,收集存储在各处的数据,最终还原成一个完整的网络包,直接拷贝到网卡而非套接字缓冲区,避免了最后一次的 CPU 拷贝