内存与零拷贝技术
内存模型
CPU是怎么读取数据的

数据存在哪里
内存
我们的程序和数据都是存储在内存,存储的区域是线性的。
在计算机数据存储中,存储数据的基本单位是字节(byte),1 字节等于 8 位(8 bit)。每一个字节都对应一个内存地址。
内存的地址是从 0 开始编号的,然后自增排列,最后一个地址为内存总字节数 - 1,这种结构好似我们程序里的数组,所以内存的读写任何一个数据的速度都是一样的。
CPU怎么找到数据
现在常用的CPU有32位与64位两种,32 位和 64 位 CPU 最主要区别在于一次能计算多少字节数据:
- 32 位 CPU 一次可以计算 4 个字节;
- 64 位 CPU 一次可以计算 8 个字节;
这里的 32 位和 64 位,通常称为 CPU 的位宽,代表的是 CPU 一次可以计算(运算)的数据量。
之所以 CPU 要这样设计,是为了能计算更大的数值,如果是 8 位的 CPU,那么一次只能计算 1 个字节 0~255 范围内的数值,这样就无法一次完成计算 10000 * 500 ,于是为了能一次计算大数的运算,CPU 需要支持多个 byte 一起计算,所以 CPU 位宽越大,可以计算的数值就越大,比如说 32 位 CPU 能计算的最大整数是 4294967295。
CPU 内部还有一些组件,常见的有寄存器、控制单元和逻辑运算单元等。其中,控制单元负责控制 CPU 工作,逻辑运算单元负责计算,而寄存器可以分为多种类,每种寄存器的功能又不尽相同。
CPU 中的寄存器主要作用是存储计算时的数据,你可能好奇为什么有了内存还需要寄存器?原因很简单,因为内存离 CPU 太远了,而寄存器就在 CPU 里,还紧挨着控制单元和逻辑运算单元,自然计算时速度会很快。
常见的寄存器种类:
通用寄存器:用来存放需要进行运算的数据,比如需要进行加和运算的两个数据。
**程序计数器:**用来存储 CPU 要执行下一条指令「所在的内存地址」,注意不是存储了下一条要执行的指令,此时指令还在内存中,程序计数器只是存储了下一条指令「的地址」。
**指令寄存器:**用来存放当前正在执行的指令,也就是指令本身,指令被执行完成之前,指令都存储在这里。
直接访问物理内存的弊端
如果你是电子相关专业的,肯定在大学里捣鼓过单片机。
单片机是没有操作系统的,所以每次写完代码,都需要借助工具把程序烧录进去,这样程序才能跑起来。
另外,单片机的 CPU 是直接操作内存的「物理地址」。
在这种情况下,要想在内存中同时运行两个程序是不可能的。因为是直接操作物理内存,这就意味着一个程序可以操作内存中的所有地址,如果有恶意程序修改了其他程序在用的地址中的数据,这就可能导致其他程序崩溃。比如第一个程序在 2000 的位置写入一个新的值,将会擦掉第二个程序存放在相同位置上的所有内容,所以同时运行两个程序是根本行不通的,这两个程序会立刻崩溃。
另外,如何为每个程度指定内存地址空间来避免同时修改一处,这种模式下,必然要求程序员手动对数据进行布局,那么内存不够用怎么办呢?而且,每个进程分配多少内存、如何保证指令中访存地址的正确性,这些问题都全部要程序员来负责。这是难以忍受的。
基于局部性原理,CPU 为程序员虚拟化了一层内存,我们只需要与虚拟内存打交道就可以了。所以接下来,我们就来讨论局部性原理说的是什么,聪明的 CPU 设计人员又是如何将这个原理完美应用的。局部性原理
在绝大多数程序的运行过程中,当前指令大概率都会引用最近访问过的数据。也就是说,程序的数据访问会表现出明显的倾向性。这种倾向性,我们就称之为局部性原理 (Principle of locality)。我们可以从两个方面来理解局部性原理。
第一个方面是时间局部性,也就是说被访问过一次的内存位置很可能在不远的将来会被再次访问;另一方面是空间局部性,说的是如果一个内存位置被引用过,那么它邻近的位置在不远的将来也有很大概率会被访问。
基于这个原理,我们可以做出一个合理的推论:无论一个进程占用的内存资源有多大,在任一时刻,它需要的物理内存都是很少的。在这个推论的基础上,CPU 为每个进程只需要保留很少的物理内存就可以保证进程的正常执行了。
而且,为了让程序员编程方便,CPU 和操作系统还联手编织了一个假象:每个进程都独享 128T 的虚拟内存空间,并且每个进程的地址空间都是相互隔离的。什么意思呢?比如说,现在进程 A 中有个变量 a,它的地址是 0x100,但是进程 B 中也有个变量 b,它的地址也是 0x100。但这并不会造成冲突,因为进程 A 的地址空间与进程 B 的地址空间是独立的,相互不影响。虚拟内存与程序局部性原理
CPU 充分利用程序局部性原理,提出了虚拟内存和物理内存的映射 (Mapping) 机制。
操作系统管理着这种映射关系,所以你在写代码的时候,就不用再操心物理内存的使用情况了,你看到的内存就是虚拟内存。这种映射关系是以页为单位的。
看看下面这张图就很好理解了,多个进程的虚拟内存中的页都被映射到物理内存页上。
我们可以从图中看到这两点。第一,虽然虚拟内存提供了很大的空间,但实际上进程启动之后,这些空间并不是全部都能使用的。开发者必须要使用 malloc 等分配内存的接口才能将内存从待分配状态变成已分配状态。
在你得到一块虚拟内存以后,这块内存就是未映射状态,因为它并没有被映射到相应的物理内存,直到对该块内存进行读写时,操作系统才会真正地为它分配物理内存。然后这个页面才能成为正常页面。
第二,在虚拟内存中连续的页面,在物理内存中不必是连续的。只要维护好从虚拟内存页到物理内存页的映射关系,你就能正确地使用内存了。这种映射关系是操作系统通过页表来自动维护的,不必你操心。
不过你还要注意一点,计算机的虚拟内存大小是不一样的。虚拟地址空间往往与机器字宽有关系。例如 32 位机器上,指向内存的指针是 32 位的,所以它的虚拟地址空间是 2 的 32 次方,也就是 4G。在 64 位机器上,指向内存的指针就是 64 位的,但在 64 位系统里只使用了低 48 位,所以它的虚拟地址空间是 2 的 48 次方,也就是 256T。虚拟内存与物理内存的映射
虚拟内存与物理内存映射的过程,是由 CPU 的内存管理单元 (Memory Management Unit, MMU) 自动完成的,但它依赖操作系统设置的页表。

页表的本质是页表项 (Page Table Entry, PTE) 的数组,一般一页大小是4k,一个页表项是4字节,一张表上1024个页表项。一个页表项对应一个实际的物理页,大小是4k,一张4k的页表就代表了4M大小的实际物理页
虚拟空间中的每一页在页表中都有一个页表项与之对应
页表项会记录虚拟页所对应的实际物理页的起始地址。
CPU怎么找到真实的物理地址
一个 CPU 要通过虚拟地址,找到物理地址需要几个步骤呢?大概是下面这四个。

第一步是确定页目录基址。每个 CPU 都有一个页目录基址寄存器,最高级页表的基地址就存在这个寄存器里。在 X86 上,这个寄存器是 CR3。每一次计算物理地址时,MMU 都会从 CR3 寄存器中取出页目录所在的物理地址。
第二步是定位页目录项(PDE)。一个 32 位的虚拟地址可以拆成 10 位,10 位和 12 位三段,上一步找到的页目录表基址加上高 10 位的值乘以 4,就是页目录项的位置。这是因为,一个页目录项正好是 4 字节,所以 1024 个页目录项共占据 4096 字节,刚好组成一页,而 1024 个页目录项需要 10 位进行编码。这样,我们就可以通过最高 10 位找到该地址所对应的 PDE 了。
第三步是定位页表项(PTE)。页目录项里记录着页表的位置,CPU 通过页目录项找到页表的位置以后,再用中间 10 位计算页表中的偏移,可以找到该虚拟地址所对应的页表项了。页表项也是 4 字节的,所以一页之内刚好也是 1024 项,用 10 位进行编码。所以计算公式与上一步相似,用页表基址加上中间 10 位乘以 4,可以得到页表项的地址。最后一步是确定真实的物理地址。上一步 CPU 已经找到页表项了,这里存储着物理地址,这才真正找到该虚拟地址所对应的物理页。虚拟地址的低 12 位,刚好可以对一页内的所有字节进行编码,所以我们用低 12 位来代表页内偏移。计算的公式是物理页的地址直接加上低 12 位。
前面我们分析的是 32 位操作系统,那对于 64 位机器是不是有点不同呢?在 64 位的机器上,使用了 48 位的虚拟地址,所以它需要使用 4 级页表。它的结构与 32 位的 3 级页表是相似的,只是多了一级页目录,定位的过程也从 32 位的 4 步变成了 5 步。内存交换
不过我们前面也说到,由于程序运行符合局部性原理,CPU 访问内存会有很明显的重复访问的倾向性。那对于那些没有被经常使用到的内存,我们可以把它换出到主存之外,比如硬盘上的 swap 区域。所谓 Swap 空间,这块空间是从硬盘划分出来的,用于内存与硬盘的空间交换。新的虚拟内存页可以被映射到刚腾出来的这个物理页。这就涉及到了页面换入换出的调度问题。我们举个例子来说明一下。假如进程 A 一开始将虚拟内存的 0 至 4K,映射到物理内存的 0 至 4K 空间。基于局部性原理,4K 以后的虚拟地址大概率是不会被访问的,我们可以让程序一直运行。直到程序开始访问 4K ~ 8K 之间的虚拟地址了,我们就可以将现在的物理地址里的内容换出到磁盘的 swap 区域,然后再将虚拟内存的 4K ~ 8K 这一个区域映射到 0~4K 的这一块物理内存。在理想情况下,虽然进程 A 的虚拟内存非常大,比如 256T,但 CPU 只需要一个 4K 大小的物理内存页就能满足它的需求了。当然在实际情况中肯定不会这么理想,所以一个进程所占用的物理内存不可能只有一个页。从效率的角度看,当物理内存足够时,操作系统也会尽量让尽可能多的页驻留在物理内存中。毕竟将内存中的数据写到磁盘里是非常耗时的操作。
怎么读取数据
总线是用于 CPU 和内存以及其他设备之间的通信,总线可分为 3 种:
地址总线: 用于指定 CPU 将要操作的内存地址;
数据总线: 用于读写内存的数据;
控制总线: 用于发送和接收信号,比如中断、设备复位等信号,CPU 收到信号后自然进行响应,这时也需要控制总线;
当 CPU 要读写内存数据的时候,一般需要通过下面这三个总线:
首先要通过「地址总线」来指定内存的地址;
然后通过「控制总线」控制是读或写命令;
最后通过「数据总线」来传输数据;
Linux与进程内存模型
抽像内存布局
我们知道,CPU 运行一个程序,实质就是在顺序执行该程序的机器码。一个程序的机器码会被组织到同一个地方,这个地方就是代码段。
另外,程序在运行过程中必然要操作数据。这其中,对于有初值的变量,它的初始值会存放在程序的二进制文件中,而且,这些数据部分也会被装载到内存中,即程序的数据段。数据段存放的是程序中已经初始化且不为 0 的全局变量和静态变量。
对于未初始化的全局变量和静态变量,因为编译器知道它们的初始值都是 0,因此便不需要再在程序的二进制映像中存放这么多 0 了,只需要记录他们的大小即可,这便是 BSS 段。BSS 段这个缩写名字是 Block Started by Symbol,但很多人可能更喜欢把它记作 Better Save Space 的缩写。
数据段和 BSS 段里存放的数据也只能是部分数据,主要是全局变量和静态变量,但程序在运行过程中,仍然需要记录大量的临时变量,以及运行时生成的变量,这里就需要新的内存区域了,即程序的堆空间跟栈空间。与代码段以及数据段不同的是,堆和栈并不是从磁盘中加载,它们都是由程序在运行的过程中申请,在程序运行结束后释放。
总的来说,一个程序想要运行起来所需要的几块基本内存区域:代码段、数据段、BSS 段、堆空间和栈空间。下面就是内存布局的示意图:
除了上面所讲的基本内存区域外,现代应用程序中还会包含其他的一些内存区域,主要有以下几类:存放加载的共享库的内存空间:如果一个进程依赖共享库,那对应的,该共享库的代码段、数据段、BSS 段也需要被加载到这个进程的地址空间中。
共享内存段:我们可以通过系统调用映射一块匿名区域作为共享内存,用来进行进程间通信。
内存映射文件:我们也可以将磁盘的文件映射到内存中,用来进行文件编辑或者是类似共享内存的方式进行进程通信。
用户空间与内核空间
IA-32 机器上的 Linux 进程内存布局
在 32 位机器上,每个进程都具有 4GB 的寻址能力。Linux 系统会默认将高地址的 1GB 空间分配给内核,剩余的低 3GB 是用户可以使用的用户空间。下图是 32 位机器上 Linux 进程的一个典型的内存布局。在实践中,我们可以通过cat /proc/pid/maps来查看某个进程的实际虚拟内存布局。

Intel 64 机器上的 Linux 进程内存布局
目前的 64 系统下的寻址空间是 2^48,即 256TB。而且根据 canonical address 的划分,地址空间天然地被分割成两个区间,分别是 0x0 - 0x00007fffffffffff 和 0xffff800000000000 - 0xffffffffffffffff。这样就直接将低 128T 的空间划分为用户空间,高 128T 划分为内核空间。下面这张图展示了 Intel 64 机器上的 Linux 进程内存布局:

从图中你可以看到,在用户空间和内核空间之间有一个巨大的内存空洞。这块空间之所以用更深颜色来区分,是因为这块空间的不可访问是由 CPU 来保证的(这里的地址都不满足 Intel 64 的 Canonical form)。
对于 64 位的程序,你在查看 /proc/pid/maps 的过程中,会发现代码段跟数据段的中间还有一段不可以读写的保护段,它的作用也是防止程序在读写数据段的时候越界访问到代码段,这个保护段可以让越界访问行为直接崩溃,防止它继续往下运行。进程的内存模型
在 Linux 中,进程是我们非常熟悉的东东了,哪怕是只写过一天代码的人也都用过它。JVM就是一个进程。在 Linux 中,是用一个 task_struct 来实现 Linux 进程的。每一个进程都有一个进程 id 的概念。在 task_struct 中有两个相关的字段,分别是 pid 和 tgid。
对于用户进程来讲,内存描述符 mm_struct( mm 代表的是 memory descriptor)是非常核心的数据结构。整个进程的虚拟地址空间部分都是由它来表示的。
进程在运行的时候,在用户态其所需要的代码,全局变量数据,以及 mmap 内存映射等全部都是通过 mm_struct 来进行内存查找和寻址的。这个数据结构的定义位于 include/linux/mm_types.h 文件下。1
2
3
4
5
6
7
8
9
10
11//file:include/linux/mm_types.h
struct mm_struct {
struct vm_area_struct * mmap; /* list of VMAs */
struct rb_root mm_rb;
unsigned long mmap_base; /* base of mmap area */
unsigned long task_size; /* size of task vm space */
unsigned long start_code, end_code, start_data, end_data;
unsigned long start_brk, brk, start_stack;
unsigned long arg_start, arg_end, env_start, env_end;
}其中 start_code、end_code 分别指向代码段的开始与结尾、start_data 和 end_data 共同决定数据段的区域、start_brk 和 brk 中间是堆内存的位置、start_stack 是用户态堆栈的起始地址。整个 mm_struct 和地址空间、页表、物理内存的关系如下图。

网络包内核空间区域

对于一个网卡的结构,会有一根线缆连接到外面的世界。网卡会检查线缆上的电信号,并将电信号转换成packet。网卡会接入到一个主机上,主机会带有网卡的驱动软件。我们需要将网卡解码出来的packet传递给主机的内存,这样软件才能解析packet。
E1000网卡会监听网线上的电信号,但是当收到packet的时候,网卡内部并没有太多的缓存,所以网卡会直接将packet拷贝到主机的内存中,而内存中的packet会等待驱动来读取自己。所以,网卡需要事先知道它应该将packet拷贝到主机内存中的哪个位置。
当位于主机的驱动初始化网卡的时候,它会分配一定数量,例如16个1500字节长度的packet buffer,然后再创建一个16个指针的数组。就是ring buffer。为什么叫ring呢?因为在这个数组中,如果用到了最后一个buffer,下一次又会使用第一个buffer。主机上的驱动软件会告诉网卡DMA ring在内存中的地址,这样网卡就可以将packet拷贝到内存中的对应位置。
传输完成之后,网卡会将内部的记录的指针指向DMA ring的下一个位置,这样就可以拷贝下一个packet。
DMA把数据拷贝到ring buffer后,会发起一个硬中断通知CPU。CPU发起一个软中断,ksoftirqd进程处理软中断,将数据帧从ringbuffer上接下来,保存为一个skb。放到socket中。
JVM内存模型
深入理解Java虚拟机(第2版)》中的描述是下面这个样子的:

JVM的内存结构大概分为:堆(Heap):线程共享。所有的对象实例以及数组都要在堆上分配。回收器主要管理的对象。
方法区(Method Area):线程共享。存储类信息、常量、静态变量、即时编译器编译后的代码。
方法栈(JVM Stack):线程私有。存储局部变量表、操作栈、动态链接、方法出口,对象指针。
本地方法栈(Native Method Stack):线程私有。为虚拟机使用到的Native 方法服务。如Java使用c或者c++编写的接口服务时,代码在此区运行。
程序计数器(Program Counter Register):线程私有。有些文章也翻译成PC寄存器(PC Register),同一个东西。它可以看作是当前线程所执行的字节码的行号指示器。指向下一条要执行的指令。
JVM内存与进程内存对应关系

未使用区是分配新内存空间的预备区域。对于普通进程来说,这个区域被可用于堆和栈空间的申请及释放,每次堆内存分配都会使用这个区 域,因此大小变动频繁;对于JVM进程来说,调整堆大小及线程栈时会使用该区域,而堆大小一般较少调整,因此大小相对稳定。操作系统会动态调整这个区域的 大小,并且这个区域通常并没有被分配实际的物理内存,只是允许进程在这个区域申请堆或栈空间。线程工作内存与主存
Java内存模型规定了所有的变量存储在JVM的主内存中。每条线程还有自己的工作内存(类比高速缓存)。线程工作内存中保存了被该线程使用到的变量的主内存副本拷贝,线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行,而不能直接读写主内存中的变量。不同线程之间的工作内存也是相互独立的,线程间变量值传递均需要主内存完成。线程、主内存、工作内存之间的关系如下图所示:

CPU计算缓存

其实在286之前的时代的CPU本是没有缓存的,因为当时的CPU和内存速度差异没有现在这么大,CPU直接访问内存。但是到386时代,CPU和内存的速度不匹配了,第一次出现了缓存。而且最早的缓存并没有放在CPU模块里,而是放在主板上的。再往后CPU越来越快,现在CPU的速度比内存要快百倍以上,所以就逐步演化出了L1、L2、L3三级缓存结构,而且都集成到的CPU芯片里,以进一步提高访问速度。
L1最接近于CPU,速度也最快,但是容量最小。一般现代CPU的L1会分成两个,一个用来cache data,一个用来cache code,这是因为code和data的更新策略并不相同,而且因为CISC的变长指令,code cache要做特殊优化。 一般每个核都有自己独立的data L1和code L1。
越往下,速度越慢,容量越大。L2一般也可以做到每个核一个独立的。但是L3一般就是整颗CPU共享的了。
CPU进行计算时,先将数据加载到高速缓存中,不同的核加载到的高速缓存可能不一致。堆外内存
如果在 JVM 内部执行 I/O 操作时,必须将数据拷贝到堆外内存,才能执行系统调用。
问题:为什么操作系统不能直接使用 JVM 堆内存进行 I/O 的读写呢?1
2
3原因有二:
操作系统并不感知 JVM 的堆内存,而且 JVM 的内存布局与操作系统所分配的是不一样的,操作系统并不会按照 JVM 的行为来读写数据。
同一个对象的内存地址随着 JVM GC 的执行可能会随时发生变化,例如 JVM GC 的过程中会通过压缩来减少内存碎片,这就涉及对象移动的问题了。平时开发时,会使用 NIO 的 DirectBuffer 来创建堆外内存。DirectBuffer底层是使用mmap来实现的。
前面我们介绍进程时说过进程的地址空间结构。1
2
3
4
5
6
7
8
9
10struct mm_struct {
struct vm_area_struct * mmap; /* list of VMAs */
struct rb_root mm_rb;
unsigned long mmap_base; /* base of mmap area */
unsigned long task_size; /* size of task vm space */
unsigned long start_code, end_code, start_data, end_data;
unsigned long start_brk, brk, start_stack;
unsigned long arg_start, arg_end, env_start, env_end;
}其中mmap对应了一个mmap区域。
mmap 系统调用可以将用户空间的虚拟内存地址与文件进行映射(绑定),对映射后的虚拟内存地址进行读写操作就如同对文件进行读写操作一样。原理如图 2 所示:
当多个进程映射到同一个文件地址,那么,可以实现进程间数据共享。内存总结

零拷贝
原理
如果服务端要提供文件传输的功能,我们能想到的最简单的方式是:将磁盘上的文件读取出来,然后通过网络协议发送给客户端。
传统 I/O 的工作方式是,数据读取和写入是从用户空间到内核空间来回复制,而内核空间的数据是通过操作系统层面的 I/O 接口从磁盘读取或写入。
代码通常如下,一般会需要两个系统调用:1
2read(file, tmp_buf, len);
write(socket, tmp_buf, len);代码很简单,虽然就两行代码,但是这里面发生了不少的事情。

首先,期间共发生了 4 次用户态与内核态的上下文切换,因为发生了两次系统调用,一次是 read() ,一次是 write(),每次系统调用都得先从用户态切换到内核态,等内核完成任务后,再从内核态切换回用户态。
上下文切换到成本并不小,一次切换需要耗时几十纳秒到几微秒,虽然时间看上去很短,但是在高并发的场景下,这类时间容易被累积和放大,从而影响系统的性能。
其次,还发生了 4 次数据拷贝,其中两次是 DMA 的拷贝,另外两次则是通过 CPU 拷贝的,下面说一下这个过程:_第一次拷贝_,把磁盘上的数据拷贝到操作系统内核的缓冲区里,这个拷贝的过程是通过 DMA 搬运的。
_第二次拷贝_,把内核缓冲区的数据拷贝到用户的缓冲区里,于是我们应用程序就可以使用这部分数据了,这个拷贝到过程是由 CPU 完成的。
_第三次拷贝_,把刚才拷贝到用户的缓冲区里的数据,再拷贝到内核的 socket 的缓冲区里,这个过程依然还是由 CPU 搬运的。
_第四次拷贝_,把内核的 socket 缓冲区里的数据,拷贝到网卡的缓冲区里,这个过程又是由 DMA 搬运的。
我们回过头看这个文件传输的过程,我们只是搬运一份数据,结果却搬运了 4 次,过多的数据拷贝无疑会消耗 CPU 资源,大大降低了系统性能。
这种简单又传统的文件传输方式,存在冗余的上文切换和数据拷贝,在高并发系统里是非常糟糕的,多了很多不必要的开销,会严重影响系统性能。
如何实现零拷贝
零拷贝技术实现的方式通常有 2 种:
- mmap + write
- sendfile
下面就谈一谈,它们是如何减少「上下文切换」和「数据拷贝」的次数。
mmap + write
在前面我们知道,read() 系统调用的过程中会把内核缓冲区的数据拷贝到用户的缓冲区里,于是为了减少这一步开销,我们可以用 mmap() 替换 read() 系统调用函数。
1 | |
mmap() 系统调用函数会直接把内核缓冲区里的数据「映射」到用户空间,这样,操作系统内核与用户空间就不需要再进行任何的数据拷贝操作。
具体过程如下:
- 应用进程调用了 mmap() 后,应用进程跟操作系统内核「共享」这个缓冲区;在用户空间中的修改都会反应中内核缓冲区中。数据没必要拷贝到用户空间中。
- 应用进程再调用 write(),操作系统直接将内核缓冲区的数据拷贝到 socket 缓冲区中,这一切都发生在内核态,由 CPU 来搬运数据;
- 最后,把内核的 socket 缓冲区里的数据,拷贝到网卡的缓冲区里,这个过程是由 DMA 搬运的。
我们可以得知,通过使用 mmap() 来代替 read(), 可以减少一次数据拷贝的过程。
但这还不是最理想的零拷贝,因为仍然需要通过 CPU 把内核缓冲区的数据拷贝到 socket 缓冲区里,而且仍然需要 4 次上下文切换,因为系统调用还是 2 次。
sendfile
在 Linux 内核版本 2.1 中,提供了一个专门发送文件的系统调用函数 sendfile(),函数形式如下:
1 | |
它的前两个参数分别是目的端和源端的文件描述符,后面两个参数是源端的偏移量和复制数据的长度,返回值是实际复制数据的长度。
首先,它可以替代前面的 read() 和 write() 这两个系统调用,这样就可以减少一次系统调用,也就减少了 2 次上下文切换的开销。
其次,该系统调用,可以直接把内核缓冲区里的数据拷贝到 socket 缓冲区里,不再拷贝到用户态,这样就只有 2 次上下文切换,和 3 次数据拷贝。如下图:
但是这还不是真正的零拷贝技术,如果网卡支持 SG-DMA(_The Scatter-Gather Direct Memory Access_)技术(和普通的 DMA 有所不同),我们可以进一步减少通过 CPU 把内核缓冲区里的数据拷贝到 socket 缓冲区的过程。
你可以在你的 Linux 系统通过下面这个命令,查看网卡是否支持 scatter-gather 特性:
1 | |
于是,从 Linux 内核 2.4 版本开始起,对于支持网卡支持 SG-DMA 技术的情况下, sendfile() 系统调用的过程发生了点变化,具体过程如下:
- 第一步,通过 DMA 将磁盘上的数据拷贝到内核缓冲区里;
- 第二步,缓冲区描述符和数据长度传到 socket 缓冲区,这样网卡的 SG-DMA 控制器就可以直接将内核缓存中的数据拷贝到网卡的缓冲区里,此过程不需要将数据从操作系统内核缓冲区拷贝到 socket 缓冲区中,这样就减少了一次数据拷贝;
所以,这个过程之中,只进行了 2 次数据拷贝,如下图:
这就是所谓的零拷贝(Zero-copy)技术,因为我们没有在内存层面去拷贝数据,也就是说全程没有通过 CPU 来搬运数据,所有的数据都是通过 DMA 来进行传输的。。
零拷贝技术的文件传输方式相比传统文件传输的方式,减少了 2 次上下文切换和数据拷贝次数,只需要 2 次上下文切换和数据拷贝次数,就可以完成文件的传输,而且 2 次的数据拷贝过程,都不需要通过 CPU,2 次都是由 DMA 来搬运。
所以,总体来看,零拷贝技术可以把文件传输的性能提高至少一倍以上。
中间件零拷贝案例
Netty
介绍完传统 Linux 的零拷贝技术之后,我们再来学习下 Netty 中的零拷贝如何实现。Netty 中的零拷贝和传统 Linux 的零拷贝不太一样。Netty 中的零拷贝技术除了操作系统级别的功能封装,更多的是面向用户态的数据操作优化,主要体现在以下 5 个方面:
- 堆外内存,避免 JVM 堆内存到堆外内存的数据拷贝。
- CompositeByteBuf 类,可以组合多个 Buffer 对象合并成一个逻辑上的对象,避免通过传统内存拷贝的方式将几个 Buffer 合并成一个大的 Buffer。
- 通过 Unpooled.wrappedBuffer 可以将 byte 数组包装成 ByteBuf 对象,包装过程中不会产生内存拷贝。
- ByteBuf.slice 操作与 Unpooled.wrappedBuffer 相反,slice 操作可以将一个 ByteBuf 对象切分成多个 ByteBuf 对象,切分过程中不会产生内存拷贝,底层共享一个 byte 数组的存储空间。
- Netty 使用 FileRegion 实现文件传输,FileRegion 底层封装了 FileChannel#transferTo() 方法,可以将文件缓冲区的数据直接传输到目标 Channel,避免内核缓冲区和用户态缓冲区之间的数据拷贝,这属于操作系统级别的零拷贝。
Kafka
Kafka 中存在大量的网络数据持久化到磁盘(Producer 到 Broker)和磁盘文件通过网络发送(Broker 到 Consumer)的过程。这一过程的性能直接影响 Kafka 的整体吞吐量。
我们把 Kafka 的生产和消费简化成如下两个过程来看[2]:
- 网络数据持久化到磁盘 (Producer 到 Broker)
- 磁盘文件通过网络发送(Broker 到 Consumer)
网络数据持久化到磁盘 (Producer 到 Broker)
对于 kafka 来说,Producer 生产的数据存到 broker,这个过程读取到 socket buffer 的网络数据,其实可以直接在内核空间完成落盘。并没有必要将 socket buffer 的网络数据,读取到应用进程缓冲区;在这里应用进程缓冲区其实就是 broker,broker 收到生产者的数据,就是为了持久化。
在此特殊场景下:接收来自 socket buffer 的网络数据,应用进程不需要中间处理、直接进行持久化时。可以使用 mmap 内存文件映射。
它的工作原理是直接利用操作系统的 Page 来实现文件到物理内存的直接映射。完成映射之后你对物理内存的操作会被同步到硬盘上。
使用这种方式可以获取很大的 I/O 提升,省去了用户空间到内核空间复制的开销。
mmap 也有一个很明显的缺陷——不可靠,写到 mmap 中的数据并没有被真正的写到硬盘,操作系统会在程序主动调用 flush 的时候才把数据真正的写到硬盘。Kafka 提供了一个参数——producer.type 来控制是不是主动 flush;如果 Kafka 写入到 mmap 之后就立即 flush 然后再返回 Producer 叫同步(sync);写入 mmap 之后立即返回 Producer 不调用 flush 就叫异步(async),默认是 sync。磁盘文件通过网络发送(Broker 到 Consumer)
Kafka 采用的方案是通过 NIO 的 transferTo/transferFrom 调用操作系统的 sendfile 实现零拷贝,与Netty一致。
sendfile 系统调用,提供了零拷贝。数据通过 DMA 拷贝到内核态 Buffer 后,直接通过 DMA 拷贝到 NIC Buffer,无需 CPU 拷贝。这也是零拷贝这一说法的来源。除了减少数据拷贝外,因为整个读文件 - 网络发送由一个 sendfile 调用完成,整个过程只有两次上下文切换,因此大大提高了性能。Nginx
Nginx 也支持零拷贝技术,一般默认是开启零拷贝技术,这样有利于提高文件传输的效率,是否开启零拷贝技术的配置如下:sendfile 配置的具体意思:1
2
3
4
5http {
...
sendfile on
...
}
- 设置为 on 表示,使用零拷贝技术来传输文件:sendfile ,这样只需要 2 次上下文切换,和 2 次数据拷贝。
- 设置为 off 表示,使用传统的文件传输技术:read + write,这时就需要 4 次上下文切换,和 4 次数据拷贝。
当然,要使用 sendfile,Linux 内核版本必须要 2.1 以上的版本。