Linux 内核将所有外部设备都看做一个文件来操作,对一个文件的读写操作会调用内核提供的系统命令,返回一个 file descriptor(文件描述符)。而对一个 socket 的读写也会有相应的描述符,称为 socketfd(socket 描述符),描述符就是一个数字,它指向内核中的一个结构体(文件路径、数据区等一些属性)。

传统 I/O

传统的 Linux 操作系统的标准 I/O 接口是基于数据拷贝操作的,即 I/O 操作会导致数据在操作系统内核地址空间的缓冲区和应用程序地址空间定义的缓冲区之间进行传输。这样做最大的好处是可以减少磁盘 I/O 操作,因为如果所请求的数据已经存放在操作系统的高速缓冲存储器中,那就不需要再进行实际的物理磁盘 I/O 操作。但是数据传输过程中的数据拷贝操作却导致了极大的 CPU 开销,限制了操作系统有效进行数据传输操作的能力。

比如,传统的系统调用如下:

  • File.read(file, buf, len);
  • Socket.send(socket, buf, len);

在没有任何优化技术使用的背景下,操作系统为此会进行 4 次数据拷贝以及 4 次上下文切换,如下图所示:
image.png
如果没有优化,读取磁盘数据,再通过网卡传输的场景性能会比较差,因为要经过 4 次数据复制,并且 CPU 还需要全程负责拷贝,占用较多的 CPU 资源。

DMA 参与下的四次拷贝

DMA 技术本质上就是我们在主板上放一块独立的芯片,在进行内存和 I/O 设备的数据传输的时候,我们不再通过 CPU 来控制数据传输,而直接通过 DMA 控制器,以此减轻 CPU 的负担。DMA 技术使得内存与其他组件,例如磁盘、网卡进行数据拷贝时,CPU 仅仅需要发出控制信号,而拷贝数据的过程则由 DMA 负责完成。

比如,当我们要传输的数据特别大、速度特别快,或者传输的数据特别小、速度特别慢的时候。如果都用 CPU 来负责的话肯定忙不过来,所以可以选择 DMA 控制器。当数据传输很慢的时候,DMA 控制器可以等数据到齐后再发送信号,给到 CPU 去处理,而不是让 CPU 在那里一直等待。

我们可以把 DMA 控制器芯片认为是 CPU 的协处理器,用于协助 CPU 完成对应的数据传输工作。在 DMA 控制数据传输的过程中,我们还是需要 CPU 进行控制,但是具体数据的拷贝不再由 CPU 来完成。在使用 DMA 技术之前,计算机所有组件之间的数据拷贝必须经过 CPU,如下图所示:
image.png
现在,DMA 代替了 CPU 负责内存与磁盘以及内存与网卡之间的数据搬运,CPU 作为 DMA 的控制者,具体如下图所示:
image.png
但是 DMA 仅能用于设备之间交换数据时进行数据拷贝,设备内部的数据拷贝还需要 CPU 进行,例如 CPU 需要负责内核空间数据与用户空间数据之间的拷贝(内存内部的拷贝),如下图所示:
image.png
从图中可以看出,在这种传统的数据传输过程中,数据发生了四次拷贝操作,即便是使用了 DMA 来进行与硬件的通讯,但 CPU 仍需要访问数据两次。

零拷贝技术

零拷贝技术是一个思想,指的是指计算机执行操作时,CPU 不需要先将数据从内存复制到某处特定区域。但零拷贝不是不进行拷贝,而是 CPU 不再全程负责数据拷贝时的搬运工作。如果数据本身不在内存中,那么必须先通过某种方式拷贝到内存中(这个过程可通过 DMA 参与)才能被 CPU 直接读取计算。

零拷贝(zero-copy)技术可以有效地改善数据传输的性能,在内核驱动程序(比如网络堆栈或者磁盘存储驱动程序)处理 I/O 数据时,零拷贝技术可以在某种程度上减少甚至完全避免不必要的 CPU 数据拷贝操作,使得在数据拷贝进行的同时,允许 CPU 执行其他的任务以提升系统性能。

综上所述,零拷贝技术的目标可以概括如下:

  • 避免操作系统内核缓冲区之间进行数据拷贝操作(内核 -> 内核)。
  • 避免操作系统内核和用户应用程序地址空间这两者之间进行数据拷贝操作(内核 -> 用户)。
  • 用户应用程序可以避开操作系统直接访问硬件存储(Direct IO)。
  • 数据传输尽量让 DMA 来做。


零拷贝技术的具体实现方式也有很多,不同的零拷贝技术适用于不同的应用场景:

1. sendfile

Linux 在 2.1 版本中引入了 sendfile() 这个系统调用,用于代替 read、write 系统调用,适用于用户从磁盘读取一些文件数据后不需要经过任何处理就通过网络传输出去的场景,典型案例是消息队列。

sendfile 主要使用到了两个技术:

  • DMA 技术
  • 传递文件描述符以代替数据拷贝

下面依次讲解这两个技术的作用。

1.1 利用 DMA 技术

sendfile 依赖于 DMA 技术,将四次 CPU 全程负责的拷贝与四次上下文切换减少到两次,如下图所示:
image.png
DMA 负责磁盘到内核空间中的 Page cache(read buffer)的数据拷贝以及从内核空间中的 socket buffer 到网卡的数据拷贝。

1.2 传递文件描述符代替数据拷贝

传递文件描述可以代替数据拷贝,这是由于两个原因:

  • page cache 以及 socket buffer 都在内核空间中;
  • 数据传输过程前后没有任何写操作;

因此,最终通过 sendfile 系统调用执行的复制过程如下图所示:
image.png
注意事项:只有网卡支持 SG-DMA(The Scatter-Gather Direct Memory Access)技术才可以通过传递文件描述符的方式避免内核空间内的一次 CPU 拷贝。这意味着此优化取决于 Linux 系统的物理网卡是否支持。
image.png
由于 sendfile 仅对应一次系统调用,而传统文件操作则需要使用 read 及 write 两个系统调用,因此 sendfile 才能够将用户态与内核态之间的上下文切换从 4 次降到 2 次。此外,如果应用程序需要对从磁盘读取的数据进行写操作,例如解密或加密,那么 sendfile 系统调用就不适用,因为用户线程根本就不能够通过 sendfile 系统调用得到传输的数据。

在 Java 中,FileChannle 类提供的 transferTo() 方法底层就是调用的 sendfile 系统调用。

2. mmap

mmap 即 memory map,也就是内存映射。

mmap 是一种内存映射文件的方法,即将一个文件或其它对象映射到进程的地址空间,实现文件磁盘地址和进程虚拟地址空间中一段虚拟地址的一一对映关系。实现该映射关系后,进程就可以采用指针的方式读写操作这一段内存,而系统会自动回写脏页面到对应的文件磁盘上,即完成了对文件的操作而不必再调用 read、write 等系统调用函数。相反,内核空间对这段区域的修改也直接反映用户空间,从而可以实现不同进程间的文件共享。

如下图所示:
image.png
mmap 具有如下的特点:

  • mmap 向应用程序提供的内存访问的地址是连续的,但对应磁盘文件的 block 可以不是地址连续的。

  • mmap 提供的内存空间是虚拟空间(虚拟内存)而不是物理空间(物理内存),因此完全可以分配远远大于物理内存大小的虚拟空间(例如 16G 内存主机分配 1000G 的 mmap 内存空间)。

  • mmap 对同一个文件地址的映射将被所有线程共享,操作系统确保线程安全以及线程可见性。

mmap 的设计很有启发性。基于磁盘的读写单位是 block(一般为 4KB),而基于内存的读写单位是地址(虽然内存的管理与分配单位是 4KB)。即 CPU 进行一次磁盘读写操作涉及的数据量至少是 4KB,但是进行一次内存操作涉及的数据量是基于地址的,也就是通常的 64bit(64 位操作系统)。mmap 下进程可以采用指针的方式进行读写操作,这是值得注意的。

2.1 mmap 的 IO 模型

image.png
利用 mmap() 系统调用来代替 read(),配合 write() 调用的整个流程如下:

  • 用户进程调用 mmap() 后从用户态陷入内核态,DMA 控制器会先将数据从硬盘拷贝到内核缓冲区(其使用了 Page Cache 机制)中,然后操作系统将内核缓冲区映射到用户缓存区后 mmap() 返回,上下文从内核态切换回用户态,整个过程不涉及内核空间和用户空间的数据拷贝。

  • 用户进程调用 write() 尝试把文件数据写到内核里的套接字缓冲区,再次陷入内核态。CPU 会将内核缓冲区中的数据拷贝到的套接字缓冲区中,然后 DMA 控制器将数据从套接字缓冲区拷贝到网卡完成数据传输后 write() 返回,上下文从内核态切换回用户态。

整个过程产生的系统消耗是:

  • 3 次数据复制过程
  • 4 次应用程序与内核之间上下文切换

通过使用 mmap() 来代替 read() 已经可以减半操作系统需要进行数据拷贝的次数,但 mmap 仅仅能够避免内核空间到用户空间的全程 CPU 负责的数据拷贝,内核空间内部还是需要全程 CPU 来负责的数据拷贝,当大量数据需要传输时会有一个比较好的效率。并且用户空间的 mmap file 使用的是虚拟内存,实际上并不占据物理内存,只有在内核空间的 kernel buffer cache 才占据实际的物理内存。

2.2 mmap 的优势

在用户空间看来,通过 mmap 机制后,磁盘上的文件仿佛直接就在内存中,把访问磁盘文件简化为按地址访问内存。这样应用程序就不需要使用文件系统的 write、read、fsync 等系统调用,只需面向内存的虚拟空间开发。但这并不意味着我们不需要这些系统调用了,只是这些系统调用由操作系统在 mmap 机制内部封装好了。

出于节约物理内存以及 mmap 方法快速返回的目的,mmap 映射采用懒加载机制。通过 mmap 申请 1000G 内存可能仅仅占用了 100MB 的虚拟内存空间,甚至没有分配实际的物理内存空间。当访问相关内存地址时才会进行真正的 write、read 等系统调用。CPU 会通过陷入缺页异常的方式来将磁盘上的数据加载到物理内存中,此时才会发生真正的物理内存分配。

当发生数据修改时,内存出现脏页,与磁盘文件出现不一致。mmap 机制下由操作系统自动完成内存数据的落盘,将脏页回刷,用户进程通常并不需要手动管理数据落盘。

使用 mmap 是 POSIX 兼容的,但是使用 mmap 并不一定能获得理想的数据传输性能。数据传输的过程中仍然需要一次 CPU 拷贝操作,而且映射操作也是一个开销很大的虚拟存储操作。但是,因为映射通常适用于较大范围,所以对于相同长度的数据来说,映射所带来的开销远远低于 CPU 拷贝所带来的开销。

2.3 mmap 的缺陷

mmap 不是银弹,这意味着 mmap 也有其缺陷,在相关场景下的性能存在缺陷:

由于 mmap 使用时必须实现指定好内存映射的大小,因此 mmap 并不适合变长文件。并且如果更新文件的操作很多,mmap 避免两态拷贝的优势就被摊还,最终还是落在了大量的脏页回写及由此引发的随机 I/O 上,所以在随机写很多的情况下,mmap 在效率上不一定会比带缓冲区的一般写快。

读/写小文件(例如 16K 以下的文件),mmap 与通过 read 系统调用相比有着更高的开销与延迟;同时 mmap 的刷盘由系统全权控制,但是在小数据量的情况下由应用本身手动控制更好。

mmap 受限于操作系统内存大小:例如在 32-bits 的操作系统上,虚拟内存总大小也就 4GB,但由于 mmap 必须要在内存中找到一块连续的地址块,此时你就无法对 4GB 大小的文件完全进行 mmap,在这种情况下你必须分多块分别进行 mmap,但是此时地址内存地址已经不再连续,使用 mmap 的意义大打折扣,而且引入了额外的复杂性。

在 Java 中,FileChannle 类提供的 map() 方法底层就是调用的 mmap 系统调用。

典型案例

Kafka 作为一个消息队列,涉及到磁盘 I/O 主要有两个操作:

  • Provider 向 Kakfa 发送消息,Kakfa 负责将消息以日志的方式持久化落盘
  • Consumer 向 Kakfa 进行拉取消息,Kafka 负责从磁盘中读取一批日志消息,然后再通过网卡发送

Kakfa 服务端接收 Provider 的消息并持久化的场景下使用 mmap 机制,注意,日志写入时采用的是顺序追加的方式,能够基于顺序磁盘 I/O 提供高效的持久化能力;而索引文件采用的才是 mmap 机制,使用的 Java 类为 MappedByteBuffer。

Kakfa 服务端向 Consumer 发送消息的场景下使用 sendfile 机制,这种机制主要两个好处:

  • sendfile 避免了内核空间到用户空间的 CPU 全程负责的数据移动;

  • sendfile 基于 Page Cache 实现,因此如果有多个 Consumer 在同时消费一个主题的消息,那么由于消息一直在 page cache 中进行了缓存,因此只需一次磁盘 I/O 就可以服务于多个 Consumer;

使用 mmap 来对接收到的数据进行持久化,使用 sendfile 从持久化介质中读取数据然后对外发送。这是一对常用的组合拳。