Skip to content

1. 零拷贝

针对优化磁盘的技术非常的多,比如零拷贝、直接 I/O、异步 I/O 等等,这些优化的目的就是为了提高系统的吞吐量,另外操作系统内核中的磁盘高速缓存区,可以有效的减少磁盘的访问次数。

1.1 DMA

在没有 DMA 技术前,I/O 的过程是这样的:

  1. CPU发出对应的指令给磁盘控制器,然后返回
  2. 磁盘控制器收到指令后,开始准备数据,会把数据放入到磁盘控制器的内部缓冲区中,然后产生一个中断
  3. CPU收到中断信号后,停下当前线程,接着把磁盘控制器的缓冲区的数据一次一个字节地读进自己的寄存器(内核缓冲区,页缓存,PageCache),然后再把寄存器里面的数据写入内存,在数据传输期间CPU无法执行其它任务

直接内存访问 DMA(Direct Memory Access)在进行 I/O 设备和内存的数据传输的时候,数据搬运的工作全部交给 DMA 控制器,而 CPU 不再参与任何与数据搬运相关的事情,这样 CPU 就可以去处理别的事务

具体过程:

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

可以看到,CPU 不再参与「将数据从磁盘控制器缓冲区搬运到内核空间」的工作,这部分工作由 DMA 完成,但是 CPU 在这个过程中也是必不可少的,因为传输什么数据,从哪里传输到哪里,都需要 CPU 来告诉 DMA 控制器。

每个 I/O 设备里面都有自己的 DMA 控制器。

1.2 传统的文件传输

传统 I/O 的工作方式是,数据读取和写入是从用户空间到内核空间来回复制,而内核空间的数据是通过操作系统层面的 I/O 接口从磁盘读取或写入。

首先,期间共发生了 4 次用户态与内核态的上下文切换,因为发生了两次系统调用,一次是 read() ,一次是 write(),每次系统调用都得先从用户态切换到内核态,等内核完成任务后,再从内核态切换回用户态。

上下文切换到成本并不小,一次切换需要耗时几十纳秒到几微秒,虽然时间看上去很短,但是在高并发的场景下,这类时间容易被累积和放大,从而影响系统的性能。

其次,还发生了 4 次数据拷贝,其中两次是 DMA 的拷贝,另外两次则是通过 CPU 拷贝的:

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

1.3 如何优化文件传输的性能

由上节可知,要想提高文件传输的性能,就需要减少用户态与内核态的上下文切换内存拷贝的次数。

1.3.1 用户态与内核态的上下文切换

读取磁盘数据的时候,之所以要发生上下文切换,这是因为用户空间没有权限操作磁盘或网卡,内核的权限最高,这些操作设备的过程都需要交由操作系统内核来完成,所以一般要通过内核去完成某些任务的时候,就需要使用操作系统提供的系统调用函数。

而一次系统调用必然会发生 2 次上下文切换:首先从用户态切换到内核态,当内核执行完任务后,再切换回用户态交由进程代码执行。

所以,要想减少上下文切换到次数,就要减少系统调用的次数

1.3.2 数据拷贝

传统的文件传输方式会历经 4 次数据拷贝,而且这里面,「从内核的读缓冲区拷贝到用户的缓冲区里,再从用户的缓冲区里拷贝到 socket 的缓冲区里」,这个过程是没有必要的。

因为文件传输的应用场景中,在用户空间我们并不会对数据「再加工」,所以数据实际上可以不用搬运到用户空间,因此用户的缓冲区是没有必要存在的

1.4 实现零拷贝

零拷贝技术实现的方式通常有 2 种:

  • mmap + write
  • sendfile

1.4.1 mmap + write

read() 系统调用的过程中会把内核缓冲区的数据拷贝到用户的缓冲区里,于是为了减少这一步开销,我们可以用 mmap() 替换 read() 系统调用函数。

buf = mmap(file, len);
write(sockfd, buf, len);

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

应用进程再调用 write()时,操作系统直接将内核缓冲区的数据拷贝到 socket 缓冲区中,这一切都发生在内核态,由 CPU 来搬运数据

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

1.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 次数据拷贝。

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

查看网卡是否支持 scatter-gather 特性:

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

1.4.3 真正的零拷贝

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

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

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

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

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

1.5 PageCache的作用

文件传输过程中,其中第一步都是先需要先把磁盘文件数据拷贝「内核缓冲区」里,这个「内核缓冲区」实际上是磁盘高速缓存(Disk Cache)中的页缓存(PageCache)

Disk Cache 在 Linux 中有三种类型:

  1. dentry cache (目录项缓存)
  2. Page cache
  3. Buffer cache

读写磁盘相比读写内存的速度慢太多了,所以我们应该想办法把「读写磁盘」替换成「读写内存」。于是,我们会通过 DMA 把磁盘里的数据搬运到内存里,这样就可以用读内存替换读磁盘。 但是,内存空间远比磁盘要小,内存注定只能拷贝磁盘里的一小部分数据。

根据局部性原理,可以用 PageCache 来缓存最近被访问的数据,当空间不足时淘汰最久未被访问的缓存

所以,读磁盘数据的时候,优先在 PageCache 找,如果数据存在则可以直接返回;如果没有,则从磁盘中读取,然后缓存 PageCache 中。

PageCache 的优点主要是两个:

  • 缓存最近被访问的数据;
  • 预读功能;

但是,在传输大文件(GB 级别的文件)的时候,PageCache 会不起作用,白白浪费 DMA 多做的一次数据拷贝,造成性能的降低,即使使用了 PageCache 的零拷贝也会损失性能

原因:

  • PageCache 空间很快被这些大文件占满
  • 由于文件太大,可能某些部分的文件数据被再次访问的概率比较低,导致下面两个问题:
    • PageCache 由于长时间被大文件占据,其他「热点」的小文件可能就无法充分使用到 PageCache,于是这样磁盘读写的性能就会下降了;
    • PageCache 中的大文件数据,由于没有享受到缓存带来的好处,但却耗费 DMA 多拷贝到 PageCache 一次;

1.6 大文件传输

先看看最初的例子: 进程实际上会阻塞在 read 方法调用,因为要等待磁盘数据的返回。

对于阻塞的问题,可以用异步 I/O 来解决:

它把读操作分为两部分:

  • 前半部分,内核向磁盘发起读请求,但是可以不等待数据就位就可以返回,于是进程此时可以处理其他任务;
  • 后半部分,当内核将磁盘中的数据拷贝到进程缓冲区后,进程将接收到内核的通知,再去处理数据;

绕开 PageCache 的 I/O 叫直接 I/O,使用 PageCache 的 I/O 则叫缓存 I/O。通常,对于磁盘,异步 I/O 只支持直接 I/O。

在高并发的场景下,针对大文件的传输的方式,应该使用「异步 I/O + 直接 I/O」来替代零拷贝技术

所以,传输文件的时候,我们要根据文件的大小来使用不同的方式:

  • 传输大文件的时候,使用「异步 I/O + 直接 I/O」;
  • 传输小文件的时候,则使用「零拷贝技术」;

【ChatGPT】: 异步 I/O(Asynchronous I/O)是指应用程序在发起 I/O 操作之后,不必等待 I/O 操作完成就可以继续执行其他任务。当 I/O 操作完成后,操作系统会通知应用程序,应用程序可以通过回调函数或其他方式获取 I/O 操作的结果。异步 I/O 通常使用操作系统提供的 aio 系列函数进行实现,例如 aio_read()、aio_write() 等。

异步 I/O 的优点在于可以提高应用程序的并发性和响应性能,并且可以减少 CPU 的等待时间,从而提高 CPU 利用率。但是,异步 I/O 的缺点在于编程复杂度比较高,需要较多的代码处理回调函数、事件通知等。

直接 I/O(Direct I/O),也称为无缓存 I/O(Cache Bypassing I/O),是指应用程序直接在文件和磁盘之间进行数据传输,跳过内核页缓存,将数据直接读取或写入磁盘。直接 I/O 通常使用 O_DIRECT 标志进行实现,例如 open()、read()、write() 等函数中的 O_DIRECT 参数。

直接 I/O 的优点在于可以避免使用页缓存的额外开销,从而减少 CPU 和内存的开销,并且可以提高数据的一致性和实时性能。但是,直接 I/O 的缺点在于对磁盘的访问模式较为严格,必须保证读写操作的大小是块大小(通常为 512B 或 4KB)的倍数,否则可能会导致额外的开销和低效率。