Linux 中的零拷贝机制

Zero-Copy 机制

零复制(英语:Zero-copy;也译零拷贝)技术是指计算机执行操作时,CPU不需要先将数据从某处内存复制到另一个特定区域。这种技术通常用于通过网络传输文件时节省CPU周期和内存带宽。
-- 摘自:维基百科百科 零复制

零拷贝主要的任务就是避免 CPU 将数据从一块存储拷贝到另外一块存储,减少不必要的拷贝,或者让别的组件来做这一类简单的数据传输任务,让CPU解脱出来专注于别的任务。

零拷贝机制解决的问题:

  • 减少数据在内核缓冲区和用户进程缓冲区之间反复的 I/O 拷贝操作;

  • 减少用户进程地址空间和内核地址空间之间因为上下文切换而带来的 CPU 开销。

内存管理

物理内存和虚拟内存

由于操作系统的进程与进程之间是共享 CPU 和内存资源的,因此需要一套完善的内存管理机制防止进程之间内存泄漏的问题。为了更加有效地管理内存并减少出错,现代操作系统提供了一种对主存的抽象概念,即是虚拟内存(Virtual Memory)。虚拟内存为每个进程提供了一个一致的、私有的地址空间,它让每个进程产生了一种自己在独享主存的错觉(每个进程拥有一片连续完整的内存空间)。

物理内存

物理内存(Physical memory)是相对于虚拟内存(Virtual Memory)而言的。物理内存指通过物理内存条而获得的内存空间,而虚拟内存则是指将硬盘的一块区域划分来作为内存。内存主要作用是在计算机运行时为操作系统和各种程序提供临时储存。在应用中,自然是顾名思义,物理上,真实存在的插在主板内存槽上的内存条的容量的大小。

虚拟内存

虚拟内存是计算机系统内存管理的一种技术。 它使得应用程序认为它拥有连续的可用的内存(一个连续完整的地址空间)。而实际上,虚拟内存通常是被分隔成多个物理内存碎片,还有部分暂时存储在外部磁盘存储器上,在需要时进行数据交换,加载到物理内存中来。 目前,大多数操作系统都使用了虚拟内存,如 Windows 系统的虚拟内存、Linux 系统的交换空间等等。

虚拟内存地址和用户进程紧密相关,一般来说不同进程里的同一个虚拟地址指向的物理地址是不一样的,所以离开进程谈虚拟内存没有任何意义。

每个进程所能使用的虚拟地址大小和 CPU 位数有关,在 32 位的系统上,虚拟地址空间大小是 \(2 ^ {32} = 4 GB\),在 64位系统上,虚拟地址空间大小是 \(2 ^ {64} = 16 EB\),而实际的物理内存可能远远小于虚拟内存的大小。

在 64位系统上,目前大部分的操作系统和应用程序,并不需要 16EB 这么巨大的地址空间, 实现 64 位长的地址,只会增加系统的复杂度和地址转换的成本, 带不来任何好处。所以,目前的 x86-64 架构的 CPU 都遵循 AMD 的Canonical form , 即只有虚拟地址的最低 48 位才会在地址转换时被使用, 且任何虚拟地址的 48 位至 63 位必须与 47 位一致(sign extension),也就是说, 实际上,总的虚拟地址空间为 256TB,其中,256TB 的虚拟内存空间划分如下:

  • 0000000000000000 - 00007fffffffffff:共 128TB,为用户空间;

  • ffff800000000000 - ffffffffffffffff:共 128TB,为内核空间。

页表(Page Table)

每个用户进程维护了一个单独的页表(Page Table),虚拟内存和物理内存就是通过这个页表实现地址空间的映射的。下面给出两个进程 A、B 各自的虚拟内存空间以及对应的物理内存之间的地址映射示意图:

image

当进程执行一个程序时,需要先从先内存中读取该进程的指令,然后执行,获取指令时用到的就是虚拟地址。这个虚拟地址是程序链接时确定的(内核加载并初始化进程时会调整动态库的地址范围)。为了获取到实际的数据,CPU 需要将虚拟地址转换成物理地址,CPU 转换地址时需要用到进程的页表(Page Table),而页表(Page Table)里面的数据由操作系统维护。

其中页表(Page Table)可以简单的理解为单个内存映射(Memory Mapping)的链表(当然实际结构很复杂),里面的每个内存映射(Memory Mapping)都将一块虚拟地址映射到一个特定的物理地址,每个进程拥有自己的页表(Page Table)。

通过上面的介绍,我们可以简单的将用户进程申请并访问物理内存的过程总结如下:

  • 用户进程向操作系统发出内存申请请求

  • 系统会检查进程的虚拟地址空间是否被用完,如果有剩余,给进程分配虚拟地址

  • 系统为这块虚拟地址创建的内存映射(Memory Mapping),并将它放进该进程的页表(Page Table)

  • 系统返回虚拟地址给用户进程,用户进程开始访问该虚拟地址

  • CPU 根据虚拟地址在此进程的页表(Page Table)中找到了相应的内存映射(Memory Mapping),但是这个内存映射(Memory Mapping)没有和物理内存关联,于是产生缺页中断

  • 操作系统收到缺页中断后,分配真正的物理内存并将它关联到页表相应的内存映射(Memory Mapping)。中断处理完成后 CPU 就可以访问内存了

  • 当然缺页中断不是每次都会发生,只有系统觉得有必要延迟分配内存的时候才用的着,也即很多时候在上面的第 3 步系统会分配真正的物理内存并和内存映射(Memory Mapping)进行关联。

在用户进程和物理内存(磁盘存储器)之间引入虚拟内存主要有以下的优点:

  • 地址空间:提供更大的地址空间,并且地址空间是连续的,使得程序编写、链接更加简单

  • 进程隔离:不同进程的虚拟地址之间没有关系,所以一个进程的操作不会对其它进程造成影响

  • 数据保护:每块虚拟内存都有相应的读写属性,这样就能保护程序的代码段不被修改,数据块不能被执行等,增加了系统的安全性

  • 内存映射:有了虚拟内存之后,可以直接映射磁盘上的文件(可执行文件或动态库)到虚拟地址空间。这样可以做到物理内存延时分配,只有在需要读相应的文件的时候,才将它真正的从磁盘上加载到内存中来,而在内存吃紧的时候又可以将这部分内存清空掉,提高物理内存利用效率,并且所有这些对应用程序是都透明的

  • 共享内存:比如动态库只需要在内存中存储一份,然后将它映射到不同进程的虚拟地址空间中,让进程觉得自己独占了这个文件。进程间的内存共享也可以通过映射同一块物理内存到进程的不同虚拟地址空间来实现共享

  • 物理内存管理:物理地址空间全部由操作系统管理,进程无法直接分配和回收,从而系统可以更好的利用内存,平衡进程间对内存的需求

Linux 体系结构

从内核空间和用户空间的角度看整个 Linux 系统的结构,它大体可以分为三个部分,从下往上依次为:硬件 -> 内核空间 -> 用户空间,如下图所示:

image

内核空间中的代码控制了硬件资源的使用权,用户空间中的代码只有通过内核暴露的系统调用接口(System Call Interface)才能使用到系统中的硬件资源。

内核空间和用户空间

为什么需要区分内核空间与用户空间

在 CPU 的所有指令中,有些指令是非常危险的,如果错用,将导致系统崩溃,比如清内存、设置时钟等。如果允许所有的程序都可以使用这些指令,那么系统崩溃的概率将大大增加。

因此,CPU 将指令分为特权指令和非特权指令,对于那些危险的指令,只允许操作系统及其相关模块使用,普通应用程序只能使用那些不会造成灾难的指令。

比如 Intel 的 CPU 将特权等级分为 4 个级别:Ring0 ~ Ring3。

其实,Linux 系统只使用了 Ring0 和 Ring3 两个运行级别(Windows 系统也是一样的),当进程运行在 Ring3 级别时被称为运行在用户态,而运行在 Ring0 级别时被称为运行在内核态。

内核空间和用户空间

操作系统的核心是内核,独立于普通的应用程序,可以访问受保护的内存空间,也有访问底层硬件设备的权限。

为了避免用户进程直接操作内核,保证内核安全,操作系统将虚拟内存划分为两部分,一部分是内核空间(Kernel-space),一部分是用户空间(User-space)。 在 Linux 系统中,内核模块运行在内核空间,对应的进程处于内核态;而用户程序运行在用户空间,对应的进程处于用户态。

以 32 位 Linux 系统为例,系统的寻址空间(虚拟存储空间)为 4G,将最高的 1G 的字节(从虚拟地址 0xC0000000 到 0xFFFFFFFF)供内核进程使用,称为内核空间;而较低的 3G 的字节(从虚拟地址 0x00000000 到 0xBFFFFFFF),供各个用户进程使用,称为用户空间。

即对于 32 位 Linux 系统,每个进程的 4G 地址空间中,最高的 1G 地址空间都是一样的,即内核空间,它是被所有进程共享的,只有剩余的 3G 才归进程使用,如下图所示:

image

当进程运行在内核空间时就处于内核态,而进程运行在用户空间时则处于用户态:

  • 在内核态下,进程运行在内核地址空间中

    此时 CPU 可以执行任何指令,运行的代码也不受任何的限制,可以自由地访问任何有效地址,也可以直接进行端口的访问。

  • 在用户态下,进程运行在用户地址空间中

    被执行的代码要受到 CPU 的诸多检查,它们只能访问映射其地址空间的页表项中规定的在用户态下可访问页面的虚拟地址,且只能对任务状态段(TSS)中 I/O 许可位图(I/O Permission Bitmap)中规定的可访问端口进行直接访问。

下图是一个进程的用户空间和内核空间的内存布局:

内核空间

内核空间总是驻留在内存中,它是为操作系统的内核保留的。应用程序是不允许直接在该区域进行读写或直接调用内核代码定义的函数的。

上图左侧区域为内核进程对应的虚拟内存,按访问权限可以分为进程私有和进程共享两块区域。

  • 进程私有的虚拟内存:每个进程都有单独的内核栈、页表、task 结构以及 mem_map 结构等。

  • 进程共享的虚拟内存:属于所有进程共享的内存区域,包括物理存储器、内核数据和内核代码区域。

用户空间

每个普通的用户进程都有一个单独的用户空间,处于用户态的进程不能访问内核空间中的数据,也不能直接调用内核函数的 ,因此要进行系统调用的时候,就要将进程切换到内核态才行。

用户空间包括以下几个内存区域:

  • 运行时栈:由编译器自动释放,存放函数的参数值,局部变量和方法返回值等。每当一个函数被调用时,该函数的返回类型和一些调用的信息被存储到栈顶,调用结束后调用信息会被弹出弹出并释放掉内存。栈区是从高地址位向低地址位增长的,是一块连续的内在区域,最大容量是由系统预先定义好的,申请的栈空间超过这个界限时会提示溢出,用户能从栈中获取的空间较小。

  • 运行时堆:用于存放进程运行中被动态分配的内存段,位于 BSS 和栈中间的地址位。由卡发人员申请分配(malloc)和释放(free)。堆是从低地址位向高地址位增长,采用链式存储结构。频繁地 malloc/free 造成内存空间的不连续,产生大量碎片。当申请堆空间时,库函数按照一定的算法搜索可用的足够大的空间。因此堆的效率比栈要低的多。

  • 代码段:存放 CPU 可以执行的机器指令,该部分内存只能读不能写。通常代码区是共享的,即其它执行程序可调用它。假如机器中有数个进程运行相同的一个程序,那么它们就可以使用同一个代码段。

  • 未初始化的数据段:存放未初始化的全局变量,BSS 的数据在程序开始执行之前被初始化为 0 或 NULL。

  • 已初始化的数据段:存放已初始化的全局变量,包括静态全局变量、静态局部变量以及常量。

  • 内存映射区域:例如将动态库,共享内存等虚拟空间的内存映射到物理空间的内存,一般是 mmap 函数所分配的虚拟内存空间。

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 的中断完成的。

image

每次用户进程读取磁盘数据时,都需要 CPU 中断,然后发起 I/O 请求等待数据读取和拷贝完成,每次的 I/O 中断都导致 CPU 的上下文切换。

  • 用户进程向 CPU 发起 read 系统调用读取数据,由用户态切换为内核态,然后一直阻塞等待数据的返回。

  • CPU 在接收到指令以后对磁盘发起 I/O 请求,将磁盘数据先放入磁盘控制器缓冲区。

  • 数据准备完成以后,磁盘向 CPU 发起 I/O 中断。

  • CPU 收到 I/O 中断以后将磁盘缓冲区中的数据拷贝到内核缓冲区,然后再从内核缓冲区拷贝到用户缓冲区。

  • 用户进程由内核态切换回用户态,解除阻塞状态,然后等待 CPU 的下一个执行时间钟。

DMA

DMA 的全称叫直接内存存取(Direct Memory Access),是一种允许外围设备(硬件子系统)直接访问系统主内存的机制。也就是说,基于 DMA 访问方式,系统主内存于硬盘或网卡之间的数据传输可以绕开 CPU 的全程调度。目前大多数的硬件设备,包括磁盘控制器、网卡、显卡以及声卡等都支持 DMA 技术。

整个数据传输操作在一个 DMA 控制器的控制下进行的。CPU 除了在数据传输开始和结束时做一点处理外(开始和结束时候要做中断处理),在传输过程中 CPU 可以继续进行其他的工作。这样在大部分时间里,CPU 计算和 I/O 操作都处于并行操作,使整个计算机系统的效率大大提高。

image

有了 DMA 磁盘控制器接管数据读写请求以后,CPU 从繁重的 I/O 操作中解脱,数据读取操作的流程如下:

  • 用户进程向 CPU 发起 read 系统调用读取数据,由用户态切换为内核态,然后一直阻塞等待数据的返回

  • CPU 在接收到指令以后对 DMA 磁盘控制器发起调度指令

  • DMA 磁盘控制器对磁盘发起 I/O 请求,将磁盘数据先放入磁盘控制器缓冲区,CPU 全程不参与此过程

  • 数据读取完成后,DMA 磁盘控制器会接受到磁盘的通知,将数据从磁盘控制器缓冲区拷贝到内核缓冲区

  • DMA 磁盘控制器向 CPU 发出数据读完的信号,由 CPU 负责将数据从内核缓冲区拷贝到用户缓冲区

  • 用户进程由内核态切换回用户态,解除阻塞状态,然后等待 CPU 的下一个执行时间钟。

缓存 I/O (Buffered I/O)

缓存 I/O 又被称作标准 I/O,大多数文件系统的默认 I/O 操作都是缓存 I/O。在 Linux 的缓存 I/O 机制中,操作系统会将 I/O 的数据缓存在文件系统的页缓存( page cache )中,也就是说,数据会先被拷贝到操作系统内核的缓冲区中,然后才会从操作系统内核的缓冲区拷贝到应用程序的地址空间。写的过程就是数据流反方向。

image

对于读操作:当应用程序要去读取某块数据的时候,如果这块数据已经在页缓存中,那直接返回,而不需要经过硬盘的读取操作了。如果这块数据不在页缓存中,就需要从硬盘中读取数据到页缓存。

对于写操作:应用程序会将数据先写到页缓存中,数据是否会被立即写到磁盘,这取决于所采用的写操作机制。

  • 同步写机制( synchronous writes ):数据会立即被写回到磁盘上,应用程序会一直等到数据被写完为止;

  • 延迟写机制( deferred writes ):应用程序不需要等到数据全部被写回到磁盘,数据只要被写到页缓存中去就可以了。在延迟写机制的情况下,操作系统会定期地将放在页缓存中的数据刷到磁盘上。与异步写机制( asynchronous writes )不同的是,延迟写机制在数据完全写到磁盘上的时候不会通知应用程序,而异步写机制在数据完全写到磁盘上的时候是会返回给应用程序的。所以延迟写机制本身是存在数据丢失的风险的,而异步写机制则不会有这方面的担心。(写接口返回的时候,页缓存的数据还没刷到硬盘,正好断电。)

缓存 I/O 有以下这些优点:

  • 缓存 I/O 使用了操作系统内核缓冲区,在一定程度上分离了应用程序空间和实际的物理设备。

  • 缓存 I/O 可以减少读盘的次数,从而提高性能。

在缓存 I/O 机制中,DMA 方式可以将数据直接从磁盘读到页缓存中,或者将数据从页缓存直接写回到磁盘上,而不能直接在应用程序地址空间和磁盘之间进行数据传输,这样的话,数据在传输过程中需要在应用程序地址空间和页缓存之间进行多次数据拷贝操作,这些数据拷贝操作所带来的 CPU 以及内存开销是非常大的。

假如一个应用需要从某个磁盘文件中读取内容通过网络发出去如下图所示:

image

整个过程涉及 2 次 CPU 拷贝、2 次 DMA 拷贝,总共 4 次拷贝,以及 4 次上下文切换:

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

对于某些特殊的应用程序来说,避开操作系统内核缓冲区而直接在应用程序地址空间和磁盘之间传输数据会比使用操作系统内核缓冲区获取更好的性能。

零拷贝方式

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

  • 用户态直接 I/O

    应用程序可以直接访问硬件存储,操作系统内核只是辅助数据传输。这种方式依旧存在用户空间和内核空间的上下文切换,硬件上的数据直接拷贝至了用户空间,不经过内核空间。因此,直接 I/O 不存在内核空间缓冲区和用户空间缓冲区之间的数据拷贝。

  • 减少数据拷贝次数

    在数据传输过程中,避免数据在用户空间缓冲区和系统内核空间缓冲区之间的CPU拷贝,以及数据在系统内核空间内的CPU拷贝,这也是当前主流零拷贝技术的实现思路。

  • 写时复制技术

    写时复制指的是当多个进程共享同一块数据时,如果其中一个进程需要对这份数据进行修改,那么将其拷贝到自己的进程地址空间中,如果只是数据读取操作则不需要进行拷贝操作。

直接 I/O

凡是通过直接I/O方式进行数据传输,数据直接从用户态地址空间写入到磁盘中,直接跳过内核缓冲区。对于一些应用程序,例如:数据库。他们更倾向于自己的缓存机制,这样可以提供更好的缓冲机制提高数据库的读写性能。

image

如下图所示的数据读写流程,整个过程不涉及CPU 拷贝和上下文切换:

image

直接 I/O 的优缺点:

  • 优点:通过减少操作系统内核缓冲区和应用程序地址空间的数据拷贝次数,降低了对文件读取和写入时所带来的 CPU 的使用以及内存带宽的占用。这对于某些特殊的应用程序,不失为一种好的选择。如果要传输的数据量很大,使用直接 I/O 的方式进行数据传输,而不需要操作系统内核地址空间拷贝数据操作的参与,这将会大大提高性能。

  • 缺点:直接IO的开销也很大,应用程序没有控制好读写,将会导致磁盘读写的效率低下。磁盘的读写是通过磁头的切换到不同的磁道上读取和写入数据,如果需要写入数据在磁盘位置相隔比较远,就会导致寻道的时间大大增加,写入读取的效率大大降低。解决这个问题需要和异步 I/O 结合使用。

应用场景

DPDK

在一般情况下,处理数据包是通过 CPU 中断方式,网卡驱动接收到数据包后,通过中断通知 CPU 处理,数据通过协议栈,保存在 Socket Buffer,最终用户态程序再通过中断取走数据,这种方式会产生大量 CPU 中断,导致性能低下。

而 DPDK 采用了轮询方式实现数据包处理过程,DPDK 在用户态重载了网卡驱动,该驱动在收到数据包后不会中断通知 CPU,而是通过 DMA 直接将数据拷贝至用户空间,这种处理方式节省了 CPU 中断时间、内存拷贝时间。

为了让驱动运行在用户态,Linux 提供 UIO(Userspace I/O)机制,使用 UIO 可以通过 read 感知中断,通过 mmap 实现和网卡的通讯。

image

数据库、同步日志系统

通常像某些数据库、同步日志系统等,想要更加灵活自如的输出文件数据,更倾向于自己控制所有的缓存机制,这样可以提供更好的缓冲机制提高数据库的读写性能。

减少数据拷贝次数

内存映射 mmap

mmap(2) 系统调用 ,可以将一个普通的文件或者块设备文件与进程所在的虚拟地址空间创建映射,若进程要访问内存页中某个字节的数据,操作系统就会将访问该内存区域的操作转换为相应的访问文件的某个字节的操作。

与标准的访问文件的方式相比,内存映射方式可以减少标准访问文件方式中 read() 系统调用所带来的数据拷贝操作,即减少数据在用户地址空间和操作系统内核地址空间之间的拷贝操作。

内存映射通常适用于较大范围,对于相同长度的数据来讲,映射所带来的开销远远低于 CPU 拷贝所带来的开销。当大量数据需要传输的时候,采用内存映射方式去访问文件会获得比较好的效率。

内存映射,简而言之就是将用户空间的一段内存区域映射到内核空间,映射成功后,用户对这段内存区域的修改可以直接反映到内核空间,同样,内核空间对这段区域的修改也直接反映用户空间。那么对于内核空间<—->用户空间两者之间需要大量数据传输等操作的话效率是非常高的。
在内存映射的过程中,并没有实际的数据拷贝,文件没有被载入内存,只是逻辑上被放入了内存。

下图所示的数据读写流程,整个过程会发生 4 次上下文切换,1 次 CPU 拷贝和 2 次 DMA 拷贝。

image

  • 用户进程通过 mmap() 函数向内核(kernel)发起系统调用,上下文从用户态(user space)切换为内核态(kernel space)。

  • 将用户进程的内核空间的读缓冲区(read buffer)与用户空间的缓存区(user buffer)进行内存地址映射。

  • CPU 利用 DMA 控制器将数据从主存或硬盘拷贝到内核空间(kernel space)的读缓冲区(read buffer)。

  • 上下文从内核态(kernel space)切换回用户态(user space),mmap 系统调用执行返回。

  • 用户进程通过 write() 函数向内核(kernel)发起系统调用,上下文从用户态(user space)切换为内核态(kernel space)。

  • CPU 将读缓冲区(read buffer)中的数据拷贝到网络缓冲区(socket buffer)。

  • CPU 利用 DMA 控制器将数据从网络缓冲区(socket buffer)拷贝到网卡进行数据传输。

  • 上下文从内核态(kernel space)切换回用户态(user space),write 系统调用执行返回。

从硬盘上将文件读入内存,都要经过文件系统进行数据拷贝,并且数据拷贝操作是由文件系统和硬件驱动实现的,理论上来说,拷贝数据的效率是一样的。但是通过内存映射的方法访问硬盘上的文件,效率要比read和write系统调用高:

  • read/write是系统调用,read()首先将文件内容从硬盘拷贝到内核空间的一个缓冲区,然后再将这些数据拷贝到用户空间,在这个过程中,实际上完成了 两次数据拷贝

  • mmap()也是系统调用,mmap()中没有进行数据拷贝,真正的数据拷贝是在缺页中断处理时进行的,由于mmap()将文件直接映射到用户空间,所以中断处理函数根据这个映射关系,直接将文件从硬盘拷贝到用户空间,只进行了 一次数据拷贝 。

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(2) 系统调用在 Linux 内核版本 2.1 中被引入,目的是简化通过网络在两个通道之间进行的数据传输过程。Sendfile 系统调用的引入,不仅减少了 CPU 拷贝的次数,还减少了上下文切换的次数。与 mmap 内存映射方式不同的是, Sendfile 调用中 I/O 数据对用户空间是完全不可见的。也就是说,这是一次完全意义上的数据传输过程。

下图所示的数据读写流程,整个过程会发生 2 次上下文切换,1 次 CPU 拷贝和 2 次 DMA 拷贝。

image

  • 用户进程通过 sendfile() 函数向内核(kernel)发起系统调用,上下文从用户态(user space)切换为内核态(kernel space)。

  • CPU 利用 DMA 控制器将数据从主存或硬盘拷贝到内核空间(kernel space)的读缓冲区(read buffer)。

  • CPU 将读缓冲区(read buffer)中的数据拷贝到的网络缓冲区(socket buffer)。

  • CPU 利用 DMA 控制器将数据从网络缓冲区(socket buffer)拷贝到网卡进行数据传输。

  • 上下文从内核态(kernel space)切换回用户态(user space),Sendfile 系统调用执行返回。

Sendfile 存在的问题是用户程序不能对数据进行修改,而只是单纯地完成了一次数据传输过程。因此只能适用于那些不需要用户态处理的应用程序

带有 DMA 收集拷贝功能的 sendfile

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

下图所示的数据读写流程,整个过程会发生 2 次上下文切换和 2 次 DMA 拷贝。

image

  • 用户进程通过 sendfile() 函数向内核(kernel)发起系统调用,上下文从用户态(user space)切换为内核态(kernel space)。

  • CPU 利用 DMA 控制器将数据从主存或硬盘拷贝到内核空间(kernel space)的读缓冲区(read buffer)。

  • CPU 把读缓冲区(read buffer)的文件描述符(file descriptor)和数据长度拷贝到网络缓冲区(socket buffer)。

  • 基于已拷贝的文件描述符(file descriptor)和数据长度,CPU 利用 DMA 控制器的 gather/scatter 操作直接批量地将数据从内核的读缓冲区(read buffer)拷贝到网卡进行数据传输。

  • 上下文从内核态(kernel space)切换回用户态(user space),Sendfile 系统调用执行返回。

带有 DMA 收集拷贝功能的 sendfile 拷贝方式,同样存在用户程序不能对数据进行修改的问题,而且本身需要硬件的支持,因此,它只适用于将数据从文件拷贝到 socket 套接字上的传输过程,同时,也需要硬件的支持,这就限定了它的使用范围。

splice

Linux 在 2.6.17 版本引入 splice(2) 系统调用,不仅不需要硬件支持,还实现了两个文件描述符之间的数据零拷贝。

下图所示的数据读写流程,整个过程会发生 2 次上下文切换和 2 次 DMA 拷贝。

image

  • 用户进程通过 splice() 函数向内核(kernel)发起系统调用,上下文从用户态(user space)切换为内核态(kernel space)。

  • CPU 利用 DMA 控制器将数据从主存或硬盘拷贝到内核空间(kernel space)的读缓冲区(read buffer)。

  • CPU 在内核空间的读缓冲区(read buffer)和网络缓冲区(socket buffer)之间建立管道(pipeline)。

  • CPU 利用 DMA 控制器将数据从网络缓冲区(socket buffer)拷贝到网卡进行数据传输。

  • 上下文从内核态(kernel space)切换回用户态(user space),Splice 系统调用执行返回。

splice 拷贝方式也同样存在用户程序不能对数据进行修改的问题。

splice 使用了 Linux 的管道缓冲机制,可以用于任意两个文件描述符中传输数据,但是,它的两个文件描述符参数中有一个必须是管道设备。

写时复制机制

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

写时复制指的是当多个进程共享同一块数据时,如果其中一个进程需要对这份数据进行修改,那么就需要将其拷贝到自己的进程地址空间中。

这样做并不影响其他进程对这块数据的操作,每个进程要修改的时候才会进行拷贝,所以叫写时拷贝。这种方法在某种程度上能够降低系统开销,如果某个进程永远不会对所访问的数据进行更改,那么也就永远不需要拷贝。

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

缓冲区共享

缓冲区共享方式完全改写了传统的 I/O 操作,因为传统 I/O 接口都是基于数据拷贝进行的,要避免拷贝就得去掉原先的那套接口并重新改写,所以这种方法是比较全面的零拷贝技术,目前比较成熟的一个方案是在 Solaris 上实现的 fbuf(Fast Buffer,快速缓冲区)。

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

iamge

缺陷:缓冲区共享的难度在于管理共享缓冲区池需要应用程序、网络软件以及设备驱动程序之间的紧密合作,而且如何改写 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 中的零拷贝

Java 只实现了两种 Linux 中的零拷贝技术:

  • mmap:MappedByteBuffer

  • sendfile:FileChannel

NIO 中的 mmap

通过 FileChannel.map() 方法,它可以把一个文件从内核缓冲区的内存,映射到用户缓冲区的内存。

FileChannel.map() 是通过调用 Linux 系统中的系统调用 mmap() 实现的。

public abstract MappedByteBuffer map(FileChannel.MapMode mode,
                                     long position,
                                     long size)
                              throws IOException

由于 MappedByteBuffer 使用是堆外的虚拟内存,因此映射的内存大小,不受 JVM 的 -Xmx 参数限制,但是,也是有大小限制的,如果当文件超出 Integer.MAX_VALUE 字节限制时,可以通过 position 参数重新 map 文件后面的内容。

MappedByteBuffer 的缺点:

  • MappedByteBuffer 存在内存占用高、文件关闭时机不确定等问题。

    被它映射的的文件,只有在垃圾回收的才会被关闭,而且,这个时间点是不确定的。

  • 当关闭文件映射时,需要用户手动清除内存映射文件

    MappedByteBuffer 提供了文件映射内存的 mmap() 方法,也提供了释放映射内存的 unmap() 方法,然而 unmap() 是 FileChannelImpl 中的私有方法,无法直接显示调用。只有在 JVM 在 full gc 时,才会进行释放内存。

    因此,当需要关闭文件时,需要手动清楚内存映射文件,应用程序需要通过 Java 反射的调用 sun.misc.Cleaner 类的 clean() 方法,手动释放映射占用的内存区域。

注意,这种方式适合读取大文件,同时,也能对文件内容进行更改,但是,如果其后要通过 SocketChannel 发送,还是需要 CPU 进行数据的拷贝。

【示例】:

MappedByteBuffer mappedByteBuffer = new RandomAccessFile(file, "r").getChannel().map(FileChannel.MapMode.READ_ONLY, 0, 1024);

NIO 中的 sendfile

FileChannel.transferTo() 方法直接将当前通道内容传输到另一个通道,没有涉及到 Buffer 的任何操作,NIO 中的 Buffer 可以是 JVM 堆内存,或者堆外内存(操作系统内核空间的内存)。

FileChannel.transferTo() 是通过调用 Linux 系统中的系统调用 sendfile() 实现的。

【示例】:

public void copyFile(File src, File dest) {
    try (FileChannel srcChannel = new FileInputStream(src).getChannel();
    FileChannel destChannel = new FileInputStream(dest).getChannel()) {
        srcChannel.transferTo(0, srcChannel.size(), destChannel);
    }
    catch (IOException e) {
        e.printStackTrace();
    }
}

Netty 零拷贝技术

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

  • 通过 DefaultFileRegion 类对 FileChanne.tranferTo() 方法进行包装,在文件传输时可以将文件缓冲区的数据直接发送到目的通道(Channel);

  • ByteBuf 可以通过 wrap 操作把字节数组、ByteBuf、ByteBuffer 包装成一个 ByteBuf 对象, 进而避免了拷贝操作;

  • ByteBuf 支持 slice 操作, 因此可以将 ByteBuf 分解为多个共享同一个存储区域的 ByteBuf,避免了内存的拷贝;

  • Netty 提供了 CompositeByteBuf 类,它可以将多个 ByteBuf 合并为一个逻辑上的 ByteBuf,避免了各个 ByteBuf 之间的拷贝。

其中,第 1 条属于操作系统层面的零拷贝操作,后面 3 条只能算用户层面的数据操作优化。

常见的 MQ 框架中的零拷贝技术

RocketMQ 选择了 mmap + write 这种零拷贝方式,适用于业务级消息这种小块文件的数据持久化和传输。

Kafka 采用的是 sendfile 这种零拷贝方式,适用于系统日志消息这种高吞吐量的大块文件的数据持久化和传输。但是,需要注意的是,Kafka 的索引文件使用的是 mmap + write 方式,数据文件使用的是 sendfile 方式。

MQ 框架 零拷贝方式 优点 缺点
RocketMQ mmap + write 适用于小块文件传输,频繁调用时,效率很高 不能很好的利用 DMA 方式,会比 sendfile 多消耗 CPU,内存安全性控制复杂,需要避免 JVM Crash 问题
Kafka sendfile 可以利用 DMA 方式,消耗 CPU 较少,大块文件传输效率高,无内存安全性问题 小块文件效率低于 mmap 方式,只能是 BIO 方式传输,不能使用 NIO 方式

参考:

posted @ 2023-08-07 16:02  LARRY1024  阅读(529)  评论(1编辑  收藏  举报