彻底搞懂零拷贝(Zero-Copy)技术

发布于 2023-07-11  2222 次阅读


1.名词释义

名词释义引用资料
零拷贝(英语:Zero-copy;也译零拷贝)技术是指计算机执行操作时,CPU不需要先将数据从某处内存复制到另一个特定区域。这种技术通常用于通过网络传输文件时节省CPU周期和内存带宽零复制
DMA直接内存存取Direct Memory Access,DMA)是计算机科学中的一种内存访问技术。它允许某些电脑内部的硬件子系统(电脑外设),可以独立地直接读写系统内存,而不需中央处理器(CPU)介入处理 。在同等程度的处理器负担下,DMA是一种快速的数据传送方式。很多硬件的系统会使用DMA,包含硬盘控制器、绘图显卡网卡声卡直接内存存取
系统调用系统调用是操作系统的最小功能单位,通过提供一些基本功能的接口供应用程序调用来调度内核空间管理的资源。 1. 系统调用发生在用户态,对系统调用的处理发生在核心态。 2.执行陷入指令会产生内中断,使处理器从用户态进入核心态。
CPU上下CPU在运行一个任务之前,要知道任务从哪里加载、从哪里开始运行,这些数据都存放在CPU寄存器和程序计数器(Program Counter,PC)中。CPU寄存器,是CPU本身含有的容量小,速度快的内存模块。PC是用来存储CPU正在执行的或即将执行的指令的位置。这些数据都是CPU执行一个任务依赖的必要环境,称之为CPU上下文。
CPU上下文切换先把当前任务的CPU上下文保存起来,然后加载即将运行任务的上下文的过程,称为CPU上下文切换。这些保存下来的上下文,将被存储在内核中,在CPU重新执行其对应的任务时,再次加载。这样保证了逻辑执行的连续性。CPU上下文切换分为:进程上下文切换、线程上下文切换、协程上下文切换、中断上下文切换。
系统调用上下文切换通过系统调用从用户态切换到内核态时候CPU将会进行上下文切换。系统调用的过程,其实是发生了两次 CPU 上下文切换。(用户态-内核态-用户态)。系统调用过程中,并不会涉及到虚拟内存等进程用户态的资源,也不会切换进程。这跟我们通常所说的进程上下文切换是不一样的:进程上下文切换,是指从一个进程切换到另一个进程运行;而系统调用过程中一直是同一个进程在运行。所以,系统调用过程通常称为特权模式切换,而不是上下文切换。系统调用属于同进程内的 CPU 上下文切换。但实际上,系统调用过程中,CPU 的上下文切换还是无法避免的。

2.为什么要有系统调用

2.1 不同指令集架构下的Linux操作系统特权等级

2.1.1 X86系列

x86 的保护模式中,有四种特权等级。Linux 使用 Ring 0 和 Ring 3:

image-20230711215223420
  1. Ring 0 是内核空间,具有最高权限,可访问所有硬件资源.通常,因为操作系统是为所有程序服务的,可靠性最高,而且必须对软硬件有完全的控制权,所以它的主体部分必须拥有特权级Ring 0,并处于整个环形结构的中心。也正是因为这样,操作系统的主体部分通常又被称做内核(Kernel、 Core)。而特权级Ring 1和Ring 2通常赋予那些可靠性不如内核的系统服务程序,比较典型的就是设备驱动程序。当然,在很多比较流行的操作系统中,驱动程序与内核的特权级别相同,都是Ring 0。
  2. Ring 3 是用户空间,权限最低,无法直接访问硬件资源。应用程序的可靠性被视为是最低的,而且通常不需要直接访问硬件和一些敏感的系统资源,调用设备驱动程序或者操作系统例程就能完成绝大多数工作,故赋予它们最低的特权级别Ring 3。

2.1.2ARMv8系列特权等级

image-20230711221730997
  1. ARMv8系列特权等级(exception Level,简称EL0,EL1,EL2,EL3),分别用于App、OS、虚拟化、安全
  2. 不同的ARM指令集对应的特权等级不一定相同,这里仅举例ARMv8系列的。

2.2用户态和内核态之间的切换

当程序通过系统调用从用户态切换到内核态,那么处在用户态的线程需要先保存当前的数据以及运行的指令,方便回到用户态时继续执行,这中间还有很多其他的事情需要做,例如CPU寄存器需要保存和加载, 系统调度器的代码需要执行, TLB实例需要重新加载, CPU 的pipeline需要刷掉。

从用户态到内核态的转变,需要通过系统调用来完成。比如,当我们查看文件内容时,就需要多次系统调用来完成:首先调用 open() 打开文件,然后调用 read() 读取文件内容,并调用 write() 将内容写到标准输出,最后再调用 close() 关闭文件。

3.读取磁盘文件数据到用户进程的流程

3.1不使用DMA

image-20230712001733499
  1. 整个数据的传输过程,都要需要 CPU 亲自参与搬运数据的过程,而且这个过程,CPU 是不能做其他事情的。
  2. 调用一次 read()系统调用需要 2次CPU上下文切换,2次CPU数据拷贝。

3.2使用DMA

直接内存访问(Direct Memory Access),是一种硬件设备绕开CPU独立直接访问内存的机制。所以DMA在一定程度上解放了CPU,把之前CPU的杂活让硬件直接自己做了,提高了CPU效率。

目前支持DMA的硬件包括:网卡、声卡、显卡、磁盘控制器等。

img

有了DMA的参与之后的流程发生了一些变化:

img

读数据过程:

  • 应用程序要读取磁盘数据,调用read()函数从而实现用户态切换内核态,这是第1次状态切换;
  • DMA控制器将数据从磁盘拷贝到内核缓冲区,这是第1次DMA拷贝;
  • CPU将数据从内核缓冲区复制到用户缓冲区,这是第1次CPU拷贝;
  • CPU完成拷贝之后,read()函数返回实现用户态切换用户态,这是第2次状态切换;
  1. DMA 收到磁盘的信号,将磁盘控制器缓冲区中的数据拷贝到内核缓冲区中,此时不占用 CPU,CPU 可以执行其他任务;
  2. 调用一次 read()系统调用需要 2次CPU上下文切换,1次CPU数据拷贝,1次DMA数据拷贝;

4.socket编程文件传输read()+write()方式

image-20230712002919076
image-20230711233831275

读数据过程:

  • 应用程序要读取磁盘数据,调用read()函数从而实现用户态切换内核态,这是第1次状态切换;
  • DMA控制器将数据从磁盘拷贝到内核缓冲区,这是第1次DMA拷贝;
  • CPU将数据从内核缓冲区复制到用户缓冲区,这是第1次CPU拷贝;
  • CPU完成拷贝之后,read()函数返回实现用户态切换用户态,这是第2次状态切换;

写数据过程:

  • 应用程序要向网卡写数据,调用write()函数实现用户态切换内核态,这是第1次切换;
  • CPU将用户缓冲区数据拷贝到socket缓冲区,这是第1次CPU拷贝;
  • DMA控制器将数据从socket缓冲区复制到网卡,这是第1次DMA拷贝;
  • 完成拷贝之后,write()函数返回实现内核态切换用户态,这是第2次切换;
  1. DMA 收到磁盘的信号,将磁盘控制器缓冲区中的数据拷贝到内核缓冲区中,此时不占用 CPU,CPU 可以执行其他任务;
  1. 调用一次 read()系统调用需要 2次CPU上下文切换,1次CPU数据拷贝,,1次DMA数据拷贝;调用一次 write()系统调用需要 2次CPU上下文切换,1次CPU数据拷贝,,1次DMA数据拷贝.
  2. 从读取磁盘文件到发送到网卡,需要见过4次CPU上下文切换,2次CPU数据拷贝,2次DMA数据拷贝

5.零拷贝技术优化方案

目前来看,零拷贝技术的几个实现手段包括:mmap+write、sendfile、sendfile+DMA收集、splice等。

img

5.1 mmap +write()方式

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

mmap对大文件传输有一定优势,但是小文件可能出现碎片,并且在多个进程同时操作文件时可能产生引发coredump的signal。

#include <sys/mman .h>
void* mmap( void *start, size t length, int prot, int flags, int fd,
off t offset );
int munmap( void *start, size t length );
  1. start参数允许用户使用某个特定的地址作为这段内存的起始地址,如果它被设置成NULL,那么系统会自动分配一个地址。
  2. length参数指定内存段的长度。
  3. prot参数用来设置内存段的访问权限。PROT_READ(内存段可读)、PROT_WRITE(内存段可写)、PROT_EXEC(内存段可执行)、PROT_NONE(内存段不能被访问)
  4. flags 参数控制内存段内容被修改后序的行为。它可以被设置为表 6-1 中的某些值(这里仅列出了常用的值)的按位或(其中 MAP SHARED 和 MAP PRIVATE 是斥的,不能同时指定)。image-20230711000525821
  5. fd 参数是被映射文件对应的文件描述符。它一般通过 pen 系统调用获得。
  6. offset 参数设置从文件的何处开始映射(对于不需要读入整个文件的情况)。
  7. mmap函数成功时返回指向目标内存区域的指针,失败则返回MAP FAILED((oid*)-1)并设置errno。
  8. munmap 函数成功时返回0,失败则返回-1并设置errno。
image-20230712010742352

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

img
  1. mmap()系统调用需要2次CPU上下文切换,1次DMA数据拷贝;write()系统调用需要2次CPU上下文切换,1次CPU数据拷贝,1次DMA数据拷贝。
  2. mmap()+write()合起来需要4次CPU上下文切换,1次CPU数据拷贝,2次DMA数据拷贝

5.2 sendfile方式

mmap+write方式有一定改进,但是由系统调用引起的状态切换并没有减少。

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

#include <sys/sendfile.h> 
ssize_t sendfile(int out_fd, int in_fd, off t* offset, size t count );
  1. in_fd 参数是待读出内容的文件描述符;in_fd必须是一个支持类似 mmap 函数的文件描述符,即它必须指向真实的文件,不能是 soket 和管道.
  2. out_fd 参数是待写内容的文件描述符, out fd则必须是一个 socket文件fd.
  3. offset参数指定从读人文件流的哪个位置开始读,如果为空,则使用读人文件流默认的起始位置;
  4. count参数指定在文件描述符 in fd 和out fd 之间传输的字节数。
  5. sendfile 成功时返回传输的字节数,失败则返回-1并设置 errno。

sendfile方式只使用一个函数就可以完成之前的read+write 和 mmap+write的功能,这样就少了2次状态切换,由于数据不经过用户缓冲区,因此该数据无法被修改。

img
img
  1. sendfile()系统调用需要2次CPU上下文切换,1次CPU数据拷贝,2次DMA数据拷贝

5.3 sendfile+DMA收集

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

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

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

img

sendfile()系统调用需要2次CPU上下文切换,0次CPU数据拷贝,2次DMA数据拷贝

5.4 splice方式

splice系统调用是Linux 在 2.6 版本引入的,其不需要硬件支持,并且不再限定于socket上,实现两个普通文件之间的数据零拷贝。

#include <fcntl.h>
ssize_t splice( int fd_in, loff_t* off_in, int fd_out, loff_t* off_out,size_t len, unsigned int flags );
  1. fd_in 参数是待输人数据的文件描述符。如果 fd_in 是一个管道文件描述符,那么off_in参数必须被设置为 NULL。如果 fd_in 不是一个道文件描述符 (比如 socket),那么 off_in表示从输人数据流的何处开始读取数据。此时,若 off_in 被设置为 NULL,则表示从输入数据流的当前偏移位置读入 ;若 of f_in 不为 NULL,则它将指出具体的偏移位置。
  2. fd_out/off_out 参数的含义与 fd_in/off_in 相同,不过用于输出数据流.
  3. len 参数指定移动数据的长度.
  4. fags 参数则控制数据如何移动
  5. 使用 splice 函数时,fd_in 和 fd_out 必须至少有一个是管道文件描述符。splice 函数调用成功时返回移动字节的数量。它可能返回 0,表示没有数据需要移动,这发生在从管道中读取数据 (fd_in 是管道文件描述符)而该管道没有被写人任何数据时。splice 函数失败时返回-1并设置errno。

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

img

splice()系统调用需要2次CPU上下文切换,1次CPU数据拷贝,2次DMA数据拷贝

6.总结

方案CPU上下文切换次数CPU拷贝次数DMA拷贝次数局限
read()+write()方式422CPU上下文切换和CPU拷贝数据次数多。
mmap()+write()方式412mmap对大文件传输有一定优势,但是小文件可能出现碎片,并且在多个进程同时操作文件时可能产生引发coredump的signal。
sendfile()方式212in_fd 参数是待读出内容的文件描述符;in_fd必须是一个支持类似 mmap 函数的文件描述符,即它必须指向真实的文件,不能是 soket 和管道. out_fd 参数是待写内容的文件描述符, out fd则必须是一个 socket文件fd.
sendfile()+202in_fd 参数是待读出内容的文件描述符;in_fd必须是一个支持类似 mmap 函数的文件描述符,即它必须指向真实的文件,不能是 soket 和管道. out_fd 参数是待写内容的文件描述符, out fd则必须是一个 socket文件fd.
splice()方式202fd_in 和 fd_out 必须至少有一个是管道文件描述符

7.参考资料

  1. 上下文切换总结
  2. 【x86】特权级别 CPL / RPL / DPL / IOPL
  3. arm下有ring0吗?
  4. 什么是零拷贝
  5. 掌握这5个技巧,彻底掌握Netty中的零拷贝!


繁华落尽,雪花漫天飞舞。