零拷贝技术

内容纲要

想说在前面的话

面试一圈是不是发现自己太水了,虐的体无完肤?

零拷贝技术

想要走向成功,迎娶白富美,走上人生巅峰吗?

ZeroCopy详解

零拷贝描述的是CPU不执行拷贝数据从一个存储区域到另一个存储区域的任务(也就是不在内核与用户空间进行数据copy),这通常用于通过网络传输一个文件时以减少CPU周期和内存带宽。

zero-copy好处很显然:

  • 整理知识,学习笔记
  • 发布日记,杂文,所见所想
  • 撰写发布技术文稿(代码支持)
  • 撰写发布学术论文(LaTeX 公式支持)
  • 减少甚至完全避免不必要的CPU拷贝,解放CPU

  • 减少内存带宽的占用

  • 减少用户空间和内核空间之间的上下文切换


物理内存和虚拟内存

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

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

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

物理内存

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

物理内存指通过物理内存条而获得的内存空间,而虚拟内存则是指将硬盘的一块区域划分来作为内存

内存主要作用是在计算机运行时为操作系统和多种程序提供临时储存。

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

虚拟内存

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

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

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

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

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

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

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

零拷贝技术

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

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

为了获取到实际的数据,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),供各个用户进程使用,称为用户空间。

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

零拷贝技术

内核空间

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

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

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

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

用户空间

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

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

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

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

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

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

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

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

Linux 的内部层级结构

内核态可以执行任意命令,调用系统的一切资源,而用户态只能执行简单的运算,不能直接调用系统资源。用户态须通过系统接口(System Call),才能向内核发出指令。

零拷贝技术

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

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

  • 内核空间可以访问所有的 CPU 指令和所有的内存空间、I/O 空间和硬件设备。
  • 用户空间只能访问受限的资源,如果需要特殊权限,可以通过系统调用获取相应的资源。
  • 用户空间允许页面中断,而内核空间则不允许。
  • 内核空间和用户空间是针对线性地址空间的。
  • x86 CPU 中用户空间是 0-3G 的地址范围,内核空间是 3G-4G 的地址范围。 x86_64 CPU 用户空间地址范围为0x0000000000000000–0x00007fffffffffff,内核地址空间为 0xffff880000000000-大地址。
  • 所有内核进程(线程)共用一个地址空间,而用户进程都有各自的地址空间。

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

零拷贝技术

存储器层级结构

零拷贝技术

由于存储介质的速度与成本是相悖的,现代计算机的存储结构呈现为金字塔型。

越往塔顶,存取效率越高、但成本也越高,容量也就越小。

由于程序都是访问局部数据,这种缓存结构也适合运行效率是很高的。

上图可见缓存是无论不在,从存储器的层次结构以及计算机对数据的处理方式来看,上层一般作为下层的Cache层来使用(广义上的Cache)。

比如寄存器缓存CPU Cache的数据,CPU Cache L1~L3层视具体实现彼此缓存或直接缓存内存的数据,而内存往往缓存来自本地磁盘的数据。

接下来焦点继续缩放,只关注与磁盘存储这块

PageCache

广义上的Cache同步有两种方式:Write Through(写穿,意思是跟底层设备一块刷新数据,这种能保证数据强一致性,但是会牺牲性能), Write Back(写回,意思是把数据写入Cache就算完事了,底层设备数刷新是个异步操作,这种可能会丢数据,但是能把性能发挥最大)。

pageCache 是磁盘数据在内存(是内核空间的内存)中的缓存见下图。 这层缓存提升了读写效率,但是如果写入数据,采用了何种数据同步方式,应当根据业务场景而定。

可靠性跟高性能是一种折中的设计,就像Kafka的设计,充分利用PageCache带来的读写优势,用多副本来提升可靠性. 所以如何设计是根据业务场景来定制的。

零拷贝技术

PageCache 有什么作用?

回顾前面说道文件传输过程,其中第一步都是先需要先把磁盘文件数据拷贝「内核缓冲区」里,这个「内核缓冲区」实际上是磁盘高速缓存(PageCache)。

由于零拷贝使用了 PageCache 技术,可以使得零拷贝进一步提升了性能,我们接下来看看 PageCache 是如何做到这一点的。

读写磁盘相比读写内存的速度慢太多了,所以我们应该想办法把「读写磁盘」替换成「读写内存」。于是,我们会通过 DMA 把磁盘里的数据搬运到内存里,这样就可以用读内存替换读磁盘。

但是,内存空间远比磁盘要小,内存注定只能拷贝磁盘里的一小部分数据。

那问题来了,选择哪些磁盘数据拷贝到内存呢?

我们都知道程序运行的时候,具有「局部性」,所以通常,刚被访问的数据在短时间内再次被访问的概率很高,于是我们可以用 PageCache 来缓存最近被访问的数据,当空间不足时淘汰最久未被访问的缓存。

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

还有一点,读取磁盘数据的时候,需要找到数据所在的位置,但是对于机械磁盘来说,就是通过磁头旋转到数据所在的扇区,再开始「顺序」读取数据,但是旋转磁头这个物理动作是非常耗时的,为了降低它的影响,PageCache 使用了「预读功能」。

比如,假设 read 方法每次只会读 32 KB 的字节,虽然 read 刚开始只会读 0 ~ 32 KB 的字节,但内核会把其后面的 32~64 KB 也读取到 PageCache,这样后面读取 32~64 KB 的成本就很低,如果在 32~64 KB 淘汰出 PageCache 前,进程读取到它了,收益就非常大。

所以,PageCache 的优点主要是两个:

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

这两个做法,将大大提高读写磁盘的性能。

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

这是因为如果你有很多 GB 级别文件需要传输,每当用户访问这些大文件的时候,内核就会把它们载入 PageCache 中,于是 PageCache 空间很快被这些大文件占满。

另外,由于文件太大,可能某些部分的文件数据被再次访问的概率比较低,这样就会带来 2 个问题:

  • PageCache 由于长时间被大文件占据,其他「热点」的小文件可能就无法充分使用到 PageCache,于是这样磁盘读写的性能就会下降了;
  • PageCache 中的大文件数据,由于没有享受到缓存带来的好处,但却耗费 DMA 多拷贝到 PageCache 一次;

所以,针对大文件的传输,不应该使用 PageCache,也就是说不应该使用零拷贝技术,因为可能由于 PageCache 被大文件占据,而导致「热点」小文件无法利用到 PageCache,这样在高并发的环境下,会带来严重的性能问题。

大文件传输用什么方式实现?

我们可以发现,异步 I/O 并没有涉及到 PageCache,所以使用异步 I/O 就意味着要绕开 PageCache。

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

前面也提到,大文件的传输不应该使用 PageCache,因为可能由于 PageCache 被大文件占据,而导致「热点」小文件无法利用到 PageCache。

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

直接 I/O 应用场景常见的两种:

应用程序已经实现了磁盘数据的缓存,那么可以不需要 PageCache 再次缓存,减少额外的性能损耗。在 MySQL 数据库中,可以通过参数设置开启直接 I/O,默认是不开启;
传输大文件的时候,由于大文件难以命中 PageCache 缓存,而且会占满 PageCache 导致「热点」文件无法充分利用缓存,从而增大了性能开销,因此,这时应该使用直接 I/O。
另外,由于直接 I/O 绕过了 PageCache,就无法享受内核的这两点的优化:

内核的 I/O 调度算法会缓存尽可能多的 I/O 请求在 PageCache 中,最后「合并」成一个更大的 I/O 请求再发给磁盘,这样做是为了减少磁盘的寻址操作;

内核也会「预读」后续的 I/O 请求放在 PageCache 中,一样是为了减少对磁盘的操作;

于是,传输大文件的时候,使用「异步 I/O + 直接 I/O」了,就可以无阻塞地读取文件了。

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

传输大文件的时候,使用「异步 I/O + 直接 I/O」;
传输小文件的时候,则使用「零拷贝技术」;
在 nginx 中,我们可以用如下配置,来根据文件的大小来使用不同的方式:

location /video/ { 
    sendfile on; 
    aio on; 
    directio 1024m; 
}

当文件大小大于 directio 值后,使用「异步 I/O + 直接 I/O」,否则使用「零拷贝技术」。

BufferCache

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技术很容易理解,本质上,DMA技术就是我们在主板上放⼀块独立的芯片。在进行内存和I/O设备的数据传输的时候,我们不再通过CPU来控制数据传输,而直接通过 DMA控制器(DMA?Controller,简称DMAC)。这块芯片,我们可以认为它其实就是一个协处理器(Co-Processor))

在 DMA 技术出现之前,应用程序与磁盘之间的 I/O 操作都是由I/O 中断完成

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

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

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

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

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

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

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

为了方便你理解,我画了一副图:

零拷贝技术

可以看到,整个数据的传输过程,都要需要 CPU 亲自参与搬运数据的过程,而且这个过程,CPU 是不能做其他事情的。

简单的搬运几个字符数据那没问题,但是如果我们用千兆网卡或者硬盘传输大量数据的时候,都用 CPU 来搬运的话,肯定忙不过来。

计算机科学家们发现了事情的严重性后,于是就发明了 DMA 技术,也就是直接内存访问(Direct Memory Access) 技术, 是一种允许外围设备(硬件子系统)直接访问系统主内存的机制。

DMA技术的由来?

简单理解就是,在进行 I/O 设备和内存的数据传输的时候,数据搬运的工作全部交给 DMA 控制器,而 CPU 不再参与任何与数据搬运相关的事情,这样 CPU 就可以去处理别的事务。

那使用 DMA 控制器进行数据传输的过程究竟是什么样的呢?下面我们来具体看看。

目前大多数的硬件设备,包括磁盘控制器、网卡、显卡以及声卡等都支持 DMA 技术。

零拷贝技术

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

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

零拷贝技术

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

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

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

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

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

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

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

可以看到, 整个数据传输的过程,CPU 不再参与数据搬运的工作,而是全程由 DMA 完成,但是 CPU 在这个过程中也是必不可少的,因为传输什么数据,从哪里传输到哪里,都需要 CPU 来告诉 DMA 控制器。

早期 DMA 只存在在主板上,如今由于 I/O 设备越来越多,数据传输的需求也不尽相同,所以每个 I/O 设备里面都有自己的 DMA 控制器。

零拷贝方式

为什么需要零拷贝?

传统的 Linux 系统的标准 I/O 接口(read、write)是基于数据拷贝的,也就是数据都是 copy_to_user 或者 copy_from_user,这样做的好处是,通过中间缓存的机制,减少磁盘 I/O 的操作,

但是坏处也很明显,大量数据的拷贝,用户态和内核态的频繁切换,会消耗大量的 CPU 资源,严重影响数据传输的性能,

有数据表明,在Linux内核协议栈中,这个拷贝的耗时甚至占到了数据包整个处理流程的57.1%。

什么是零拷贝?

零拷贝就是这个问题的一个解决方案,通过尽量避免拷贝操作来缓解 CPU 的压力。

Linux 下常见的零拷贝技术可以分为两大类:

一是针对特定场景,去掉不必要的拷贝;

二是去优化整个拷贝的过程。

由此看来,零拷贝并没有真正做到“0”拷贝,它更多是一种思想,很多的零拷贝技术都是基于这个思想去做的优化。

传统I/O模式

此种模式是: 磁盘设备支持DMA的模式

传统读操作:

  • 当应用程序执行 read 系统调用读取一块数据的时候,如果这块数据已经存在于用户进程的页内存中,就直接从内存中读取数据

  • 如果数据不存在,则先将数据从磁盘加载数据到内核空间的读缓存(read buffer)中,再从读缓存拷贝到用户进程的页内存中

read(file_fd, tmp_buf, len);

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

发起数据读取的流程如下:

  • 用户进程通过 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 系统调用发送网络数据时,先将数据从用户空间的页缓存拷贝到内核空间的网络缓冲区(socket buffer)中,然后再将写缓存中的数据拷贝到网卡设备完成数据发送

write(socket_fd, tmp_buf, len);

基于传统的 I/O 写入方式,write 系统调用会触发 2 次上下文切换,1 次 CPU 拷贝和 1 次 DMA 拷贝。

用户程序发送网络数据的流程如下:

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

下图分别对应传统 I/O 操作的数据读写流程, 整个过程涉及:

  • 2 次 CPU 拷贝
  • 2 次 DMA 拷贝
  • 4次上下文切换

零拷贝技术

Demo编程示例:

for (;;) {
    if (lseek(fd, 0, SEEK_SET) < 0) 
        perror("error seek file");
    connect_fd = accept(listen_fd, &serv_addr, &client_addr);
    if (connect_fd < 0) 
        perror("accept failed");
    int n = 0;
    char buf[BUFFER_SIZE];
    while ((n = read(fd, buf, BUFFER_SIZE)) > 0) {
        write(connect_fd, buf, n);
    }
    close(connect_fd);
}

零拷贝 - 用户态直接IO

  • 缓冲IO

    1. 同步写
    2. 异步写
    3. 延迟写
  • 异步IO

标志: O_DIRECT

应用程序可以直接访问硬件存储,操作系统内核只是辅助数据传输

这种方式依旧存在用户空间和内核空间的上下文切换,硬件上的数据直接拷贝至了用户空间,不经过内核空间

因此,直接 I/O 不存在内核空间缓冲区和用户空间缓冲区之间的数据拷贝

零拷贝技术

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

缺陷

  • 只能适用于那些不需要内核缓冲区处理的应用程序,这些应用程序通常在进程地址空间有自己的数据缓存机制,称为自缓存应用程序, 如数据库管理系统

  • 这种方法直接操作磁盘I/O,由于CPU和磁盘I/O之间的执行时间差距,会造成资源的浪费,解决这个问题需要和异步I/O结合使用

零拷贝 - mmap+write

mmap原理:

Linux提供的一种内存映射文件的机制,它实现了将内核中读缓冲区地址与用户空间缓冲区地址进行映射,从而实现内核缓冲区与用户缓冲区的共享.

零拷贝技术

上图分别对应mmap操作的数据读写流程, 整个过程涉及:

  • 1 次 CPU 拷贝 (减少一次CPU拷贝)
  • 2 次 DMA 拷贝
  • 4次上下文切换

减少了一次用户态和内核态的CPU拷贝,但是在内核空间内仍然有一次CPU拷贝

mmap主要的用处是:

  • 提高 I/O 性能,特别是针对大文件

  • 对于小文件,内存映射文件反而会导致碎片空间的浪费, 因为内存映射总是要对齐页边界,小单位是 4 KB,一个 5 KB 的文件将会映射占用 8 KB 内存,也就会浪费 3 KB 内存

mmap隐藏问题:

  • 多个进程同时操作文件时, 你的程序map了一个文件,但是当这个文件被另一个进程截断(truncate)时, write 系统调用会因为访问非法地址被 SIGBUS 信号终止,SIGBUS 默认会杀死进程并产生一个 coredump,服务器可能因此被终止

通常我们使用以下解决方案避免这种问题:

  • 为SIGBUS信号建立信号处理程序
    当遇到SIGBUS信号时,信号处理程序简单地返回,write系统调用在被中断之前会返回已经写入的字节数,并且errno会被设置成success,但是这是一种糟糕的处理办法,因为你并没有解决问题的实质核心

  • 使用文件租借锁
    通常我们使用这种方法,在文件描述符上使用租借锁,我们为文件向内核申请一个租借锁,当其它进程想要截断这个文件时,内核会向我们发送一个实时的RT_SIGNAL_LEASE信号,告诉我们内核正在破坏你加持在文件上的读写锁。这样在程序访问非法内存并且被SIGBUS杀死之前,你的write系统调用会被中断。write会返回已经写入的字节数,并且置errno为success

我们应该在mmap文件之前加锁,并且在操作完文件后解锁:

Demo编程示例:

if(fcntl(diskfd, F_SETSIG, RT_SIGNAL_LEASE) == -1) {
    perror("kernel lease set signal");
    return -1;
}

buf = mmap(diskfd, len);

write(sockfd, buf, len);

/* l_type can be F_RDLCK F_WRLCK  加锁*/
/* l_type can be  F_UNLCK 解锁*/
if(fcntl(diskfd, F_SETLEASE, l_type)){
    perror("kernel lease set type");
    return -1;
}

API函数:

内存映射:

void mmap(void addr, size_t length, int prot, int flags, int fd, off_t offset);

参数名字 参数释义
addr 可以指定描述符fd应被映射到的进程内空间的地址, 通常设为NULL, 表明由内核自己选择起始地址
length 为映射到调用进程地址空间中的字节数,从映射文件开头起第 offset 个字节处开始算
prot PROT_READ: 数据可读
PROT_WRITE: 数据可写
PROT_EXEC: 数据可执行
PROT_NONE: 数据不可访问
flags MAP_SHARED: 变动是共享的,进程对映射数据的修改对共享该对象的所有进程可见
MAP_PRIVATE: 变动是私有的,进程对映射数据的修改只对该进程可见
MAP_FIXED: 准确的解释 addr 参数,从移植性考虑,不应指定
注意: 其中 MAP_SHARED 与 MAP_PRIVATE 必须指定一个
返回值 若成功则为被映射区的起始地址
若出错则为 MAP_FAILD(void *)-1), 并设置 errno

删除映射:

int munmap(void *addr, size_t length);

参数名字 参数解释
addr mmap返回的地址
length 映射区大小
return 0/-1, 设置 errno

注意:

若被映射区为 MAP_PRIVATE 标志,则进程对它的所有变动都会被丢弃

同步映射:

int msync(void *addr, size_t length, int flags);

参数名字 参数解释
addr mmap返回的地址,整个映射区,也可以为该映射区的一个子集
length 映射区大小,整个映射区,也可以为该映射区的一个子集
flags MS_ASYNC 执行异步写
MS_SYNC 执行同步写
MS_INVALIDATE 使高速缓存的数据失效
return 0/-1, 设置 errno

注意:

用来同步内存映射文件(一般在硬盘上)和内存映射区(在内存中),前提该映射区为 MAP_SHARED。

零拷贝 - sendfile

参考: 高效的sendfile,实现零拷贝

sendfile系统调用是在 Linux 内核2.1版本中被引入,它建立了两个文件之间的传输通道

零拷贝技术

上图分别对应sendfile操作的数据读写流程, 整个过程涉及:

  • 1 次 CPU 拷贝
  • 2 次 DMA 拷贝
  • 2 次上下文切换 (减少2次切换)

缺点:

  • 内核缓冲区和socket缓冲区仍然存在一次CPU拷贝

  • 由于数据不经过用户缓冲区,因此该数据无法被修改

  • 在我们调用sendfile时,如果有其它进程截断了文件会发生什么呢? 假设我们没有设置任何信号处理程序,sendfile调用仅仅返回它在被中断之前已经传输的字节数,errno会被置为success。如果我们在调用sendfile之前给文件加了锁,sendfile的行为仍然和之前相同,我们还会收到RT_SIGNAL_LEASE的信号

#include<sys/sendfile.h>

ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count);

for (;;) {
    connect_fd = accept(listen_fd, &serv_addr, &client_addr);
    if (connect_fd < 0) perror("accept failed");
    struct stat stat_buf;
    fstat(fd, &stat_buf);
    off_t offset = 0;
    int cnt = 0;
    if ((cnt = sendfile(connect_fd, fd, &offset, stat_buf.st_size)) < 0) {
        perror("send file failed");
    }
    close(connect_fd);
}

零拷贝 - sendfile+dma

网卡需要支持支持Scatter-Gather模式

Linux 2.4 内核对 sendfile 系统调用进行优化,但是需要硬件DMA控制器的配合。

升级后的sendfile将内核空间缓冲区中对应的数据描述信息(文件描述符、地址偏移量等信息)记录到socket缓冲区中。

DMA控制器根据socket缓冲区中的地址和偏移量将数据从内核缓冲区拷贝到网卡中,从而省去了内核空间中仅剩1次CPU拷贝。

零拷贝技术

上图分别对应sendfile操作的数据读写流程, 整个过程涉及:

  • 0 次 CPU 拷贝
  • 2 次 DMA 拷贝 (减少1次DMA)
  • 2 次上下文切换 (减少2次切换)

缺陷:

  • 仍然无法对数据进行修改
  • 需要硬件层面DMA的支持
  • sendfile只能将文件数据拷贝到socket描述符上

过程:

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

这种方法借助硬件的帮助,在数据从内核缓冲区到Socket缓冲区这一步操作上,并不是拷贝数据,而是拷贝缓冲区描述符(fd)和数据长度.

完成后,DMA引擎直接将数据从内核缓冲区拷贝到协议引擎中去,避免了最后一次拷贝

这样 DMA 引擎直接利用 gather 操作将页缓存中数据打包发送到网络中即可,本质就是和虚拟内存映射的思路类似

零拷贝 - splice+tee

参考: splice函数,高效的零拷贝

参考: 用tee在管道间复制数据,进行零拷贝操作

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

splice去掉sendfile的使用范围限制,可以用于任意两个文件描述符中传输数据

splice调用利用了Linux提出的管道缓冲区机制, 所以至少一个描述符要为管道

splice 系统调用可以在内核缓冲区和socket缓冲区之间建立管道来传输数据,避免了两者之间的 CPU 拷贝操作

零拷贝技术

上图分别对应sendfile操作的数据读写流程, 整个过程涉及:

  • 0 次 CPU 拷贝
  • 2 次 DMA 拷贝 (减少1次DMA)
  • 2 次上下文切换 (减少2次切换)

缺点:

  • 不能对数据进行修改的问题
  • 它使用了 Linux 的管道缓冲机制, 可以用于任意两个文件描述符中传输数据,但是它的两个文件描述符参数中有一个必须是管道设备

流程:

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

函数原型:

#define _GNU_SOURCE         /* See feature_test_macros(7) */

#include <fcntl.h> 

ssize_t splice(int fdin, loff_t *offin, int fdout, loff_t *offout, size_t len, unsigned int flags);

参数意义:

  • fdin参数:待读取数据的文件描述符。
  • offin参数:指示从输入数据的何处开始读取,为NULL表示从当前位置。如果fdin是一个管道描述符,则offin必须为NULL。
  • fdout参数:待写入数据的文件描述符。
  • offout参数:同offin,不过用于输出数据。
  • len参数:指定移动数据的长度。
  • flags参数:表示控制数据如何移动,可以为以下值的按位或:SPLICE_F_MOVE:按整页内存移动数据,存在bug,自内核2.6.21后,实际上没有效果。
    SPLICE_F_NONBLOCK:非阻塞splice操作,实际会受文件描述符本身阻塞状态影响。
    SPLICE_F_MORE:提示内核:后续splice将调用更多数据。
    SPLICE_F_GIFT:对splice没有效果。

注意: fdin和fdout必须至少有一个是管道文件描述符。

返回值:

  • 返回值>0:表示移动的字节数。
  • 返回0:表示没有数据可以移动,如果从管道中读,表示管道中没有被写入数据。
  • 返回-1;表示失败,并设置errno。

errno值如下:

  • EBADF:描述符有错。
  • EINVAL:目标文件不支持splice,或者目标文件以追加方式打开,或者两个文件描述符都不是管道描述符。
  • ENOMEM:内存不够。
  • ESPIPE:某个参数是管道描述符,但其偏移不是NULL

tee()函数

在两个管道文件描述符之间复制数据,同是零拷贝。但它不消耗数据,数据被操作之后,仍然可以用于后续操作

函数原型:

#include <fcntl.h> ssize_t tee(int fdin, int fdout, size_t len, unsigned int flags);

参数意义:

  • fdin参数:待读取数据的文件描述符。
  • fdout参数:待写入数据的文件描述符。
  • len参数:表示复制的数据的长度。
  • flags参数:同splice( )函数。

注意: fdin和fdout必须都是管道文件描述符。

返回值:

  • 返回值>0:表示复制的字节数。
  • 返回0:表示没有复制任何数据。
  • 返回-1:表示失败,并设置errno

splice-demo: echo server

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <assert.h>
#include <errno.h>
#include <string.h>
#include <fcntl.h>

int main(int argc, char **argv) {
    if (argc <= 2) {
        printf("usage: %s ip port\n", basename(argv[0]));
        return 1;
    }

    const char *ip = argv[1];
    int port = atoi(argv[2]);

    struct sockaddr_in address;
    bzero(&address, sizeof(address));
    address.sin_family = AF_INET;
    address.sin_port = htons(port);
    inet_pton(AF_INET, ip, &address.sin_addr);

    int sock = socket(PF_INET, SOCK_STREAM, 0);
    assert(sock >= 0);

    int reuse = 1;
    setsockopt(sock, SOL_SOCKET, SO_REUSEADDR, &reuse, sizeof(reuse));

    int ret = bind(sock, (struct sockaddr*)&address, sizeof(address));
    assert(ret != -1);

    ret = listen(sock, 5);
    assert(ret != -1);

    struct sockaddr_in client;
    socklen_t client_addrlength = sizeof(client);

    int connfd = accept(sock, (struct sockaddr*)&client, &client_addrlength);
    if (connfd < 0) {
        printf("errno is: %s\n", strerror(errno));
    }
    else {
        int pipefd[2];
        ret = pipe(pipefd);
        assert(ret != -1);

        ret = splice(connfd, NULL, pipefd[1], NULL, 32768, SPLICE_F_MORE | SPLICE_F_MOVE);
        assert(ret != -1);
        ret = splice(pipefd[0], NULL, connfd, NULL,
                        32768, SPLICE_F_MORE | SPLICE_F_MOVE);
        assert(ret != -1);
        close(connfd);
    }
    close(sock);
    return 0;
}

零拷贝 - SO_ZEROCOPY

零拷贝 - 前面总结

前面提到的几种零拷贝技术都是通过:

  • 避免用户态和内核态数据CPU拷贝
  • 避免系统调用的上下文切换次数
  • 局限于某些特殊的情况:要么不能在操作系统内核中处理数据,要么不能在用户地址空间中处理数据

零拷贝优化 - 写时复制(COW)

以上几种零拷贝技术都是减少数据在用户空间和内核空间拷贝技术实现的,但是有些时候,数据必须在用户空间和内核空间之间拷贝

这时候,我们只能针对数据在用户空间和内核空间拷贝的时机上下功夫了

Linux通常利用写时复制(copy on write)来减少系统开销,这个技术又时常称作COW。

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

写时复制:

就是当多个进程共享同一块数据时,如果其中一个进程需要对这份数据进行修改,那么就需要将其拷贝到自己的进程地址空间中,这样做并不影响其他进程对这块数据的操作,每个进程要修改的时候才会进行拷贝,所以叫写时拷贝

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

缺陷:

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

零拷贝优化 - 缓冲区共享(fbufs)

这种方法完全改写 I/O 操作,因为传统 I/O 接口都是基于数据拷贝的,要避免拷贝,就去掉原先的那套接口,重新改写.

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

Fbuf 的思想是每个进程都维护着一个缓冲区池,这个缓冲区池能被同时映射到程序地址空间和内核地址空间,内核和用户共享这个缓冲区池,这样就避免了拷贝

零拷贝技术

缺陷:

  • 管理共享缓冲区池需要应用程序、网络软件、以及设备驱动程序之间的紧密合作
  • 改写 API, 尚处于试验阶段。

高性能网络 I/O 框架——netmap

Netmap 基于共享内存的思想,是一个高性能收发原始数据包的框架,由Luigi Rizzo 等人开发完成,其包含了内核模块以及用户态库函数。

其目标是,不修改现有操作系统软件以及不需要特殊硬件支持,实现用户态和网卡之间数据包的高性能传递

在 Netmap 框架下,内核拥有数据包池,发送环\接收环上的数据包不需要动态申请,有数据到达网卡时,当有数据到达后,直接从数据包池中取出一个数据包,然后将数据放入此数据包中,再将数据包的描述符放入接收环中。

内核中的数据包池,通过 mmap 技术映射到用户空间。用户态程序最终通过 netmap_if 获取接收发送环 netmap_ring,进行数据包的获取发送。

零拷贝 - 性能对比

无论是传统 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 大文件
SO_ZEROCOPY 0 2 sendmsg 2 大文件

零拷贝 - 开源项目

  • nginx (sendfile)
    静态文件下载

  • Kafka (sendfile)
    适用于系统日志消息这种高吞吐量的大块文件的数据持久化和传输,Kafka 的索引文件使用的是 mmap+write 方式,数据文件使用的是 Sendfile 方式

  • RocketMQ (mmap+write)
    适用于业务级消息这种小块文件的数据持久化和传输

  • nbd-client (splice)
    客户端数据发送

参考文档

深入剖析Linux IO原理和几种零拷贝机制的实现

图解:零拷贝Zero-Copy技术大揭秘

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

Linux中的零拷贝技术

原来 8 张图,就可以搞懂「零拷贝」了

原创文章,作者:luotang,如若转载,请注明出处:https://luotang.me/zerocopy.html

发表评论

您的电子邮箱地址不会被公开。 必填项已用*标注