1 术语
1.1 零拷贝
"零拷贝"中的"拷贝"是操作系统在I/O操作中,将数据从一个内存区域复制到另外一个内存区域。而"零"并不是指0次复制,更多的是指在用户态和内核态之间的复制是0次。
1.2 CPU COPY
通过计算机的组成原理我们知道,内存的读写操作是需要CPU的协调数据总线、地址总线和控制总线来完成的。因此在"拷贝"发生的时候,往往需要CPU暂停现有的处理逻辑,来协助内存的读写,这种我们称为CPU COPY。
CPU COPY不但占用了CPU资源,还占用了总线的带宽。
1.3 DMA COPY
DMA(DIRECT MEMORY ACCESS)是现代计算机的重要功能。它的一个重要的特点就是,当需要与外设进行数据交换时,CPU只需要初始化这个动作便可以继续执行其他指令,剩下的数据传输的动作完全由DMA来完成。就是 DMA 替他完成一部分的拷贝工作,这样 CPU 就能去做其他事情了。
可以看到DMA COPY是可以避免大量的CPU中断的。
1.4 SOCKET BUFFER
TCP/IP协议栈维护着两个缓冲区:send buffer和recv buffer,它们合称为socket buffer。
1.5 上下文切换
本文上下文切换是指由用户态切换到内核态,以及由内核态切换到用户态。
1.5 内核空间和用户空间
现代操作系统用的都是虚拟存储器(即对上层应用来说,地址是连续的,但是在磁盘上不一定是连续的),一般32位的寻址空间为4G(2的32次方)。为了安全,操作系统将内存空间分为两部分,一部分为内核空间,一部分为用户空间。在linux中,将最高的1G字节供内核使用,成为内核空间,将较低的3G字节供各个进程使用,称为用户空间。
2 存在多次拷贝的原因
- 操作系统为了保护系统不被应用程序有意或无意地破坏,为操作系统设置了用户态和内核态两种状态。用户态想要获取系统资源(例如访问硬盘),必须通过系统调用进入到内核态,由内核态获取到系统资源,再切换回用户态返回应用程序。
- 出于"readahead cache"和异步写入等等性能优化的需要,操作系统在内核态中也增加了一个"内核缓冲区"(kernel buffer),读取数据时并不是直接把数据读取到应用程序的buffer,而先读取到kernel buffer,再由kernel buffer复制到应用程序的buffer。因此,数据在被应用程序使用之前,可能需要被多次拷贝。
3 传统的文件传输
读数据过程:
- 应用程序要读取磁盘数据,调用read()函数从而实现用户态切换内核态,这是第1次状态切换;
- DMA控制器将数据从磁盘拷贝到内核缓冲区,这是第1次DMA拷贝;
- CPU将数据从内核缓冲区复制到用户缓冲区,这是第1次CPU拷贝;
- CPU完成拷贝之后,read()函数返回实现内核态切换用户态,这是第2次状态切换;
写数据过程:
- 应用程序要向网卡写数据,调用write()函数实现用户态切换内核态,这是第1次切换;
- CPU将用户缓冲区数据拷贝到socket缓冲区,这是第1次CPU拷贝;
- DMA控制器将数据从socket缓冲区复制到网卡,这是第1次DMA拷贝;
- 完成拷贝之后,write()函数返回实现内核态切换用户态,这是第2次切换;
综上所述:
- 读过程涉及2次空间切换、1次DMA拷贝、1次CPU拷贝(CPU中断);
- 写过程涉及2次空间切换、1次DMA拷贝、1次CPU拷贝(CPU中断);
很明显,第2次和第3次的copy只是把数据复制到user buffer又原封不动的复制回来,为此带来了两次的cpu copy和两次上下文切换,是完全没有必要的。
Linux的零拷贝技术就是为了优化掉这两次不必要的拷贝。
4 实现一:sendFile
Linux内核2.1开始引入一个叫sendFile系统调用,这个系统调用可以在内核态内把数据从内核缓冲区直接复制到socket buffer内,从而可以减少上下文的切换和不必要数据的复制。
这个系统调用其实就是一个高级I/O函数, 函数签名如下:
#include<sys/sendfile.h>
ssize_t senfile(int out_fd,int in_fd,off_t* offset,size_t count);
- out_fd是写出的文件描述符,而且必须是一个socket;
- in_fd是读取内容的文件描述符,必须是一个真实的文件,不能是管道或socket;
- offset是开始读的位置;
- count是将要读取的字节数;
sendfile方式只使用一个函数就可以完成之前的 read+write 和 mmap+write 的功能,这样就少了2次状态切换,由于数据不经过用户缓冲区,因此该数据无法被修改。
读写数据过程:
- 应用程序要读取磁盘数据,调用sendfile()函数从而实现用户态切换内核态,这是第1次状态切换;
- DMA控制器将数据从磁盘拷贝到内核缓冲区,这是第1次DMA拷贝;
- CPU将内核缓冲区数据拷贝到socket缓冲区,这是第1次CPU拷贝;
- DMA控制器将数据从socket缓冲区复制到网卡,这是第2次DMA拷贝;
- 完成拷贝之后,sendfile()函数返回实现内核态切换用户态,这是第2次状态切换;
综上所述:
- 过程涉及2次空间切换、2次DMA拷贝、1次CPU拷贝(CPU中断);
可以看到,利用sendFile系统调用后,可以将4次数据拷贝减少到3次,4次上下文切换减少到2次,2次CPU中断减少到1次。相对传统I/O, 这种零拷贝技术通过减少两次上下文切换,1次cpu copy,可以将I/O性能提高50%以上(网络数据, 未亲测)。
开始的术语中说到, 所谓的零拷贝的"零"是指用户态和内核态之间的拷贝次数为0,从这个定义上来说, 现在的这个零拷贝技术已经是真正的"零"了。然而,对性能追求极致的伟大的科学家和工程师们并不满足于此。精益求精的他们对中间第2次的cpu copy依旧耿耿于怀,想尽千方百计要去掉这一次没有必要的数据拷贝和CPU中断。
5 实现二:sendFile+scatter-gather
在内核2.4以后的版本中, Linux内核对socket缓冲区描述符做了优化。通过这次优化,sendFile系统调用可以在只复制kernel buffer少量元信息的基础上,把数据直接从kernel buffer 复制到网卡buffer中去,从而避免了从"kernel buffer"拷贝到"socket buffer"的这一次拷贝。这个优化后的sendFile,我们称之为支持scatter-gather特性的sendFile。
读写数据过程:
- 应用程序要读取磁盘数据,调用sendfile()函数从而实现用户态切换内核态,这是第1次状态切换;
- DMA控制器将数据从磁盘拷贝到内核缓冲区,这是第1次DMA拷贝;
- 将内核缓冲区中数据的offset和length拷贝到socket缓冲区;
- DMA控制器根据socekt缓冲区的offset和length从内核缓冲区中直接拷贝数据到网卡缓冲区,这是第2次DMA拷贝;
- 完成拷贝之后,sendfile()函数返回实现内核态切换用户态,这是第2次状态切换;
综上所述,最后数据拷贝变成只有两次DMA COPY:
- 硬盘拷贝到内核缓冲区(DMA COPY);
- 内核缓冲区拷贝到网卡的buf(DMA COPY);
6 实现三:mmap+write
mmap(内存映射文件),是指将文件映射到进程的地址空间去, 实现硬盘上的物理地址跟进程空间的虚拟地址的一一对应关系。
mmap是另外一个用于实现零拷贝的系统调用。跟sendFile不一样的地方是,它是利用共享内存空间的方式,避免user buffer和kernel buf之间的数据拷贝(两个buffer共享同一段内存)。
执行过程:
buf = mmap(diskfd, len);
write(sockfd, buf, len);
读数据过程:
- 应用程序要读取磁盘数据,调用 mmap() 函数从而实现用户态切换内核态,这是第1次状态切换;
- DMA控制器将数据从磁盘拷贝到内核缓冲区,这是第1次DMA拷贝;
- 接着,应用进程跟操作系统内核「共享」这个缓冲区;
- 一切都完成后,mmap()函数返回实现内核态切换用户态,这是第2次状态切换;
写数据过程:
- 应用程序要向网卡写数据,调用write()函数实现用户态切换内核态,这是第1次切换;
- 操作系统直接将内核缓冲区的数据拷贝到 socket 缓冲区中,这一切都发生在内核态,由 CPU来搬运数据,这是第1次CPU拷贝;
- DMA控制器将数据从socket缓冲区复制到网卡,这是第1次DMA拷贝;
- 完成拷贝之后,write()函数返回实现内核态切换用户态,这是第2次切换;
综上所述:
- 通过使用mmap() 来代替 read(), 可以减少一次数据拷贝的过程;
- 但这还不是最理想的零拷贝,因为仍然需要通过 CPU 把内核缓冲区的数据拷贝到 socket 缓冲区里,而且仍然需要 4 次上下文切换,因为系统调用还是 2 次。
7 实现四:splice
splice系统调用是Linux 在 2.6 版本引入的,其不需要硬件支持,并且不再限定于socket上,实现两个普通文件之间的数据零拷贝。
splice 系统调用可以在内核缓冲区和socket缓冲区之间建立管道来传输数据,避免了两者之间的 CPU 拷贝操作。
splice也有一些局限,它的两个文件描述符参数中有一个必须是管道设备。
参考文章: