引言
传统的 Linux 操作系统的标准 I/O 接口是基于数据拷贝操作的,即 I/O 操作会导致数据在操作系统内核地址空间的缓冲区和应用程序地址空间定义的缓冲区之间进行传输。这样做最大的好处是可以减少磁盘 I/O 的操作,因为如果所请求的数据已经存放在操作系统的高速缓冲存储器中,那么就不需要再进行实际的物理磁盘 I/O 操作。但是数据传输过程中的数据拷贝操作却导致了极大的 CPU 开销,限制了操作系统有效进行数据传输操作的能力。
零拷贝( zero-copy )技术可以有效地改善数据传输的性能,在内核驱动程序(比如网络堆栈或者磁盘存储驱动程序)处理 I/O 数据的时候,零拷贝技术可以在某种程度上减少甚至完全避免不必要 CPU 数据拷贝操作。
什么是零拷贝?
零拷贝就是一种避免 CPU 将数据从一块存储拷贝到另外一块存储的技术。针对操作系统中的设备驱动程序、文件系统以及网络协议堆栈而出现的各种零拷贝技术极大地提升了特定应用程序的性能,并且使得这些应用程序可以更加有效地利用系统资源。这种性能的提升就是通过在数据拷贝进行的同时,允许 CPU 执行其他的任务来实现的。
零拷贝技术可以减少数据拷贝和共享总线操作的次数,消除传输数据在存储器之间不必要的中间拷贝次数,从而有效地提高数据传输效率。而且,零拷贝技术减少了用户应用程序地址空间和操作系统内核地址空间之间因为上下文切换而带来的开销。进行大量的数据拷贝操作其实是一件简单的任务,从操作系统的角度来说,如果 CPU 一直被占用着去执行这项简单的任务,那么这将会是很浪费资源的;如果有其他比较简单的系统部件可以代劳这件事情,从而使得 CPU 解脱出来可以做别的事情,那么系统资源的利用则会更加有效。综上所述,零拷贝技术的目标可以概括如下:
避免数据拷贝
①避免操作系统内核缓冲区之间进行数据拷贝操作。
②避免操作系统内核和用户应用程序地址空间这两者之间进行数据拷贝操作。
③用户应用程序可以避开操作系统直接访问硬件存储。
④数据传输尽量让 DMA 来做。
将多种操作结合在一起
①避免不必要的系统调用和上下文切换。
②需要拷贝的数据可以先被缓存起来。
③对数据进行处理尽量让硬件来做。
引文
接下来就探讨Linux中主要的几种零拷贝技术以及零拷贝技术适用的场景。为了迅速建立起零拷贝的概念,我们拿一个常用的场景进行引入:
在写一个服务端程序时(Web Server或者文件服务器),文件下载是一个基本功能。这时候服务端的任务是:将服务端主机磁盘中的文件不做修改地从已连接的socket发出去,我们通常用下面的代码完成:
while((n = read(diskfd, buf, BUF_SIZE)) > 0)
write(sockfd, buf , n);
基本操作就是循环的从磁盘读入文件内容到缓冲区,再将缓冲区的内容发送到socket
。但是由于Linux的I/O
操作默认是缓冲I/O
。这里面主要使用的也就是read
和write
两个系统调用,我们并不知道操作系统在其中做了什么。实际上在以上I/O
操作中,发生了多次的数据拷贝。
当应用程序访问某块数据时,操作系统首先会检查,是不是最近访问过此文件,文件内容是否缓存在内核缓冲区,如果是,操作系统则直接根据read
系统调用提供的buf
地址,将内核缓冲区的内容拷贝到buf
所指定的用户空间缓冲区中去。如果不是,操作系统则首先将磁盘上的数据拷贝的内核缓冲区,这一步目前主要依靠DMA
来传输,然后再把内核缓冲区上的内容拷贝到用户缓冲区中。
接下来,write
系统调用再把用户缓冲区的内容拷贝到网络堆栈相关的内核缓冲区中,最后socket
再把内核缓冲区的内容发送到网卡上。
说了这么多,不如看图直接:
数据拷贝
从上图中可以看出,共产生了四次数据拷贝,即使使用了DMA
来处理了与硬件的通讯,CPU仍然需要处理两次数据拷贝,与此同时,在用户态与内核态也发生了多次上下文切换,无疑也加重了CPU负担。
在此过程中,我们没有对文件内容做任何修改,那么在内核空间和用户空间来回拷贝数据无疑就是一种浪费,而零拷贝主要就是为了解决这种低效性。
什么是零拷贝技术(zero-copy)?
零拷贝主要的任务就是避免CPU将数据从一块存储拷贝到另外一块存储,主要就是利用各种零拷贝技术,避免让CPU做大量的数据拷贝任务,减少不必要的拷贝,或者让别的组件来做这一类简单的数据传输任务,让CPU解脱出来专注于别的任务。这样就可以让系统资源的利用更加有效。
我们继续回到引文中的例子,我们如何减少数据拷贝的次数呢?一个很明显的着力点就是减少数据在内核空间和用户空间来回拷贝,这也引入了零拷贝的一个类型:
让数据传输不需要经过user space
使用mmap
我们减少拷贝次数的一种方法是调用mmap()来代替read调用:
buf = mmap(diskfd, len);
write(sockfd, buf, len);
应用程序调用mmap()
,磁盘上的数据会通过DMA
被拷贝到内核缓冲区,接着操作系统会把这段内核缓冲区与应用程序共享,这样就不需要把内核缓冲区的内容往用户空间拷贝。应用程序再调用write(),
操作系统直接将内核缓冲区的内容拷贝到socket
缓冲区中,这一切都发生在内核态,最后,socket
缓冲区再把数据发到网卡去。
同样的,看图更简介明了:
使用mmap替代read很明显减少了一次拷贝,当拷贝数据量很大时,无疑提升了效率。但是使用mmap
是有代价的,当你使用mmap
时,你可能会遇到一些隐藏的陷阱。例如,当你的程序map
了一个文件,但是当这个文件被另一个进程截断(truncate)时, write系统调用会因为访问非法地址而被SIGBUS
信号终止。SIGBUS
信号默认会杀死你的进程并产生一个coredump,
如果你的服务器这样被中止了,那会产生一笔损失。
通常我们使用以下解决方案避免这种问题:
- 为SIGBUS信号建立信号处理程序
当遇到SIGBUS
信号时,信号处理程序简单地返回,write
系统调用在被中断之前会返回已经写入的字节数,并且errno
会被设置成success,但是这是一种糟糕的处理办法,因为你并没有解决问题的实质核心。 - 使用文件租借锁
通常我们使用这种方法,在文件描述符上使用租借锁,我们为文件向内核申请一个租借锁,当其它进程想要截断这个文件时,内核会向我们发送一个实时的RT_SIGNAL_LEASE
信号,告诉我们内核正在破坏你加持在文件上的读写锁。这样在程序访问非法内存并且被SIGBUS
杀死之前,你的write
系统调用会被中断。write
会返回已经写入的字节数,并且置errno
为success。
我们应该在mmap
文件之前加锁,并且在操作完文件后解锁:
if(fcntl(diskfd, F_SETSIG, RT_SIGNAL_LEASE) == -1) {
perror("kernel lease set signal");
return -1;
}
/* 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;
}
使用sendfile
从2.1版内核开始,Linux引入了sendfile
来简化操作
#include<sys/sendfile.h>
ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count);
系统调用sendfile()
在输入文件的描述符in_fd
和输出文件的描述符out_fd
之间传送文件内容(字节)。描述符out_fd
必须指向一个套接字,而in_fd
指向的文件必须是可以mmap
的。这限制了sendfile
的使用,使sendfile
只能将数据从文件传递到套接字上,反之则不行。
使用sendfile
不仅减少了数据拷贝的次数,还减少了上下文切换,数据传送始终只发生在kernel space
。
sendfile系统调用过程
在我们调用sendfile
时,如果有其它进程截断了文件会发生什么呢?假设我们没有设置任何信号处理程序,sendfile
调用仅仅返回它在被中断之前已经传输的字节数,errno
会被置为success。如果我们在调用sendfile之前给文件加了锁,sendfile
的行为仍然和之前相同,我们还会收到RT_SIGNAL_LEASE的信号。
目前为止,我们已经减少了数据拷贝的次数了,但是仍然存在一次拷贝,就是页缓存到socket缓存的拷贝。那么能不能把这个拷贝也省略呢?
借助于硬件上的帮助,我们是可以办到的。之前我们是把页缓存的数据拷贝到socket缓存中,实际上,我们仅仅需要把缓冲区描述符传到socket
缓冲区,再把数据长度传过去,这样DMA
控制器直接将页缓存中的数据打包发送到网络中就可以了。
总结一下,sendfile
系统调用利用DMA
引擎将文件内容拷贝到内核缓冲区去,然后将带有文件位置和长度信息的缓冲区描述符添加socket缓冲区去,这一步不会将内核中的数据拷贝到socket缓冲区中,DMA
引擎会将内核缓冲区的数据拷贝到协议引擎中去,避免了最后一次拷贝。
带DMA的sendfile,不过这种收集拷贝功能是需要硬件以及驱动程序支持的。
使用splice
sendfile只适用于将数据从文件拷贝到套接字上,限定了它的使用范围。Linux在2.6.17
版本引入splice
系统调用,用于在两个文件描述符中移动数据:
#define _GNU_SOURCE /* See feature_test_macros(7) */
#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);
splice调用在两个文件描述符之间移动数据,而不需要数据在内核空间和用户空间来回拷贝。他从fd_in
拷贝len
长度的数据到fd_out
,但是有一方必须是管道设备,这也是目前splice
的一些局限性。flags
参数有以下几种取值:
- SPLICE_F_MOVE :尝试去移动数据而不是拷贝数据。这仅仅是对内核的一个小提示:如果内核不能从
pipe
移动数据或者pipe
的缓存不是一个整页面,仍然需要拷贝数据。Linux最初的实现有些问题,所以从2.6.21
开始这个选项不起作用,后面的Linux版本应该会实现。 - ** SPLICE_F_NONBLOCK** :
splice
操作不会被阻塞。然而,如果文件描述符没有被设置为不可被阻塞方式的 I/O ,那么调用 splice 有可能仍然被阻塞。 - ** SPLICE_F_MORE**: 后面的
splice
调用会有更多的数据。
splice调用利用了Linux提出的管道缓冲区机制, 所以至少一个描述符要为管道。
总结
以上几种零拷贝技术都是减少数据在用户空间和内核空间拷贝技术实现的,但是有些时候,数据必须在用户空间和内核空间之间拷贝。这时候,我们只能针对数据在用户空间和内核空间拷贝的时机上下功夫了。Linux通常利用写时复制(copy on write)来减少系统开销,这个技术又时常称作COW
。
由于篇幅原因,本文不详细介绍写时复制,大概描述下:如果多个程序同时访问同一块数据,那么每个程序都拥有指向这块数据的指针,在每个程序看来,自己都是独立拥有这块数据的,只有当程序需要对数据内容进行修改时,才会把数据内容拷贝到程序自己的应用空间里去,这时候,数据才成为该程序的私有数据。如果程序不需要对数据进行修改,那么永远都不需要拷贝数据到自己的应用空间里。这样就减少了数据的拷贝。
简单的理解就是程序需要改变访问的数据的时候,才将该数据拷贝到程序自己的应用空间,不需要的时候不做拷贝动作。
除此之外,还有一些零拷贝技术,比如传统的Linux I/O中加上O_DIRECT
标记可以直接I/O
,避免了自动缓存,还有尚未成熟的fbufs
技术,本文尚未覆盖所有零拷贝技术,只是介绍了一些常见的,如有兴趣,可以自行研究,一般成熟的服务端项目也会自己改造内核中有关I/O的部分,提高自己的数据传输速率。
零拷贝原理
1. io读写的方式
io读写的方式分为两种:中断、DMA
2. 中断方式
2.1 中断方式的流程
如下图:
①用户进程发起数据读取请求
②系统调度为该进程分配cpu
③cpu向io控制器(ide,scsi)发送io请求
④用户进程等待io完成,让出cpu
⑤系统调度cpu执行其他任务
⑥数据写入至io控制器的缓冲寄存器
⑦缓冲寄存器满了向cpu发出中断信号
⑧cpu读取数据至内存
2.2 中断方式的缺点
中断次数取决于缓冲寄存器的大小
3. DMA(直接内存存取)
3.1 DMA方式的流程图如下:
①用户进程发起数据读取请求
②系统调度为该进程分配cpu
③cpu向DMA发送io请求
④用户进程等待io完成,让出cpu
⑤系统调度cpu执行其他任务
⑥数据写入至io控制器的缓冲寄存器
⑦DMA不断获取缓冲寄存器中的数据(需要cpu时钟)
⑧传输至内存(需要cpu时钟)
⑨所需的全部数据获取完毕后向cpu发出中断信号
3.2 优点
DMA的io读写方式优点就是减少了cpu中断次数,不用cpu拷贝数据
4. 数据拷贝
4.1 数据拷贝的过程
下图展示了传统方式读取数据后,并通过网络发送所发生的数据拷贝的过程
①一个read系统调用后,DMA执行了一次数据拷贝,从磁盘到内核空间
②read结束后,发生第二次数据拷贝,由cpu将数据从内核空间拷贝至用户空间
③send系统调用,cpu发生第三次数据拷贝,由cpu将数据从用户空间拷贝至内核空间(socket缓冲区)
④send系统调用结束后,DMA执行第四次数据拷贝,将数据从内核拷贝至协议引擎
⑤另外,这四个过程中,每个过程都发生一次上下文切换
4.2 内存缓冲数据,主要是为了提高性能,内核可以预读部分数据,当所需数据小于内存缓冲区大小时,将极大的提高性能。
4.3 零拷贝是为了消除这个过程中冗余的拷贝
5.零拷贝-sendfile 对应到java中
FileChannel.transferTo(long position, long count, WritableByteChannel target) //将数据从文件通道传输到了给定的可写字节通道
5.1 避免了第2,3步的数据拷贝
参考下图:
①DMA从拷贝至内核缓冲区
②cpu将数据从内核缓冲区拷贝至内核空间(socket缓冲区)
③DMA将数据从内核拷贝至协议引擎
④这三个过程中共发生2次上下文切换,分别为发起读取文件和发送数据
5.2 以上过程发生了三次数据拷贝,其中有一次为cpu完成
5.3 DMA带收集功能
linux内核2.4以后,socket缓冲区做了调整,DMA带收集功能,如下图:
①DMA从拷贝至内核缓冲区
②将数据的位置和长度的信息的描述符增加至内核空间(socket缓冲区)
③DMA将数据从内核拷贝至协议引擎
6.零拷贝-mmap 对应到java中
MappedByteBuffer//文件内存映射
数据不会复制到用户空间,只在内核空间,与sendfile类似,但是应用程序可以直接操作该内存。