前言
本篇文章我们来探讨一下Linux中的几种“零拷贝”技术,我们在 java nio,kafka,RocketMQ等框架中多多少少都有听到这个概念,零拷贝是IO性能提升非常重要的技术,也是Netty高性能的原因之一。
物理内存和虚拟内存
内存主要作用是在计算机运行时为操作系统和各种程序提供临时储存,操作系统的进程和进程之间是共享CPU和内存资源的。为了防止内存泄露需要一套完善且高效的内存管理机制。因此现代操作系提供了一种基于主内存抽象出来的概念:虚拟内存(Virtual Memory)。
- 虚拟内存
虚拟内存是计算机系统内存管理的一种技术,主要为每个进程提供私有的地址空间,让每个进程拥有一片连续完整的内存空间。而实际上,虚拟内存通常是被分隔成多个物理内存碎片,还有部分暂时存储在外部磁盘存储器上,在需要时进行数据交换,加载到物理内存中来 - 物理内存
物理内存指通过内存条而获得的内存空间,而虚拟内存则是指将硬盘的一块区域划分来作为内存。也就是说每个虚拟内存都对应一个特定的地址空间(物理内存或者磁盘存储空间)
在用户进程和物理内存引入虚拟内存后,当程序向系统申请内存时,系统为程序分配虚拟内存,虚拟内存地址会映射到物理地址,为了获取到实际的数据,CPU 需要将虚拟地址转换成物理地址。
这里的页表可以理解成是虚拟内存映射到物理列出的链表。
内核空间和用户空间
操作系统的核心是内核,可以访问受保护的内存空间,也有访问底层硬件设备的权限,为了避免用户进程直接操作内核,操作系统将虚拟内存划分为内核空间(Kernel-space)和 用户空间(User-space)。
- 内核空间
内核空间总是驻留在内存中,它是为操作系统的内核保留的。应用程序是不允许直接在该区域进行读写或直接调用内核代码定义的函数的 - 用户空间
每个用户进程都有一个独立的用户空间,处于用户态的进程不能访问内核空间中的数据和调用内核函数 ,因此要进行系统调用的时候,就要将进程切换到内核态。
DMA传输原理
DMA (Direct Memory Access):DMA的意思是直接内存访问,它允许外围设备(硬件子系统)直接访问系统主内存。有了 DMA之后,系统主内存 与 硬盘或网卡之间的数据传输可以绕开 CPU 的全程调度,大大解放了CPU的劳动力,下面我们来理解一下DMA
传统IO流程
我们针对下面案例来分析一下IO的执行流程
RandomAccessFilerandomAccessFile=newRandomAccessFile(newFile("file.txt"),"rw"); byte[] arr=newbyte[(int)file.length()]; //读randomAccessFile.read(arr); //把数据写到SokcetSocketsocket=newServerSocket(5555).accept(); //写socket.getOutputStream().write(arr);
上面的案例完成了一次读写操作,先是从磁盘读取 file.txt 文件,内容存储到 byte[]中,然后把 byte[]中的数据写到socket 。那么在没有DMA的情况下IO是如何工作的呢?
- 用户进程向 发起 read操作,用户进程由用户态切换为内核态,然后一直阻塞等待数据的返回。
- CPU 在接收到指令以后对磁盘发起 I/O 请求,将磁盘数据先放入磁盘控制器缓冲区。
- 接下来由CPU将磁盘缓冲区中的数据拷贝到内核缓冲区,然后再从内核缓冲区拷贝到用户缓冲区。
- 用户进程由内核态切换回用户态,解除阻塞状态,程序继续执行。
由于整个IO过程都需要CPU亲力亲为,在数据的拷贝是非常消耗CPU性能的,为了提升IO性能出现了DMA技术。
DMA IO流程
下面以读取文件数据到内存为例来演示 DMA 原理:
解释一下图中的步骤
- 应用进程发起read命令, 调用CPU 读取数据,此时CPU会将用户进行从用户态切到内核态,程序线程一直阻塞等待数据的返回。
- CPU向 DMA 磁盘控制器发起调度指令。
- DMA 磁盘控制器向磁盘请求IO,将磁盘数据先放入磁盘控制器缓冲区,CPU 不参与此过程。
- 后续DMA收到完成指令, 将数据从磁盘控制器缓冲区拷贝到内核缓冲区。
- DMA 磁盘控制器向 CPU 发出数据读完的信号,由CPU 负责将数据从内核缓冲区拷贝到用户缓冲区。
- 最后切换回用户态,返回数据,解除阻塞, 程序继续往后执行。
所以为什么要出现DMA呢?如果没有DMA,那么所有的拷贝操作都需要CPU的参与,拷贝数据非常消耗CPU资源,导致整体系统性能下降。所以DMA的出现解放了CPU,使得系统性能得到提升。
经过上面的流程,数据已经读取到用户缓冲区,接下来执行 write 向网络发送数据,先将数据从用户空间的页缓存拷贝到内核空间的网络缓冲区(socket buffer)中,然后再将写缓存中的数据拷贝到网卡设备完成数据发送,流程如下:
解释一下图中的步骤
- 用户进程通过 write() 函数向内核(kernel)发起系统调用,上下文从用户态(user space)切换为内核态(kernel space)。
- CPU 将用户缓冲区(user buffer)中的数据拷贝到内核空间(kernel space)的网络缓冲区(socket buffer)。
- CPU 利用 DMA 控制器将数据从网络缓冲区(socket buffer)拷贝到网卡进行数据传输。
- 上下文从内核态(kernel space)切换回用户态(user space),write 系统调用执行返回。
DMA 的问题
整个过程涉及 2 次 CPU 拷贝、2 次 DMA 拷贝总共 4 次拷贝,以及 4 次内核切换,如下图:
下面是内核切换完整流程:
- 用户进程执行read,从用户态切到内核态
- DMA控制器将数据从硬盘拷贝到内核缓冲区
- CPU将内核缓冲区的数据拷贝到用户空间的用户缓冲区
- 上下文从内核态切回用户态,read 调用执行返回。
DMA拷贝虽然一定程度解放了CPU,但是涉及到的内核切换次数和数据拷贝次数太多,依然不能让IO性能达到最优。
零拷贝技术
零拷贝(Zero-copy)技术指在计算机执行操作时,CPU 不需要先将数据从一个内存区域复制到另一个内存区域,从而可以减少上下文切换以及 CPU 的拷贝时间。
它的作用是在数据报从网络设备到用户程序空间传递的过程中,减少数据拷贝次数,减少系统调用,实现 CPU 的零参与,彻底消除 CPU 在这方面的负载
也就是说所谓的零拷贝是消除CPU拷贝,但是DMA拷贝肯定是需要的。
MMAP模式
使用 MMAP 的目的是将内核中缓冲区(read buffer)的地址与用户空间的缓冲区(user buffer)进行映射,
从而实现内核缓冲区与应用程序内存的共享,这样在进行网络传输时,就可以减少内核空间到用户空间的拷贝,大致流程如下:
然而内核读缓冲区(read buffer)仍需将数据拷贝到内核写缓冲区(socket buffer), 整个拷贝过程会发生 4 次内核切换,1 次 CPU 拷贝和 2 次 DMA 拷贝。
- 用户进程调用 mmap 函数,用户进程从用户态切到内核态
- 将用户进程的内核缓冲区与用户缓存区进行内存地址映射
- DMA 控制器将数据从主存或硬盘拷贝到内核缓冲区
- 上下文从内核态切回用户态mmap 系统调用结束
- 用户进程调用 write 函数,上下文从用户态切换为内核态
- CPU 将内核缓冲区的数据拷贝到网络缓冲区(SocketBuffer)
- CPU 利用 DMA 控制器将数据从网络缓冲区拷贝到网卡进行数据传输。
- 上下文从内核态切换回用户态,write 调用结束
MMAP的问题是 4次内核切换,3次数据拷贝,拷贝次数和切换次数依然很多。
Sendfile模式
Sendfile在Linux2.1被引入 ,Sendfile 系统调用的引入,不仅减少了 CPU 拷贝的次数,还减少了上下文切换的次数通。过 Sendfile 数据可以直接在内核空间内部进行 I/O 传输,也就是说数据直接通过内核缓冲区(Kernel Buffer)拷贝到Socket缓冲区(Socket Buffer), 数据根部不经过用户空间,对于用户来说数据是不可见的。
基于 Sendfile 系统调用的零拷贝方式,整个拷贝过程会发生 2 次上下文切换,1 次 CPU 拷贝和 2 次 DMA 拷贝
- 用户进程执行 sendfile,上下文从用户态切换为内核态
- DMA 控制器将数据从主存或硬盘拷贝到内核缓冲区
- CPU 将内核缓冲区中的数据拷贝到的网络缓冲区
- CPU 利用 DMA 控制器将数据从网络缓冲区拷贝到网卡进行数据传输。
- 上下文从内核态切换回用户态Sendfile 结束
Sendfile模式只需要2次内核态的切换,数据拷贝次数还是3次,它的问题是用户程序不能对数据进行修改,而只是单纯地完成了一次数据传输过程。
Sendfile+DMA 优化
在Linux2.4 对Sendfile进行了优化 ,它将内核缓冲区中对应的数据描述信息(内存地址、地址偏移量)记录到相应的网络缓冲区中,由 DMA 根据内存地址、地址偏移量将数据批量地从读缓冲区(read buffer)拷贝到网卡设备中 。
也就是说它实现了将内核缓冲区中的数据直接拷贝到网卡设备,省去了内核缓冲区数据拷贝到网络缓冲区的过程,彻底消除了CPU考别。
整个拷贝过程会发生 2 次上下文切换、0 次 CPU 拷贝以及 2 次 DMA 拷贝。
- 用户进程调用 sendfile 上下文从用户态切换为内核态
- DMA 控制器将数据从主存或硬盘拷贝到内核缓冲区
- CPU 把内核缓冲区中的文件描述符和数据长度拷贝到网络缓冲区(socket buffer)。
- 基于已有的文件描述符和数据长度,DMA 控制器直接批量地将数据从内核缓冲区拷贝到网卡进行数据传输。
- 上下文从内核态切换回用户态Sendfile 执行结束
这种方式用户程序依然不能对数据进行修改的问题,它只适用于将数据从文件拷贝到 socket 套接字上的传输过程。
Splice
Linux 在 2.6.17 版本引入 Splice 系统调用 , 它通过在内核缓冲区和网络缓冲区之间建立通道(pipeline),来避免了两者之间的 CPU 拷贝操作。
整个拷贝过程会发生 2 次上下文切换,0 次 CPU 拷贝以及 2 次 DMA 拷贝。
- 用户进程调用 splice 函数,从用户态切换为内核态。
- DMA 控制器将数据从主存或硬盘拷贝到内核缓冲区。
- CPU 在内核缓冲区和网络缓冲区之间建立管道(pipeline)。
- DMA 控制器将数据从网络缓冲区拷贝到网卡进行数据传输。
- 上下文从内核态切换回用户态Splice 调用结束
Splice 拷贝的问题是用户程序同样不能对数据进行修改。
缓冲区共享
它的思想是为每个进程都维护着一个缓冲区,这个缓冲区池能被同时映射到用户空间和内核态,内核和用户共享这个缓冲区池,这样就避免了一系列的拷贝操作。就目前而言缓冲区共享并不是一个非常成熟的方案,这里也不进行探讨。
总结
零拷贝在数据进行IO时,对性能的影响是非常大的,零拷贝不是不拷贝,而是以消除CPU拷贝,减少拷贝,减少内核切换次数来提升IO性能为目的。
本文简单介绍了物理内存,虚拟内存,用户态,内核态等概念,并介绍了Linux系统中的零拷贝技术的集中方案,下面是各种零拷贝技术的对比
拷贝模式 | 函数 | CPU拷贝次数 | DMA拷贝次数 | 内核切换次数 |
传统IO | read/write | 2 | 2 | 4 |
mmap | mmap/write | 1 | 2 | 4 |
sendfile | sendfile | 1 | 2 | 2 |
sendfile优化 | sendfile | 0 | 2 | 2 |
splice | splice | 0 | 2 | 2 |