零拷贝

零拷贝(Zero-copy)技术指在计算机执行操作时,CPU 不需要先将数据从一个内存区域复制到另一个内存区域,从而可以减少上下文切换以及 CPU 的拷贝时间。

作用

它的作用是在数据报从网络设备到用户程序空间传递的过程中,减少数据拷贝次数,减少系统调用,实现 CPU 的零参与,彻底消除 CPU 在这方面的负载。

实现零拷贝用到的最主要技术是 DMA 数据传输技术和内存区域映射技术:

零拷贝机制可以减少数据在内核缓冲区和用户进程缓冲区之间反复的 I/O 拷贝操作。

零拷贝机制可以减少用户进程地址空间和内核地址空间之间因为上下文切换而带来的 CPU 开销。

物理内存和虚拟内存

由于操作系统的进程与进程之间是共享 CPU 和内存资源的,因此需要一套完善的内存管理机制防止进程之间内存泄漏的问题。

为了更加有效地管理内存并减少出错,现代操作系统提供了一种对主存的抽象概念,即虚拟内存(Virtual Memory)。

虚拟内存为每个进程提供了一个一致的、私有的地址空间,它让每个进程产生了一种自己在独享主存的错觉(每个进程拥有一片连续完整的内存空间)。

物理内存

物理内存(Physical Memory)是相对于虚拟内存(Virtual Memory)而言的。

物理内存指通过物理内存条而获得的内存空间,而虚拟内存则是指将硬盘的一块区域划分来作为内存。内存主要作用是在计算机运行时为操作系统和各种程序提供临时储存。

在应用中,自然是顾名思义,物理上,真实存在的插在主板内存槽上的内存条的容量的大小。

虚拟内存

虚拟内存是计算机系统内存管理的一种技术。它使得应用程序认为它拥有连续的可用的内存(一个连续完整的地址空间)。

而实际上,虚拟内存通常是被分隔成多个物理内存碎片,还有部分暂时存储在外部磁盘存储器上,在需要时进行数据交换,加载到物理内存中来。

目前,大多数操作系统都使用了虚拟内存,如 Windows 系统的虚拟内存、Linux 系统的交换空间等等。

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

在 32 位的系统上,虚拟地址空间大小是 2^32=4G,在 64 位系统上,虚拟地址空间大小是 2^64=16G,而实际的物理内存可能远远小于虚拟内存的大小。

每个用户进程维护了一个单独的页表(Page Table),虚拟内存和物理内存就是通过这个页表实现地址空间的映射的。

下面给出两个进程 A、B 各自的虚拟内存空间以及对应的物理内存之间的地址映射示意图:

image

程序执行流程

当进程执行一个程序时,需要先从内存中读取该进程的指令,然后执行,获取指令时用到的就是虚拟地址。

这个虚拟地址是程序链接时确定的(内核加载并初始化进程时会调整动态库的地址范围)。

为了获取到实际的数据,CPU 需要将虚拟地址转换成物理地址,CPU 转换地址时需要用到进程的页表(Page Table),而页表(Page Table)里面的数据由操作系统维护。

其中页表(Page Table)可以简单的理解为单个内存映射(Memory Mapping)的链表(当然实际结构很复杂)。

里面的每个内存映射(Memory Mapping)都将一块虚拟地址映射到一个特定的地址空间(物理内存或者磁盘存储空间)。

每个进程拥有自己的页表(Page Table),和其他进程的页表(Page Table)没有关系。

总结

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

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

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

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

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

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

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

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

虚拟内存的优点

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

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

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

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

内存映射:有了虚拟内存之后,可以直接映射磁盘上的文件(可执行文件或动态库)到虚拟地址空间。

这样可以做到物理内存延时分配,只有在需要读相应的文件的时候,才将它真正的从磁盘上加载到内存中来,而在内存吃紧的时候又可以将这部分内存清空掉,提高物理内存利用效率,并且所有这些对应用程序都是透明的。

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

进程间的内存共享也可以通过映射同一块物理内存到进程的不同虚拟地址空间来实现共享。

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

内核空间和用户空间

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

为了避免用户进程直接操作内核,保证内核安全,操作系统将虚拟内存划分为两部分,一部分是内核空间(Kernel-space),一部分是用户空间(User-space)。

在 Linux 系统中,内核模块运行在内核空间,对应的进程处于内核态;而用户程序运行在用户空间,对应的进程处于用户态。

内核进程和用户进程所占的虚拟内存比例是 1:3,而 Linux x86_32 系统的寻址空间(虚拟存储空间)为 4G(2 的 32 次方),将最高的 1G 的字节(从虚拟地址 0xC0000000 到 0xFFFFFFFF)供内核进程使用,称为内核空间。

而较低的 3G 的字节(从虚拟地址 0x00000000 到 0xBFFFFFFF),供各个用户进程使用,称为用户空间。

内存布局

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

image

内核空间

内核空间总是驻留在内存中,它是为操作系统的内核保留的。

应用程序是不允许直接在该区域进行读写或直接调用内核代码定义的函数的。

ps: 这是是出于安全考虑。

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

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

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

用户空间

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

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

运行时栈:由编译器自动释放,存放函数的参数值,局部变量和方法返回值等。每当一个函数被调用时,该函数的返回类型和一些调用的信息被存储到栈顶,调用结束后调用信息会被弹出并释放掉内存。

栈区是从高地址位向低地址位增长的,是一块连续的内在区域,最大容量是由系统预先定义好的,申请的栈空间超过这个界限时会提示溢出,用户能从栈中获取的空间较小。

运行时堆:用于存放进程运行中被动态分配的内存段,位于 BSS 和栈中间的地址位。由卡发人员申请分配(malloc)和释放(free)。堆是从低地址位向高地址位增长,采用链式存储结构。

频繁地 malloc/free 造成内存空间的不连续,产生大量碎片。当申请堆空间时,库函数按照一定的算法搜索可用的足够大的空间。因此堆的效率比栈要低的多。

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

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

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

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

Linux 的内部层级结构

内核态可以执行任意命令,调用系统的一切资源,而用户态只能执行简单的运算,不能直接调用系统资源。

用户态必须通过系统接口(System Call),才能向内核发出指令。

image

比如,当用户进程启动一个 bash 时,它会通过 getpid() 对内核的 pid 服务发起系统调用,获取当前用户进程的 ID。

cat 调用流程

当用户进程通过 cat 命令查看主机配置时,它会对内核的文件子系统发起系统调用:

内核空间可以访问所有的 CPU 指令和所有的内存空间、I/O 空间和硬件设备。

用户空间只能访问受限的资源,如果需要特殊权限,可以通过系统调用获取相应的资源。

用户空间允许页面中断,而内核空间则不允许。

内核空间和用户空间是针对线性地址空间的。

x86 CPU 中用户空间是 0-3G 的地址范围,内核空间是 3G-4G 的地址范围。

x86_64 CPU 用户空间地址范围为0x0000000000000000–0x00007fffffffffff,内核地址空间为 0xffff880000000000-最大地址。

所有内核进程(线程)共用一个地址空间,而用户进程都有各自的地址空间。

Linux 层级结构划分

有了用户空间和内核空间的划分后,Linux 内部层级结构可以分为三部分,从最底层到最上层依次是硬件、内核空间和用户空间,如下图所示:

image

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 技术。

image

整个数据传输操作在一个 DMA 控制器的控制下进行的。

CPU 除了在数据传输开始和结束时做一点处理外(开始和结束时候要做中断处理),在传输过程中 CPU 可以继续进行其他的工作。

这样在大部分时间里,CPU 计算和 I/O 操作都处于并行操作,使整个计算机系统的效率大大提高。

image

DMA 读写流程

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

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

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

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

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

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

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

传统 I/O 方式

为了更好的理解零拷贝解决的问题,我们首先了解一下传统 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 次上下文切换。

image

下面简单地阐述一下相关的概念:

上下文切换:当用户程序向内核发起系统调用时,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 不存在内核空间缓冲区和用户空间缓冲区之间的数据拷贝。

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

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

用户态直接 I/O

用户态直接 I/O 使得应用进程或运行在用户态(user space)下的库函数直接访问硬件设备。

数据直接跨过内核进行传输,内核在数据传输过程除了进行必要的虚拟存储配置工作之外,不参与任何其他工作,这种方式能够直接绕过内核,极大提高了性能。

image

用户态直接 I/O 只能适用于不需要内核缓冲区处理的应用程序,这些应用程序通常在进程地址空间有自己的数据缓存机制,称为自缓存应用程序,如数据库管理系统就是一个代表。

其次,这种零拷贝机制会直接操作磁盘 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),大致的流程如下图所示:

image

基于 mmap+write 系统调用的零拷贝方式,整个拷贝过程会发生 4 次上下文切换,1 次 CPU 拷贝和 2 次 DMA 拷贝。

用户程序读写数据的流程如下:

用户进程通过 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 系统调用执行返回。

mmap 主要的用处是提高 I/O 性能,特别是针对大文件。对于小文件,内存映射文件反而会导致碎片空间的浪费。

因为内存映射总是要对齐页边界,最小单位是 4 KB,一个 5 KB 的文件将会映射占用 8 KB 内存,也就会浪费 3 KB 内存。

mmap 的拷贝虽然减少了 1 次拷贝,提升了效率,但也存在一些隐藏的问题。

当 mmap 一个文件时,如果这个文件被另一个进程所截获,那么 write 系统调用会因为访问非法地址被 SIGBUS 信号终止,SIGBUS 默认会杀死进程并产生一个 coredump,服务器可能因此被终止。

Sendfile

Sendfile 系统调用在 Linux 内核版本 2.1 中被引入,目的是简化通过网络在两个通道之间进行的数据传输过程。

Sendfile 系统调用的引入,不仅减少了 CPU 拷贝的次数,还减少了上下文切换的次数,它的伪代码如下:

sendfile(socket_fd, file_fd, len);

通过 Sendfile 系统调用,数据可以直接在内核空间内部进行 I/O 传输,从而省去了数据在用户空间和内核空间之间的来回拷贝。

与 mmap 内存映射方式不同的是, Sendfile 调用中 I/O 数据对用户空间是完全不可见的。也就是说,这是一次完全意义上的数据传输过程。

image

基于 Sendfile 系统调用的零拷贝方式,整个拷贝过程会发生 2 次上下文切换,1 次 CPU 拷贝和 2 次 DMA 拷贝。

用户程序读写数据的流程如下:

用户进程通过 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 系统调用执行返回。

  • 对比

相比较于 mmap 内存映射的方式,Sendfile 少了 2 次上下文切换,但是仍然有 1 次 CPU 拷贝操作。

Sendfile 存在的问题是用户程序不能对数据进行修改,而只是单纯地完成了一次数据传输过程。

Sendfile+DMA gather copy

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 操作将页缓存中数据打包发送到网络中即可,本质就是和虚拟内存映射的思路类似。

image

基于 Sendfile+DMA gather copy 系统调用的零拷贝方式,整个拷贝过程会发生 2 次上下文切换、0 次 CPU 拷贝以及 2 次 DMA 拷贝。

用户程序读写数据的流程如下:

用户进程通过 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 系统调用执行返回。

  • 适用场景

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 拷贝操作。

image

基于 Splice 系统调用的零拷贝方式,整个拷贝过程会发生 2 次上下文切换,0 次 CPU 拷贝以及 2 次 DMA 拷贝。

用户程序读写数据的流程如下:

用户进程通过 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 拷贝方式也同样存在用户程序不能对数据进行修改的问题。

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

写时复制

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

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

这样做并不影响其他进程对这块数据的操作,每个进程要修改的时候才会进行拷贝,所以叫写时拷贝。

这种方法在某种程度上能够降低系统开销,如果某个进程永远不会对所访问的数据进行更改,那么也就永远不需要拷贝。

缓冲区共享

缓冲区共享方式完全改写了传统的 I/O 操作,因为传统 I/O 接口都是基于数据拷贝进行的,要避免拷贝就得去掉原先的那套接口并重新改写。

所以这种方法是比较全面的零拷贝技术,目前比较成熟的一个方案是在 Solaris 上实现的 fbuf(Fast Buffer,快速缓冲区)。

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

image

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

Linux 零拷贝对比

无论是传统 I/O 拷贝方式还是引入零拷贝的方式,2 次 DMA Copy 是都少不了的,因为两次 DMA 都是依赖硬件完成的。

下面从 CPU 拷贝次数、DMA 拷贝次数以及系统调用几个方面总结一下上述几种 I/O 拷贝方式的差别:

image

个人收获

一个技术理念的背后,更重要的是理解类似 linux 这种底层的原理。

C 语言还是高等语言的基石。

拓展阅读

DMA

参考资料

支撑百万并发的“零拷贝”技术,你了解吗?