io

IO 系列 零拷贝

解析 IO...

Posted by lichao modified on May 13, 2021

在 OS 层面上的零拷贝通常指避免在用户态(User-space) 与内核态(Kernel-space) 之间来回拷贝数据。严格意义上的零拷贝是指将数据直接从磁盘文件复制到网卡设备中,而不需要经由应用程序之手。

零拷贝技术:

  • mmap
  • sendfile

相关概念

DMA(Direct Memory Access,直接存储器访问) :DMA 传输将数据从一个地址空间复制到另外一个地址空间。当 CPU 初始化这个传输动作,传输动作本身是由 DMA 控制器来实行和完成。在实现 DMA 传输时,是由 DMA 控制器直接掌管总线,因此,存在着一个总线控制权转移问题。即 DMA 传输前,CPU 要把总线控制权交给 DMA 控制器,而在结束 DMA 传输后,DMA 控制器应立即把总线控制权再交回给 CPU。

传统IO

在 Java 中,传统 IO 的代码示例:

1
2
3
4
5
6
7
8
  File file = new File("index.html");
  RandomAccessFile raf = new RandomAccessFile(file, "rw");
  
  byte[] arr = new byte[(int) file.length()];
  raf.read(arr);
  
  Socket socket = new ServerSocket(8080).accept();
  socket.getOutputStream().write(arr);

调用 read 方法读取 index.html 的内容,变成字节数组,然后调用 write 方法,将 index.html 字节流写到 socket 中,在 OS 底层的传输流程如下图所示。 传统IO 上图中,上半部分表示用户态和内核态的上下文切换。下半部分表示数据复制操作。步骤具体如下:

  • read 调用导致用户态到内核态的一次变化,同时,第一次复制开始:DMA(Direct Memory Access,直接内存存取,即不使用 CPU 拷贝数据到内存,而是 DMA 引擎传输数据到内存,用于解放 CPU) 引擎从磁盘读取 index.html 文件,并将数据放入到内核缓冲区。
  • 发生第二次数据拷贝,即:将内核缓冲区的数据拷贝到用户缓冲区,同时,发生了一次用内核态到用户态的上下文切换。
  • 发生第三次数据拷贝,调用 write 方法,系统将用户缓冲区的数据拷贝到 Socket 缓冲区。此时,又发生了一次用户态到内核态的上下文切换。
  • 第四次拷贝,数据异步的从 Socket 缓冲区,使用 DMA 引擎拷贝到网络协议引擎。这一段,不需要进行上下文切换。
  • write 方法返回,再次从内核态切换到用户态。

mmap

mmap 通过内存映射,将文件映射到内核缓冲区,同时,用户空间可以共享内核空间的数据。这样,在进行网络传输时,就可以减少内核空间到用户空间的拷贝次数。 mmap

如上图,user buffer 和 kernel buffer 共享 index.html。如果想把硬盘的 index.html 传输到网络中,再也不用拷贝到用户空间,再从用户空间拷贝到 Socket 缓冲区。

只需要从内核缓冲区拷贝到 Socket 缓冲区即可,这将减少一次内存拷贝(从 4 次变成了 3 次),但不减少上下文切换次数。

sendfile

Linux 2.1 版本 提供了 sendFile 函数,其基本原理如下,数据根本不经过用户态,直接从内核缓冲区进入到 Socket Buffer,同时由于和用户态完全无关,就减少了一次上下文切换。

sendfile 如上图,进行 sendFile 系统调用时,数据被 DMA 引擎从文件复制到内核缓冲区,接着从内核缓冲区进入到 Socket,这是没有上下文切换的,因为都在内核空间。最后,数据从 Socket 缓冲区进入到协议栈。此时,数据经过了 3 次拷贝,3 次上下文切换。

1
ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count);

系统调用 sendfile() 在代表输入文件的描述符 in_fd 和代表输出文件的描述符 out_fd 之间传送文件内容(字节)。描述符 out_fd 必须指向一个套接字,而 in_fd 指向的文件必须是可以 mmap 的。这些局限限制了 sendfile 的使用,使 sendfile 只能将数据从文件传递到套接字上,反之则不行。 使用 sendfile 不仅减少了数据拷贝的次数,还减少了上下文切换,数据传送始终只发生在 kernel space。

sendfile 进阶版(严格意义上的零拷贝)

Linux 在 2.4 版本中,做了一些修改,避免了从内核缓冲区拷贝到 Socket buffer 的操作,直接拷贝到协议引擎,从而再一次减少了数据拷贝。具体如下图: sendfile进阶版

零拷贝技术通过 DMA (Direct Memory Access)技术将文件内容复制到内核模式下的 ReadBuffer 中。不过没有数据被复制到Socket Buffer,相反只有包含数据的位置和长度的信息的文件描述符被加到 Socket Buffer 中。DMA 引擎直接将数据从内核模式中传递到网卡设备(协议引擎)。这里数据只经历了 2 次复制就从磁盘中传送出去了, 并且上下文切换也变成了 2 次。零拷贝是针对内核模式而言的,数据在内核模式下实现了零拷贝。

#### mmap 和 sendFile 区别

  1. mmap 适合小数据量读写,sendFile 适合大文件传输
  2. mmap 需要 4 次上下文切换,3 次数据拷贝;sendFile 需要 3 次上下文切换,最少 2 次数据拷贝
  3. sendFile 可以利用 DMA 方式,减少 CPU 拷贝,mmap 则不能(必须从内核拷贝到 Socket 缓冲区)

在这个选择上:rocketMQ 在消费消息时,使用了 mmap。kafka 使用了 sendFile。

splice

Linux 在 2.6.17 版本引入 splice 系统调用,用于在两个文件描述符中移动数据:

1
ssize_t splice(int fd_in, loff_t *off_in, int fd_out, loff_t *off_out, size_t len, unsigned int flags);

splice 调用在两个文件描述符之间移动数据,而不需要数据在内核空间和用户空间来回拷贝。它从 fd_in 拷贝 len 长度的数据到 fd_out,但是有一方必须是管道设备,这也是目前 splice 的一些局限性。flags 参数有以下几种取值:

  • SPLICE_F_MOVE :尝试去移动数据而不是拷贝数据。这仅仅是对内核的一个小提示:如果内核不能从 pipe 移动数据或者 pipe 的缓存不是一个整页面,仍然需要拷贝数据。Linux 最初的实现有些问题,所以从 2.6.21 开始这个选项不起作用,后面的 Linux 版本应该会实现。
  • SPLICE_F_NONBLOCK :splice 操作不会被阻塞。然而,如果文件描述符没有被设置为不可被阻塞方式的 I/O ,那么调用 splice 有可能仍然被阻塞。
  • SPLICE_F_MORE: 后面的 splice 调用会有更多的数据。

splice 调用利用了 Linux 提出的管道缓冲区机制, 所以至少一个描述符要为管道。

参考文献

什么是零拷贝?mmap与sendFile的区别是什么?