浅谈零拷贝

554 阅读9分钟

image.png

引言

磁盘相比于内存其速率慢了很多,读写速度相差10倍以上,在某些环境里,硬盘和内存之间的速度差距可能会更大。为了提升磁盘的性能,有很多技术手段,譬如零拷贝、直接 I/O、异步 I/O 等等、所谓的零拷贝是指将数据直接从磁盘文件复制到网卡设备中,而不需要经过应用程序,减少用户态和内核态的切换。

DMA(Direct Memory Access,直接存储器访问)

在介绍零拷贝之前先了解一下为什么要有DMA这个东西。

未使用DMA

我们先看看未使用DMA技术的I/O操作流程

image.png

  1. 应用程序向CPU发送read()调用;
  2. CPU向磁盘发起IO操作,磁盘控制器收到指令后,于是就开始准备数据,会把数据放入到磁盘控制器的内部缓冲区中
  3. 磁盘控制器向CPU发起IO中断信号;
  4. CPU将数据从磁盘控制器缓冲区拷贝到PageCache;
  5. CPU将数据从PageCache拷贝到用户缓存区;
  6. CPU返回read()调用; 在这期间CPU是没办法执行其他任务的,可想而知CPU这么宝贵的资源一直这样阻塞是多么浪费

使用DMA

image.png 具体流程:

  1. 用户进程调用 read 方法,向操作系统发出 I/O 请求,请求读取数据到自己的内存缓冲区中,进程进入阻塞状态;
  2. 操作系统收到请求后,进一步将 I/O 请求发送 DMA,然后让 CPU 执行其他任务;
  3. DMA 进一步将 I/O 请求发送给磁盘;
  4. 磁盘收到 DMA 的 I/O 请求,把数据从磁盘读取到磁盘控制器的缓冲区中,当磁盘控制器的缓冲区被读满后,向 DMA 发起中断信号,告知自己缓冲区已满;
  5. DMA 收到磁盘的信号,将磁盘控制器缓冲区中的数据拷贝到内核缓冲区中,此时不占用 CPU,CPU 可以执行其他任务
  6. 当 DMA 读取了足够多的数据,就会发送中断信号给 CPU;
  7. CPU 收到 DMA 的信号,知道数据已经准备好,于是将数据从内核拷贝到用户空间,系统调用返回;

可以看到, 整个数据传输的过程,CPU 不再参与数据搬运的工作,而是全程由 DMA 完成,但是 CPU 在这个过程中也是必不可少的,因为传输什么数据,从哪里传输到哪里,都需要 CPU 来告诉 DMA 控制器。

早期 DRM 只存在在主板上,如今由于 I/O 设备越来越多,数据传输的需求也不尽相同,所以每个 I/O 设备里面都有自己的 DMA 控制器。

传统文件传输

传统文件传输方式又应用程序将磁盘上的文件读取到用户线程,然后通过网络协议发送给客户端。这种方式数据会在用户态和内核态来回切换复制,每次切换都是重量级的开销。

read(file, tmp_buf, len);
write(socket, tmp_buf, len);

image.png

根据上图可以看出,传统的IO操作:

  1. 发生了两次read()和两次write(),每次read和write都会发生用户态和内核态的切换,共发生了4次上下文切换。上下文切换成本是很高的,尽管每次耗时只有几十纳秒到几微秒,但是高并发场景下这样的消耗会被积累和放大,对系统的性能影响是巨大的。

  2. 发生了4次数据拷贝

  • 第一次拷贝:把磁盘上的数据拷贝到操作系统内核的缓冲区里,这个拷贝的过程是通过 DMA 搬运的。
  • 第二次拷贝:把内核缓冲区的数据拷贝到用户的缓冲区里,于是我们应用程序就可以使用这部分数据了,这个拷贝到过程是由 CPU 完成的。
  • 第三次拷贝:把刚才拷贝到用户的缓冲区里的数据,再拷贝到内核的 socket 的缓冲区里,这个过程依然还是由 CPU 搬运的。
  • 第四次拷贝:把内核的 socket 缓冲区里的数据,拷贝到网卡的缓冲区里,这个过程又是由 DMA 搬运的。

可以看出 ,传统的传输方式内核态和用户态的切换以及数据拷贝次数冗余,提高文件传输的性能,就需要减少用户态与内核态的上下文切换和内存拷贝的次数

零拷贝

实现方式

零拷贝的实现方式一般有两种:

 mmap + write
 sendfile

mmap + write

mmap() 系统调用函数会直接把内核缓冲区里的数据映射到用户空间,这样,操作系统内核与用户空间就不需要再进行任何的数据拷贝操作。

image.png

具体过程如下:

  • 应用进程调用了 mmap() 后,DMA 会把磁盘的数据拷贝到内核的缓冲区里。接着,应用进程跟操作系统内核「共享」这个缓冲区;
  • 应用进程再调用 write(),操作系统直接将内核缓冲区的数据拷贝到 socket 缓冲区中,这一切都发生在内核态,由 CPU 来搬运数据;
  • 最后,把内核的 socket 缓冲区里的数据,拷贝到网卡的缓冲区里,这个过程是由 DMA 搬运的。

我们可以得知,通过使用 mmap() 来代替 read(), 可以减少一次数据拷贝的过程。

但这还不是最理想的零拷贝,因为仍然需要通过 CPU 把内核缓冲区的数据拷贝到 socket 缓冲区里,而且仍然需要 4 次上下文切换,因为系统调用还是 2 次。

sendfile

在 Linux 内核版本 2.1 中,提供了一个专门发送文件的系统调用函数 sendfile(),函数形式如下:

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

它的前两个参数分别是目的端和源端的文件描述符,后面两个参数是源端的偏移量和复制数据的长度,返回值是实际复制数据的长度。

首先,它可以替代前面的 read()write() 这两个系统调用,这样就可以减少一次系统调用,也就减少了 2 次上下文切换的开销。

其次,该系统调用,可以直接把内核缓冲区里的数据拷贝到 socket 缓冲区里,不再拷贝到用户态,这样就只有 2 次上下文切换,和 3 次数据拷贝。如下图:

image.png 但是这还不是真正的零拷贝技术,如果网卡支持 SG-DMA(The Scatter-Gather Direct Memory Access)技术(和普通的 DMA 有所不同),我们可以进一步减少通过 CPU 把内核缓冲区里的数据拷贝到 socket 缓冲区的过程。

你可以在你的 Linux 系统通过下面这个命令,查看网卡是否支持 scatter-gather 特性:

$ ethtool -k eth0 | grep scatter-gather
scatter-gather: on

于是,从 Linux 内核 2.4 版本开始起,对于支持网卡支持 SG-DMA 技术的情况下, sendfile() 系统调用的过程发生了点变化,具体过程如下:

  • 第一步,通过 DMA 将磁盘上的数据拷贝到内核缓冲区里;
  • 第二步,缓冲区描述符和数据长度传到 socket 缓冲区,这样网卡的 SG-DMA 控制器就可以直接将内核缓存中的数据拷贝到网卡的缓冲区里,此过程不需要将数据从操作系统内核缓冲区拷贝到 socket 缓冲区中,这样就减少了一次数据拷贝;

所以,这个过程之中,只进行了 2 次数据拷贝,如下图:

image.png

这就是所谓的零拷贝(Zero-copy)技术,因为我们没有在内存层面去拷贝数据,也就是说全程没有通过 CPU 来搬运数据,所有的数据都是通过 DMA 来进行传输的。

零拷贝技术的文件传输方式相比传统文件传输的方式,减少了 2 次上下文切换和数据拷贝次数,只需要 2 次上下文切换和数据拷贝次数,就可以完成文件的传输,而且 2 次的数据拷贝过程,都不需要通过 CPU,2 次都是由 DMA 来搬运。

所以,总体来看,零拷贝技术可以把文件传输的性能提高至少一倍以上

零拷贝技术的应用

Kafka

Kafka 处理海量数据之所以快的一个原因就是使用了零拷贝技术,从而大幅提升了 I/O 的吞吐率。

追溯 Kafka 文件传输的代码,最终它调用了 Java NIO 库里的 transferTo方法:

@Overridepublic 
long transferFrom(FileChannel fileChannel, long position, long count) throws IOException { 
    return fileChannel.transferTo(position, count, socketChannel);
}

如果 Linux 系统支持 sendfile() 系统调用,那么 transferTo() 实际上最后就会使用到 sendfile() 系统调用函数。

曾经有大佬专门写过程序测试过,在同样的硬件条件下,传统文件传输和零拷拷贝文件传输的性能差异,你可以看到下面这张测试数据图,使用了零拷贝能够缩短 65% 的时间,大幅度提升了机器传输数据的吞吐量。

image.png 注:数据来源于:developer.ibm.com/articles/j-…

Nginx

Nginx 也支持零拷贝技术,一般默认是开启零拷贝技术,这样有利于提高文件传输的效率,是否开启零拷贝技术的配置如下:

http {
...
    sendfile on
...
}

sendfile 配置的具体意思:

  • 设置为 on 表示,使用零拷贝技术来传输文件:sendfile ,这样只需要 2 次上下文切换,和 2 次数据拷贝。
  • 设置为 off 表示,使用传统的文件传输技术:read + write,这时就需要 4 次上下文切换,和 4 次数据拷贝。

当然,要使用 sendfile,Linux 内核版本必须要 2.1 以上的版本。

参考: 原来 8 张图,就可以搞懂「零拷贝」了