本文探讨Linux中主要的几种零拷贝技术以及零拷贝技术适用的场景。为了迅速创建起零拷贝的概念,咱们拿一个经常使用的场景进行引入:缓存
在写一个服务端程序时(Web Server或者文件服务器),文件下载是一个基本功能。这时候服务端的任务是:将服务端主机磁盘中的文件不作修改地从已链接的socket发出去,咱们一般用下面的代码完成:bash
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负担。
在此过程当中,咱们没有对文件内容作任何修改,那么在内核空间和用户空间来回拷贝数据无疑就是一种浪费,而零拷贝主要就是为了解决这种低效性。socket
零拷贝主要的任务就是避免CPU将数据从一块存储拷贝到另一块存储,主要就是利用各类零拷贝技术,避免让CPU作大量的数据拷贝任务,减小没必要要的拷贝,或者让别的组件来作这一类简单的数据传输任务,让CPU解脱出来专一于别的任务。这样就可让系统资源的利用更加有效。ui
咱们继续回到引文中的例子,咱们如何减小数据拷贝的次数呢?一个很明显的着力点就是减小数据在内核空间和用户空间来回拷贝,这也引入了零拷贝的一个类型:spa
咱们减小拷贝次数的一种方法是调用mmap()来代替read调用:操作系统
buf = mmap(diskfd, len);
write(sockfd, buf, len);
复制代码
应用程序调用mmap()
,磁盘上的数据会经过DMA
被拷贝的内核缓冲区,接着操做系统会把这段内核缓冲区与应用程序共享,这样就不须要把内核缓冲区的内容往用户空间拷贝。应用程序再调用write()
,操做系统直接将内核缓冲区的内容拷贝到socket
缓冲区中,这一切都发生在内核态,最后,socket
缓冲区再把数据发到网卡去。 一样的,看图很简单:3d
mmap
是有代价的。当你使用
mmap
时,你可能会遇到一些隐藏的陷阱。例如,当你的程序
map
了一个文件,可是当这个文件被另外一个进程截断(truncate)时, write系统调用会由于访问非法地址而被
SIGBUS
信号终止。
SIGBUS
信号默认会杀死你的进程并产生一个
coredump
,若是你的服务器这样被停止了,那会产生一笔损失。
一般咱们使用如下解决方案避免这种问题:指针
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;
}
复制代码
从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
调用仅仅返回它在被中断以前已经传输的字节数,errno
会被置为success。若是咱们在调用sendfile以前给文件加了锁,sendfile
的行为仍然和以前相同,咱们还会收到RT_SIGNAL_LEASE的信号。
目前为止,咱们已经减小了数据拷贝的次数了,可是仍然存在一次拷贝,就是页缓存到socket缓存的拷贝。那么能不能把这个拷贝也省略呢?
借助于硬件上的帮助,咱们是能够办到的。以前咱们是把页缓存的数据拷贝到socket缓存中,实际上,咱们仅仅须要把缓冲区描述符传到socket
缓冲区,再把数据长度传过去,这样DMA
控制器直接将页缓存中的数据打包发送到网络中就能够了。
总结一下,sendfile
系统调用利用DMA
引擎将文件内容拷贝到内核缓冲区去,而后将带有文件位置和长度信息的缓冲区描述符添加socket缓冲区去,这一步不会将内核中的数据拷贝到socket缓冲区中,DMA
引擎会将内核缓冲区的数据拷贝到协议引擎中去,避免了最后一次拷贝。
不过这一种收集拷贝功能是须要硬件以及驱动程序支持的。
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
参数有如下几种取值:
pipe
移动数据或者pipe
的缓存不是一个整页面,仍然须要拷贝数据。Linux最初的实现有些问题,因此从2.6.21
开始这个选项不起做用,后面的Linux版本应该会实现。splice
操做不会被阻塞。然而,若是文件描述符没有被设置为不可被阻塞方式的 I/O ,那么调用 splice 有可能仍然被阻塞。splice
调用会有更多的数据。splice调用利用了Linux提出的管道缓冲区机制, 因此至少一个描述符要为管道。
以上几种零拷贝技术都是减小数据在用户空间和内核空间拷贝技术实现的,可是有些时候,数据必须在用户空间和内核空间之间拷贝。这时候,咱们只能针对数据在用户空间和内核空间拷贝的时机上下功夫了。Linux一般利用**写时复制(copy on write)**来减小系统开销,这个技术又时常称做COW
。
因为篇幅缘由,本文不详细介绍写时复制。大概描述下就是:若是多个程序同时访问同一块数据,那么每一个程序都拥有指向这块数据的指针,在每一个程序看来,本身都是独立拥有这块数据的,只有当程序须要对数据内容进行修改时,才会把数据内容拷贝到程序本身的应用空间里去,这时候,数据才成为该程序的私有数据。若是程序不须要对数据进行修改,那么永远都不须要拷贝数据到本身的应用空间里。这样就减小了数据的拷贝。写时复制的内容能够再写一篇文章了。。。
除此以外,还有一些零拷贝技术,好比传统的Linux I/O中加上O_DIRECT
标记能够直接I/O
,避免了自动缓存,还有还没有成熟的fbufs
技术,本文还没有覆盖全部零拷贝技术,只是介绍常见的一些,若有兴趣,能够自行研究,通常成熟的服务端项目也会本身改造内核中有关I/O的部分,提升本身的数据传输速率。