零拷贝(Zero-copy) 浅析及其应用

时间:2024-01-27 07:25:23

相信大家都有过面试经历,如果跟面试官聊到了操作系统,聊到了文件操作,可能会问你普通的文件读写流程,它有什么缺点,你知道有什么改进的措施。我们经常听说 零拷贝,每次可能只是背诵一些面试要点就过去了,今天我们就从文件读写说起一步一步深入零拷贝。

Linux 文件系统简介

说到文件读写,为了增强代入感我们还是先回顾或者说是了解一下基本的 Linux 内核相关知识。

系统调用

操作系统的主要功能是为管理硬件资源和为应用程序开发人员提供良好的环境,但是计算机系统的各种硬件资源是有限的,因此为了保证每一个进程都能安全的执行。处理器设有两种模式:用户模式内核模式。一些容易发生安全问题的操作都被限制在只有内核模式下才可以执行,例如 I/O 操作,修改基址寄存器内容等。

当我们处在用户态但是却不得不调用内核态下一些操作的时候这时候可以利用Linux提供的一些转换接口唤起操作,而连接用户模式和内核模式的接口称之为 系统调用

应用程序代码运行在用户模式下,当应用程序需要实现内核模式下的指令时,先向操作系统发送调用请求。操作系统收到请求后,执行系统调用接口,使处理器进入内核模式。当处理器处理完系统调用操作后,操作系统会让处理器返回用户模式,继续执行用户代码。

进程的虚拟地址空间可分为两部分,内核空间用户空间。内核空间中存放的是内核代码和数据,而进程的用户空间中存放的是用户程序的代码和数据。不管是内核空间还是用户空间,它们都处于虚拟空间中,都是对物理地址的映射

虚拟文件系统

一个操作系统可以支持多种底层不同的文件系统(比如 NTFS, FAT, ext3, ext4),为了给内核和用户进程提供统一的文件系统视图,Linux 在用户进程和底层文件系统之间加入了一个抽象层,即虚拟文件系统( Virtual File System, VFS ),进程所有的文件操作都通过 VFS,由 VFS 来适配各种底层不同的文件系统,完成实际的文件操作。

通俗的说,VFS 就是定义了一个通用文件系统的接口层和适配层,一方面为用户进程提供了一组统一的访问文件,目录和其他对象的统一方法,另一方面又要和不同的底层文件系统进行适配。如图所示:

1

虚拟文件系统主要模块
  1. 超级块(super_block),用于保存一个文件系统的所有元数据,相当于这个文件系统的信息库,为其他的模块提供信息。因此一个超级块可代表一个文件系统。文件系统的任意元数据修改都要修改超级块。超级块对象是常驻内存并被缓存的。
  2. 目录项模块,管理路径的目录项。比如一个路径 /usr/local/hello.txt,那么目录项有 usr, local, hello.txt。目录项的块,存储的是这个目录下的所有的文件的 inode 号 和 文件名 等信息。其内部是树形结构,操作系统检索一个文件,都是从根目录开始,按层次解析路径中的所有目录,直到定位到文件。
  3. inode 模块,管理一个具体的文件,是文件的唯一标识,一个文件对应一个 inode。通过 inode 可以方便的找到文件在磁盘扇区的位置。同时 inode 模块可链接到 address_space 模块,方便查找自身文件数据是否已经缓存。
  4. 打开文件列表模块,包含所有内核已经打开的文件。已经打开的文件对象由 open 系统调用在内核中创建,也叫文件句柄。打开文件列表模块中包含一个列表,每个列表表项是一个结构体 struct file,结构体中的信息用来表示打开的一个文件的各种状态参数。
  5. file_operations 模块。这个模块中维护一个数据结构,是一系列函数指针的集合,其中包含所有可以使用的系统调用函数,例如 open、read、write、mmap 等。每个打开文件(打开文件列表模块的一个表项)都可以连接到 file_operations 模块,从而对任何已打开的文件,通过系统调用函数,实现各种操作。
  6. address_space 模块,它表示一个文件在页缓存中已经缓存了的物理页。它是页缓存和外部设备中文件系统的桥梁。如果将文件系统可以理解成数据源,那么 address_space 可以说关联了内存系统和文件系统。我们会在后面继续讨论。

3

I/O 缓冲区

概念

如高速缓存(cache)产生的原理类似,在 I/O 过程中,读取磁盘的速度相对内存读取速度要慢的多。因此为了能够加快处理数据的速度,需要将读取过的数据缓存在内存里。而这些缓存在内存里的数据就是高速缓冲区(buffer cache),下面简称为 buffer

具体来说,buffer 是一个用于存储速度不同步的设备或优先级不同的设备之间传输数据的区域。一方面,通过缓冲区,可以使进程之间的相互等待变少,从而使从速度慢的设备读入数据时,速度快的设备的操作进程不发生间断。另一方面,可以保护硬盘或减少网络传输的次数。

Buffer 和 Cache

buffer 和 cache 是两个不同的概念:

cache 是高速缓存,用于 CPU 和内存之间的缓冲;

buffer是 I/O 缓存,用于内存和硬盘的缓冲。

简单的说,cache 是加速 ,而 buffer 是缓冲 ,前者解决读的问题,保存从磁盘上读出的数据,后者是解决写的问题,保存即将要写入到磁盘上的数据。

Buffer Cache和 Page Cache

buffer cache 和 page cache 都是为了处理设备和内存交互时高速访问的问题。

buffer cache可称为块缓冲器,page cache可称为页缓冲器。

在 Linux 不支持虚拟内存机制之前,还没有页的概念,因此缓冲区以块为单位对设备进行。在 Linux 采用虚拟内存的机制来管理内存后,页是虚拟内存管理的最小单位,开始采用页缓冲的机制来缓冲内存。Linux2.6 之后内核将这两个缓存整合,页和块可以相互映射,同时页缓存 page cache 面向的是虚拟内存,块 I/O 缓存 Buffer cache 是面向块设备。需要强调的是页缓存和块缓存对进程来说就是一个存储系统,进程不需要关注底层的设备的读写。

buffer cache 和page cache 两者最大的区别是缓存的粒度。buffer cache 面向的是文件系统的块,而内核的内存管理组件采用了比文件系统的块更高级别的抽象:页(page),其处理的性能更高。因此和内存管理交互的缓存组件,都使用页缓存。

Page Cache 页缓存是面向文件,面向内存的。通俗来说,它位于内存和文件之间缓冲区,文件 I/O 操作实际上只和 page cache 交互,不直接和内存交互。page cache 可以用在所有以文件为单元的场景下,比如网络文件系统等等。page cache 通过一系列的数据结构,比如 inode, address_space, struct page,实现将一个文件映射到页的级别:

  1. struct page 结构标志一个物理内存页,通过 page + offset 就可以将此页帧定位到一个文件中的具体位置。同时 struct page 还有以下重要参数:

    1. 标志位 flags 来记录该页是否是脏页,是否正在被写回等等;
    2. mapping 指向了地址空间 address_space,表示这个页是一个页缓存中的页,和一个文件的地址空间对应;
    3. index 记录这个页在文件中的页偏移量;
  2. 文件系统的 inode 实际维护了这个文件所有的( block )的块号,通过对文件偏移量 offset 取模可以很快定位到这个偏移量所在的文件系统的块号,磁盘的扇区号。同样,通过对文件偏移量 offset 进行取模可以计算出偏移量所在的页的偏移量。

  3. page cache 缓存组件抽象了地址空间 address_space 这个概念来作为文件系统和页缓存的中间桥梁。地址空间 address_space 通过指针可以方便的获取文件 inode 和 struct page 的信息,所以可以很方便地定位到一个文件的 offset 在各个组件中的位置,即通过:文件字节偏移量 --> 页偏移量 --> 文件系统块号 block --> 磁盘扇区号

  4. 页缓存实际上就是采用了一个基数树结构将一个文件的内容组织起来存放在物理内存 struct page 中。一个文件 inode 对应一个地址空间 address_space。而一个 address_space 对应一个页缓存基数树,它们之间的关系如下:
    2

文件读写基本流程

读文件

  1. 进程调用库函数向内核发起读文件请求;

  2. 内核通过检查进程的文件描述符定位到虚拟文件系统的已打开文件列表项;

  3. 调用该文件可用的系统调用函数 read()

  4. read() 函数通过文件表项链接到目录项模块,根据传入的文件路径,在目录项模块中检索,找到该文件的 inode

  5. inode 中,通过文件内容偏移量计算出要读取的页;

  6. 通过 inode 找到文件对应的 address_space

  7. address_space 中访问该文件的页缓存树,查找对应的页缓存结点:

    1. 如果页缓存命中,那么直接返回文件内容;
    2. 如果页缓存缺失,那么产生一个页缺失异常,创建一个页缓存页,同时通过inode 找到文件该页的磁盘地址,读取相应的页填充该缓存页;
    3. 重新进行第 6 步查找页缓存;
  8. 文件内容读取成功。

总结一下:inode 管磁盘,address_space 接内存,两者互相指针链接。

Inode 是文件系统(VFS)下的概念,通过 一个 inode 对应一个文件 使得文件管理按照类似索引的这种树形结构进行管理,通过 inode 快速的找到文件在磁盘扇区的位置;但是这种管理机制并不能满足读写的要求,因为我们修改文件的时候是先修改内存里的,所以就有了页缓存机制,作为内存与文件的缓冲区。
address_space 模块表示一个文件在页缓存中已经缓存了的物理页。它是页缓存和外部设备中文件系统的桥梁。如果将文件系统可以理解成数据源,那么 address_space 可以说关联了内存系统和文件系统。

写文件

前5步和读文件一致,在 address_space 中查询对应页的页缓存是否存在;

  1. 如果页缓存命中,直接把文件内容修改更新在页缓存的页中,写文件就结束了。这时候文件修改位于页缓存,并没有写回到磁盘文件中去。
  2. 如果页缓存缺失,那么产生一个页缺失异常,创建一个页缓存页,同时通过 inode 找到文件该页的磁盘地址,读取相应的页填充该缓存页。此时缓存页命中,进行第 6 步。
  3. 一个页缓存中的页如果被修改,那么会被标记成脏页,脏页需要写回到磁盘中的文件块。有两种方式可以把脏页写回磁盘:
    1. 手动调用 sync() 或者 fsync() 系统调用把脏页写回;
    2. pdflush 进程会定时把脏页写回到磁盘。

同时注意,脏页不能被置换出内存,如果脏页正在被写回,那么会被设置写回标记,这时候该页就被上锁,其他写请求被阻塞直到锁释放。

Linux I/O 读写方式

Linux 提供了轮询、I/O 中断以及 DMA 传输这 3 种磁盘与主存之间的数据传输机制。其中轮询方式是基于死循环对 I/O 端口进行不断检测。I/O 中断方式是指当数据到达时,磁盘主动向 CPU 发起中断请求,由 CPU 自身负责数据的传输过程。 DMA 传输则在 I/O 中断的基础上引入了 DMA 磁盘控制器,由 DMA 磁盘控制器负责数据的传输,降低了 I/O 中断操作对 CPU 资源的大量消耗。

I/O 中断

在 DMA 技术出现之前,应用程序与磁盘之间的 I/O 操作都是通过 CPU 的中断完成的。每次用户进程读取磁盘数据时,都需要 CPU 中断,然后发起 I/O 请求等待数据读取和拷贝完成,每次的 I/O 中断都导致 CPU 的上下文切换。

4

使用 I/O 中断方式读取数据步骤:

  1. 用户进程向 CPU 发起 read 系统调用读取数据,由用户态切换为内核态,然后一直阻塞等待数据的返回;
  2. CPU 在接收到指令以后对磁盘发起 I/O 请求,将磁盘数据先放入磁盘控制器缓冲区;
  3. 数据准备完成以后,磁盘向 CPU 发起 I/O 中断;
  4. CPU 收到 I/O 中断以后将磁盘缓冲区中的数据拷贝到内核缓冲区,然后再从内核缓冲区拷贝到用户缓冲区;
  5. 用户进程由内核态切换回用户态,解除阻塞状态,然后等待 CPU 的下一个执行时间钟。
DMA

DMA(Direct Memory Access)即直接存储器存取,是指外部设备不通过 CPU 而直接与系统内存交换数据的接口技术。

要把外设的数据读入内存或把内存的数据传送到外设,一般都要通过 CPU 控制完成,如 CPU 程序查询或中断方式。利用中断进行数据传送,可以大大提高 CPU 的利用率。但是采用中断传送有它的缺点,对于一个高速 I/O 设 备以及批量交换数据的情况,如果中断 I/O 操作带来的将是性能的损耗。对于这种类型的操作如果可以找一个第三方来执行数据拷贝而 I/O 还继续执行数据读取主流程任务是最好的。DMA 在外设与内存间直接进行数据交换,而不通过 CPU,这样数据传送的速度就取决于存储器和外设的工作速度。

通常系统的总线是由 CPU 管理的。在 DMA 方式时,就希望 CPU 把这些总线让出来,即 CPU 连到这些总线上的线处于第三态:高阻状态,而由 DMA 控制器接管,控制传送的字节数,判断 DMA 是否结束,以及发出 DMA 结束信号。DMA 控制器必须有以下功能:

  1. 能向 CPU 发出系统保持(HOLD)信号,提出总线接管请求;
  2. 当 CPU 发出允许接管信号后,负责对总线的控制,进入 DMA 方式;
  3. 能对存储器寻址及能修改地址指针,实现对内存的读写操作;
  4. 能决定本次 DMA 传送的字节数,判断 DMA 传送是否结束;
  5. 发出 DMA 结束信号,使 CPU 恢复正常工作状态。

有了DMA之后的数据读取方式就变了:

5

CPU 从繁重的 I/O 操作中解脱,数据读取操作的流程如下:

  1. 用户进程向 CPU 发起 read 系统调用读取数据,由用户态切换为内核态,然后一直阻塞等待数据的返回;
  2. CPU 在接收到指令以后对 DMA 磁盘控制器发起调度指令;
  3. DMA 磁盘控制器对磁盘发起 I/O 请求,将磁盘数据先放入磁盘控制器缓冲区,CPU 全程不参与此过程;
  4. 数据读取完成后,DMA 磁盘控制器会接受到磁盘的通知,将数据从磁盘控制器缓冲区拷贝到内核缓冲区;
  5. DMA 磁盘控制器向 CPU 发出数据读完的信号,由 CPU 负责将数据从内核缓冲区拷贝到用户缓冲区;
  6. 用户进程由内核态切换回用户态,解除阻塞状态,然后等待 CPU 的下一个执行时间钟。

传统 I/O 存在哪些问题

在 Linux 系统中,传统的访问方式是通过 write()read() 两个系统调用实现的,通过 read() 函数读取文件到到缓存区中,然后通过 write() 方法把缓存中的数据输出到网络端口,伪代码如下:

read(file_fd, tmp_buf, len);
write(socket_fd, tmp_buf, len);

图分别对应传统 I/O 操作的数据读写流程,整个过程涉及 2 次 CPU 拷贝、2 次 DMA 拷贝总共 4 次拷贝,以及 4 次上下文切换,下面简单地阐述一下相关的概念。

6

关键名词解释:

上下文切换:当用户程序向内核发起系统调用时,CPU 将用户进程从用户态切换到内核态;当系统调用返回时,CPU 将用户进程从内核态切换回用户态。

CPU 拷贝:由 CPU 直接处理数据的传送,数据拷贝时会一直占用 CPU 的资源。

DMA 拷贝:由 CPU 向 DMA 磁盘控制器下达指令,让 DMA 控制器来处理数据的传送,数据传送完毕再把信息反馈给 CPU,从而减轻了 CPU 资源的占有率。

当应用程序执行 read 系统调用读取一块数据的时候,如果这块数据已经存在于用户进程的页内存中,就直接从内存中读取数据;如果数据不存在,则先将数据从磁盘加载数据到内核空间的读缓存(read buffer)中,再从读缓存拷贝到用户进程的页内存中。

read(file_fd, tmp_buf, len);

基于传统的 I/O 读取方式,read 系统调用会触发 2 次上下文切换,1 次 DMA 拷贝和 1 次 CPU 拷贝,发起数据读取的流程如下:

  1. 用户进程通过read()函数向内核 (kernel) 发起系统调用,上下文从用户态 (user space) 切换为内核态 (kernel space);
  2. CPU 利用 DMA 控制器将数据从主存或硬盘拷贝到内核空间 (kernel space) 的读缓冲区 (read buffer);
  3. CPU 将读缓冲区 (read buffer) 中的数据拷贝到用户空间 (user space) 的用户缓冲区 (user buffer)。
  4. 上下文从内核态 (kernel space) 切换回用户态 (user space),read 调用执行返回。
传统写操作

当应用程序准备好数据,执行 write 系统调用发送网络数据时,先将数据从用户空间的页缓存拷贝到内核空间的网络缓冲区(socket buffer)中,然后再将写缓存中的数据拷贝到网卡设备完成数据发送。

write(socket_fd, tmp_buf, len);

基于传统的 I/O 写入方式,write() 系统调用会触发 2 次上下文切换,1 次 CPU 拷贝和 1 次 DMA 拷贝,用户程序发送网络数据的流程如下:

  1. 用户进程通过 write() 函数向内核 (kernel) 发起系统调用,上下文从用户态 (user space) 切换为内核态(kernel space)。
  2. CPU 将用户缓冲区 (user buffer) 中的数据拷贝到内核空间 (kernel space) 的网络缓冲区 (socket buffer)。
  3. CPU 利用 DMA 控制器将数据从网络缓冲区 (socket buffer) 拷贝到网卡进行数据传输。
  4. 上下文从内核态 (kernel space) 切换回用户态 (user space),write 系统调用执行返回。
零拷贝方式

在 Linux 中零拷贝技术主要有 3 个实现思路:用户态直接 I/O、减少数据拷贝次数以及写时复制技术。

  • 用户态直接 I/O:应用程序可以直接访问硬件存储,操作系统内核只是辅助数据传输。这种方式依旧存在用户空间和内核空间的上下文切换,硬件上的数据直接拷贝至了用户空间,不经过内核空间。因此,直接 I/O 不存在内核空间缓冲区和用户空间缓冲区之间的数据拷贝。
  • 减少数据拷贝次数:在数据传输过程中,避免数据在用户空间缓冲区和系统内核空间缓冲区之间的CPU拷贝,以及数据在系统内核空间内的CPU拷贝,这也是当前主流零拷贝技术的实现思路。
  • 写时复制技术:写时复制指的是当多个进程共享同一块数据时,如果其中一个进程需要对这份数据进行修改,那么将其拷贝到自己的进程地址空间中,如果只是数据读取操作则不需要进行拷贝操作。

13

用户态直接 I/O

用户态直接 I/O 使得应用进程或运行在用户态(user space)下的库函数直接访问硬件设备,数据直接跨过内核进行传输,内核在数据传输过程除了进行必要的虚拟存储配置工作之外,不参与任何其他工作,这种方式能够直接绕过内核,极大提高了性能。

7

缺点:

  1. 这种方法只能适用于那些不需要内核缓冲区处理的应用程序,这些应用程序通常在进程地址空间有自己的数据缓存机制,称为自缓存应用程序,如数据库管理系统就是一个代表。
  2. 这种方法直接操作磁盘 I/O,由于 CPU 和磁盘 I/O 之间的执行时间差距,会造成资源的浪费,解决这个问题需要和异步 I/O 结合使用。
mmap + write

一种零拷贝方式是使用 mmap + write 代替原来的 read + write 方式,减少了 1 次 CPU 拷贝操作。mmap 是 Linux 提供的一种内存映射文件方法,即将一个进程的地址空间中的一段虚拟地址映射到磁盘文件地址,mmap + write 的伪代码如下:

tmp_buf = mmap(file_fd, len);
write(socket_fd, tmp_buf, len);

使用 mmap 的目的是将内核中读缓冲区(read buffer)的地址与用户空间的缓冲区(user buffer)进行映射,从而实现内核缓冲区与应用程序内存的共享,省去了将数据从内核读缓冲区(read buffer)拷贝到用户缓冲区(user buffer)的过程,然而内核读缓冲区(read buffer)仍需将数据到内核写缓冲区(socket buffer),大致的流程如下图所示:

8

基于 mmap + write 系统调用的零拷贝方式,整个拷贝过程会发生 4 次上下文切换,1 次 CPU 拷贝和 2 次 DMA 拷贝,用户程序读写数据的流程如下:

  1. 用户进程通过 mmap() 函数向内核 (kernel) 发起系统调用,上下文从用户态 (user space) 切换为内核态(kernel space);
  2. 将用户进程的内核空间的读缓冲区 (read buffer) 与用户空间的缓存区 (user buffer) 进行内存地址映射;
  3. CPU 利用 DMA 控制器将数据从主存或硬盘拷贝到内核空间 (kernel space) 的读缓冲区 (read buffer);
  4. 上下文从内核态 (kernel space) 切换回用户态 (user space),mmap 系统调用执行返回;
  5. 用户进程通过 write() 函数向内核 (kernel) 发起系统调用,上下文从用户态 (user space) 切换为内核态(kernel space);
  6. CPU 将读缓冲区 (read buffer) 中的数据拷贝到的网络缓冲区 (socket buffer) ;
  7. CPU 利用 DMA 控制器将数据从网络缓冲区 (socket buffer) 拷贝到网卡进行数据传输;
  8. 上下文从内核态 (kernel space) 切换回用户态 (user space) ,write 系统调用执行返回;

缺陷:

mmap 主要的用处是提高 I/O 性能,特别是针对大文件。对于小文件,内存映射文件反而会导致碎片空间的浪费,因为内存映射总是要对齐页边界,最小单位是 4 KB,一个 5 KB 的文件将会映射占用 8 KB 内存,也就会浪费 3 KB 内存。

另外 mmap 隐藏着一个陷阱,当使用 mmap 映射一个文件时,如果这个文件被另一个进程所截获,那么 write 系统调用会因为访问非法地址被 SIGBUS 信号终止,SIGBUS 默认会杀死进程并产生一个 coredump,如果服务器被这样终止那损失就可能不小。

解决这个问题通常使用文件的租借锁:首先为文件申请一个租借锁,当其他进程想要截断这个文件时,内核会发送一个实时的 RT_SIGNAL_LEASE 信号,告诉当前进程有进程在试图破坏文件,这样 write 在被 SIGBUS 杀死之前,会被中断,返回已经写入的字节数,并设置 errno 为 success。

通常的做法是在 mmap 之前加锁,操作完之后解锁。

sendfile

sendfile 系统调用在 Linux 内核版本 2.1 中被引入,目的是简化通过网络在两个通道之间进行的数据传输过程。sendfile 系统调用的引入,不仅减少了 CPU 拷贝的次数,还减少了上下文切换的次数,它的伪代码如下:

sendfile(socket_fd, file_fd, len);

通过 sendfile 系统调用,数据可以直接在内核空间内部进行 I/O 传输,从而省去了数据在用户空间和内核空间之间的来回拷贝。与 mmap 内存映射方式不同的是, sendfile 调用中 I/O 数据对用户空间是完全不可见的。也就是说,这是一次完全意义上的数据传输过程。

9

基于 sendfile 系统调用的零拷贝方式,整个拷贝过程会发生 2 次上下文切换,1 次 CPU 拷贝和 2 次 DMA 拷贝,用户程序读写数据的流程如下:

  1. 用户进程通过 sendfile() 函数向内核 (kernel) 发起系统调用,上下文从用户态 (user space) 切换为内核态(kernel space)。
  2. CPU 利用 DMA 控制器将数据从主存或硬盘拷贝到内核空间 (kernel space) 的读缓冲区 (read buffer)。
  3. CPU 将读缓冲区 (read buffer) 中的数据拷贝到的网络缓冲区 (socket buffer)。
  4. CPU 利用 DMA 控制器将数据从网络缓冲区 (socket buffer) 拷贝到网卡进行数据传输。
  5. 上下文从内核态 (kernel space) 切换回用户态 (user space),sendfile 系统调用执行返回。

相比较于 mmap 内存映射的方式,sendfile 少了 2 次上下文切换,但是仍然有 1 次 CPU 拷贝操作。sendfile 存在的问题是用户程序不能对数据进行修改,而只是单纯地完成了一次数据传输过程。

缺点:

只能适用于那些不需要用户态处理的应用程序。

sendfile + DMA gather copy

常规 sendfile 还有一次内核态的拷贝操作,能不能也把这次拷贝给去掉呢?

答案就是这种 DMA 辅助的 sendfile。

Linux 2.4 版本的内核对 sendfile 系统调用进行修改,为 DMA 拷贝引入了 gather 操作。它将内核空间 (kernel space) 的读缓冲区 (read buffer) 中对应的数据描述信息 (内存地址、地址偏移量) 记录到相应的网络缓冲区( (socket buffer) 中,由 DMA 根据内存地址、地址偏移量将数据批量地从读缓冲区 (read buffer) 拷贝到网卡设备中,这样就省去了内核空间中仅剩的 1 次 CPU 拷贝操作,sendfile 的伪代码如下:

sendfile(socket_fd, file_fd, len);

在硬件的支持下,sendfile 拷贝方式不再从内核缓冲区的数据拷贝到 socket 缓冲区,取而代之的仅仅是缓冲区文件描述符和数据长度的拷贝,这样 DMA 引擎直接利用 gather 操作将页缓存中数据打包发送到网络中即可,本质就是和虚拟内存映射的思路类似。

10

基于 sendfile + DMA gather copy 系统调用的零拷贝方式,整个拷贝过程会发生 2 次上下文切换、0 次 CPU 拷贝以及 2 次 DMA 拷贝,用户程序读写数据的流程如下:

  1. 用户进程通过 sendfile() 函数向内核 (kernel) 发起系统调用,上下文从用户态 (user space) 切换为内核态(kernel space)。
  2. CPU 利用 DMA 控制器将数据从主存或硬盘拷贝到内核空间 (kernel space) 的读缓冲区 (read buffer)。
  3. CPU 把读缓冲区 (read buffer) 的文件描述符(file descriptor)和数据长度拷贝到网络缓冲区(socket buffer)。
  4. 基于已拷贝的文件描述符 (file descriptor) 和数据长度,CPU 利用 DMA 控制器的 gather/scatter 操作直接批量地将数据从内核的读缓冲区 (read buffer) 拷贝到网卡进行数据传输。
  5. 上下文从内核态 (kernel space) 切换回用户态 (user space),sendfile 系统调用执行返回。

sendfile + DMA gather copy 拷贝方式同样存在用户程序不能对数据进行修改的问题,而且本身需要硬件的支持,它只适用于将数据从文件拷贝到 socket 套接字上的传输过程。

splice

sendfile 只适用于将数据从文件拷贝到 socket 套接字上,同时需要硬件的支持,这也限定了它的使用范围。Linux 在 2.6.17 版本引入 splice 系统调用,不仅不需要硬件支持,还实现了两个文件描述符之间的数据零拷贝。splice 的伪代码如下:

splice(fd_in, off_in, fd_out, off_out, len, flags);

splice 系统调用可以在内核空间的读缓冲区 (read buffer) 和网络缓冲区 (socket buffer) 之间建立管道 (pipeline),从而避免了两者之间的 CPU 拷贝操作。

11

基于 splice 系统调用的零拷贝方式,整个拷贝过程会发生 2 次上下文切换,0 次 CPU 拷贝以及 2 次 DMA 拷贝,用户程序读写数据的流程如下:

  1. 用户进程通过 splice() 函数向内核(kernel)发起系统调用,上下文从用户态 (user space) 切换为内核态(kernel space);
  2. CPU 利用 DMA 控制器将数据从主存或硬盘拷贝到内核空间 (kernel space) 的读缓冲区 (read buffer);
  3. CPU 在内核空间的读缓冲区 (read buffer) 和网络缓冲区(socket buffer)之间建立管道 (pipeline);
  4. CPU 利用 DMA 控制器将数据从网络缓冲区 (socket buffer) 拷贝到网卡进行数据传输;
  5. 上下文从内核态 (kernel space) 切换回用户态 (user space),splice 系统调用执行返回。

splice 拷贝方式也同样存在用户程序不能对数据进行修改的问题。除此之外,它使用了 Linux 的管道缓冲机制,可以用于任意两个文件描述符中传输数据,但是它的两个文件描述符参数中有一个必须是管道设备。

写时复制

在某些情况下,内核缓冲区可能被多个进程所共享,如果某个进程想要这个共享区进行 write 操作,由于 write 不提供任何的锁操作,那么就会对共享区中的数据造成破坏,写时复制的引入就是 Linux 用来保护数据的。

写时复制指的是当多个进程共享同一块数据时,如果其中一个进程需要对这份数据进行修改,那么就需要将其拷贝到自己的进程地址空间中。这样做并不影响其他进程对这块数据的操作,每个进程要修改的时候才会进行拷贝,所以叫写时拷贝。这种方法在某种程度上能够降低系统开销,如果某个进程永远不会对所访问的数据进行更改,那么也就永远不需要拷贝。

缺点:

需要 MMU 的支持,MMU 需要知道进程地址空间中哪些页面是只读的,当需要往这些页面写数据时,发出一个异常给操作系统内核,内核会分配新的存储空间来供写入的需求。

缓冲区共享

缓冲区共享方式完全改写了传统的 I/O 操作,传统的 Linux I/O 接口支持数据在应用程序地址空间和操作系统内核之间交换,这种交换操作导致所有的数据都需要进行拷贝。

如果采用 fbufs 这种方法,需要交换的是包含数据的缓冲区,这样就消除了多余的拷贝操作。应用程序将 fbuf 传递给操作系统内核,这样就能减少传统的 write 系统调用所产生的数据拷贝开销。

同样的应用程序通过 fbuf 来接收数据,这样也可以减少传统 read 系统调用所产生的数据拷贝开销。

fbuf 的思想是每个进程都维护着一个缓冲区池,这个缓冲区池能被同时映射到用户空间 (user space) 和内核态 (kernel space),内核和用户共享这个缓冲区池,这样就避免了一系列的拷贝操作。

12

缺点:

缓冲区共享的难度在于管理共享缓冲区池需要应用程序、网络软件以及设备驱动程序之间的紧密合作,而且如何改写 API 目前还处于试验阶段并不成熟。

Linux零拷贝对比

无论是传统 I/O 拷贝方式还是引入零拷贝的方式,2 次 DMA Copy 是都少不了的,因为两次 DMA 都是依赖硬件完成的。下面从 CPU 拷贝次数、DMA 拷贝次数以及系统调用几个方面总结一下上述几种 I/O 拷贝方式的差别。

拷贝方式 CPU拷贝 DMA拷贝 系统调用 上下文切换
传统方式(read + write) 2 2 read / write 4
内存映射(mmap + write) 1 2 mmap / write 4
sendfile 1 2 sendfile 2
sendfile + DMA gather copy 0 2 sendfile 2
splice 0 2 splice 2

零拷贝应用

Java NIO 中的零拷贝 - MappedByteBuffer

MappedByteBuffer 是 NIO 基于内存映射 (mmap) 这种零拷贝方式的提供的一种实现,它继承自 ByteBuffer。FileChannel 定义了一个 map() 方法,它可以把一个文件从 position 位置开始的 size 大小的区域映射为内存映像文件。抽象方法 map() 方法在 FileChannel 中的定义如下:

public abstract MappedByteBuffer map(MapMode mode, long position, long size)
        throws IOException;
  • mode:限定内存映射区域(MappedByteBuffer)对内存映像文件的访问模式,包括只可读(READ_ONLY)、可读可写(READ_WRITE)和写时拷贝(PRIVATE)三种模式。
  • position:文件映射的起始地址,对应内存映射区域(MappedByteBuffer)的首地址。
  • size:文件映射的字节长度,从 position 往后的字节数,对应内存映射区域(MappedByteBuffer)的大小。

MappedByteBuffer 相比 ByteBuffer 新增了 fore()、load() 和 isLoad() 三个重要的方法:

  • fore():对于处于 READ_WRITE 模式下的缓冲区,把对缓冲区内容的修改强制刷新到本地文件。
  • load():将缓冲区的内容载入物理内存中,并返回这个缓冲区的引用。
  • isLoaded():如果缓冲区的内容在物理内存中,则返回 true,否则返回 false。

下面给出一个利用 MappedByteBuffer 对文件进行读写的使用示例:

private final static String CONTENT = "我要测试零拷贝写入数据";
private final static String FILE_NAME = "/Users/yangyue/Downloads/1.txt";

public static void main(String[] args) {

  Path path = Paths.get(FILE_NAME);
  byte[] bytes = CONTENT.getBytes(Charset.forName("UTF-8"));
  try (FileChannel fileChannel = FileChannel.open(path, StandardOpenOption.READ,
                                                  StandardOpenOption.WRITE, StandardOpenOption.TRUNCATE_EXISTING)) {
    MappedByteBuffer mappedByteBuffer = fileChannel.map(READ_WRITE, 0, bytes.length);
    if (mappedByteBuffer != null) {
      mappedByteBuffer.put(bytes);
      mappedByteBuffer.force();
    }
  } catch (IOException e) {
    e.printStackTrace();
  }
}

打开文件通道 fileChannel 并提供读权限、写权限和数据清空权限,通过 fileChannel 映射到一个可写的内存缓冲区 mappedByteBuffer,将目标数据写入 mappedByteBuffer,通过 force() 方法把缓冲区更改的内容强制写入本地文件。

测试读文件:

public static void read(){
  Path path = Paths.get(FILE_NAME);
  int length = CONTENT.getBytes(Charset.forName("UTF-8")).length;
  try (FileChannel fileChannel = FileChannel.open(path, StandardOpenOption.READ)) {
    MappedByteBuffer mappedByteBuffer = fileChannel.map(READ_ONLY, 0, length);
    if (mappedByteBuffer != null) {
      byte[] bytes = new byte[length];
      mappedByteBuffer.get(bytes);
      String content = new String(bytes, StandardCharsets.UTF_8);
      System.out.println(content);
    }
  } catch (IOException e) {
    e.printStackTrace();
  }
}

map() 方法是 java.nio.channels.FileChannel 的抽象方法,由子类 sun.nio.ch.FileChannelImpl.java 实现,下面是和内存映射相关的核心代码:

public MappedByteBuffer map(MapMode var1, long var2, long var4) throws IOException {

  ......

    if (var4 == 0L) {
      var7 = 0L;
      FileDescriptor var38 = new FileDescriptor();
      if (this.writable && var6 != 0) {
        var17 = Util.newMappedByteBuffer(0, 0L, var38, (Runnable)null);
        return var17;
      }

      var17 = Util.newMappedByteBufferR(0, 0L, var38, (Runnable)null);
      return var17;
    }

  var12 = (int)(var2 % allocationGranularity);
  long var36 = var2 - (long)var12;
  var10 = var4 + (long)var12;

  try {
    var7 = this.map0(var6, var36, var10);
  } catch (OutOfMemoryError var31) {
    System.gc();

    try {
      Thread.sleep(100L);
    } catch (InterruptedException var30) {
      Thread.currentThread().interrupt();
    }

    try {
      var7 = this.map0(var6, var36, var10);
    } catch (OutOfMemoryError var29) {
      throw new IOException("Map failed", var29);
    }
  }
  
  FileDescriptor var13;
  try {
    var13 = this.nd.duplicateForMapping(this.fd);
  } catch (IOException var28) {
    unmap0(var7, var10);
    throw var28;
  }

  assert IOStatus.checkAll(var7);

  assert var7 % allocationGranularity == 0L;

  int var35 = (int)var4;
  FileChannelImpl.Unmapper var15 = new FileChannelImpl.Unmapper(var7, var10, var35, var13);
  if (this.writable && var6 != 0) {
    var37 = Util.newMappedByteBuffer(var35, var7 + (long)var12, var13, var15);
    return var37;
  } else {
    var37 = Util.newMappedByteBufferR(var35, var7 + (long)var12, var13, var15);
    return var37;
  }
  ......


}

map() 方法通过本地方法 map0() 为文件分配一块虚拟内存,作为它的内存映射区域,然后返回这块内存映射区域的起始地址。

文件映射需要在 Java 堆中创建一个 MappedByteBuffer 的实例。如果第一次文件映射导致 OOM,则手动触发垃圾回收,休眠 100ms 后再尝试映射,如果失败则抛出异常。

通过 Util 的 newMappedByteBuffer (可读可写)方法或者 newMappedByteBufferR(仅读) 方法方法反射创建一个 DirectByteBuffer 实例,其中 DirectByteBuffer 是 MappedByteBuffer 的子类。

map() 方法返回的是内存映射区域的起始地址,通过(起始地址 + 偏移量)就可以获取指定内存的数据。这样一定程度上替代了 read()write() 方法,底层直接采用 sun.misc.Unsafe 类的 getByte() putByte()方法对数据进行读写。

private native long map0(int prot, long position, long mapSize) throws IOException;

上面是本地方法(native method) map0 的定义,它通过 JNI(Java Native Interface)调用底层 C 的实现,这个 native 函数(Java_sun_nio_ch_FileChannelImpl_map0)的实现位于 JDK 源码包下的 native/sun/nio/ch/FileChannelImpl.c 这个源文件里面:https://github.com/openjdk/jdk/blob/a619f36d115f1c6ebda15d7165de95dc44ebb1fd/src/java.base/windows/native/libnio/ch/FileChannelImpl.c

MappedByteBuffer 的特点和不足之处:

  • MappedByteBuffer 使用是堆外的虚拟内存,因此分配(map)的内存大小不受 JVM 的 -Xmx 参数限制,但是也是有大小限制的。
  • 如果当文件超出 Integer.MAX_VALUE 字节限制时,可以通过 position 参数重新 map 文件后面的内容。
  • MappedByteBuffer 在处理大文件时性能的确很高,但也存在内存占用、文件关闭不确定等问题,被其打开的文件只有在垃圾回收的才会被关闭,而且这个时间点是不确定的。
  • MappedByteBuffer 提供了文件映射内存的 mmap() 方法,也提供了释放映射内存的 unmap() 方法。然而 unmap() 是 FileChannelImpl 中的私有方法,无法直接显示调用。因此,用户程序需要通过 Java 反射的调用 sun.misc.Cleaner 类的 clean() 方法手动释放映射占用的内存区域。

DirectByteBuffer

DirectByteBuffer 是 Java NIO 用于实现堆外内存的一个很重要的类,而 Netty 用 DirectByteBuffer 作为PooledDirectByteBufUnpooledDirectByteBuf 的内部数据容器(区别于 HeapByteBuf 直接用 byte[] 作为数据容器)。

DirectByteBuffer 的对象引用位于 Java 内存模型的堆里面,JVM 可以对 DirectByteBuffer 的对象进行内存分配和回收管理,一般使用 DirectByteBuffer 的静态方法 allocateDirect() 创建 DirectByteBuffer 实例并分配内存。

public static ByteBuffer allocateDirect(int capacity) {
    return new DirectByteBuffer(capacity);
}

DirectByteBuffer 内存分配是调用底层的 Unsafe 类提供的基础方法 allocateMemory()直接分配堆外内存:

DirectByteBuffer(int cap) {                   // package-private

  super(-1, 0, cap, cap);
  boolean pa = VM.isDirectMemoryPageAligned();
  int ps = Bits.pageSize();
  long size = Math.max(1L, (long)cap + (pa ? ps : 0));
  Bits.reserveMemory(size, cap);

  long base = 0;
  try {
    base = unsafe.allocateMemory(size);
  } catch (OutOfMemoryError x) {
    Bits.unreserveMemory(size, cap);
    throw x;
  }
  unsafe.setMemory(base, size, (byte) 0);
  if (pa && (base % ps != 0)) {
    // Round up to page boundary
    address = base + ps - (base & (ps - 1));
  } else {
    address = base;
  }
  cleaner = Cleaner.create(this, new Deallocator(base, size, cap));
  att = null;



}

那么 DirectByteBuffer 和零拷贝有什么关系?我们看一下 DirectByteBuffer 的类名:

class DirectByteBuffer extends MappedByteBuffer implements DirectBuffer {
  
}

可以看到她继承了 MappedByteBuffer,而 MappedByteBuffer 的 map() 方法会通过 Util.newMappedByteBuffer() 来创建一个缓冲区实例。

基于 sendfile 实现的 FileChannel

FileChannel 是一个用于文件读写、映射和操作的通道,同时它在并发环境下是线程安全的,基于 FileInputStream、FileOutputStream 或者 RandomAccessFile 的 getChannel() 方法可以创建并打开一个文件通道。FileChannel 定义了 transferFrom() transferTo() 两个抽象方法,它通过在通道和通道之间建立连接实现数据传输的。

transferTo():通过 FileChannel 把文件里面的源数据写入一个 WritableByteChannel 的目的通道。

transferFrom():把一个源通道 ReadableByteChannel 中的数据读取到当前 FileChannel 的文件里面。

这两个方法也是 java.nio.channels.FileChannel 的抽象方法,由子类 sun.nio.ch.FileChannelImpl.java 实现。transferTo() transferFrom() 底层都是基于 sendfile 实现数据传输的,其中 FileChannelImpl.java 定义了 3 个常量,用于标示当前操作系统的内核是否支持 sendfile 以及 sendfile 的相关特性。

private static volatile boolean transferSupported = true;
private static volatile boolean pipeSupported = true;
private static volatile boolean fileSupported = true;

transferSupported:用于标记当前的系统内核是否支持sendfile()调用,默认为 true。

pipeSupported:用于标记当前的系统内核是否支持文件描述符(fd)基于管道(pipe)的sendfile()调用,默认为 true。

fileSupported:用于标记当前的系统内核是否支持文件描述符(fd)基于文件(file)的 sendfile() 调用,默认为 true。

Netty零拷贝

Netty 中的零拷贝和上面提到的操作系统层面上的零拷贝不太一样, 我们所说的 Netty 零拷贝完全是基于(Java 层面)用户态的,它的更多的是偏向于数据操作优化这样的概念,具体表现在以下几个方面:

  • Netty 通过 DefaultFileRegion 类对 java.nio.channels.FileChanneltranferTo() 方法进行包装,在文件传输时可以将文件缓冲区的数据直接发送到目的通道(Channel);
  • ByteBuf 可以通过 wrap 操作把字节数组、ByteBuf、ByteBuffer 包装成一个 ByteBuf 对象, 进而避免了拷贝操作;
  • ByteBuf 支持 slice 操作, 因此可以将 ByteBuf 分解为多个共享同一个存储区域的 ByteBuf,避免了内存的拷贝;
  • Netty 提供了 CompositeByteBuf 类,它可以将多个 ByteBuf 合并为一个逻辑上的 ByteBuf,避免了各个 ByteBuf 之间的拷贝。