本文主要参考https://www.linuxjournal.com/article/6345
你可能觉得,把数据从内核空间拷贝到用户空间似乎有些多余。为什么不直接让磁盘控制器把数据送到用户空间的缓冲呢?这样做有几个问题。
1、硬盘通常不能直接访问用户空间。
2、像磁盘这样基于块存储的硬件设备操作的是固定大小的数据块,而用户进程请求的可能是任意大小的或非对齐的数据块。在数据往来于用户空间与存储设备的过程中,内核负责数据的分解、再组合工作,因此充当着中间人的角色。
所有现代操作系统都使用虚拟内存。虚拟内存意为使用虚假(或虚拟)地址取代物理(硬件RAM)内存地址。这样做好处颇多,总结起来可分为两大类:
1、一个以上的虚拟地址柯指向同一个物理内存地址
2、虚拟内存空间可大于实际可用的硬件内存
这样真是太好了,省去了内核与用户空间的往来拷贝,但前提条件是,内核与用户缓冲区必须使用相同的页对齐,缓冲区的大小还必须是磁盘控制器块大小(通常为 512 字节磁盘扇区)的倍数。操作系统把内存地址空间划分为页,即固定大小的字节组。内存页的大小总是磁盘块大小的倍数,通常为 2 次幂(这样可简化寻址操作)
为了更好地理解问题的解决方案,我们首先需要了解问题本身。让我们看一下网络服务器后台程序的简单过程涉及将文件中存储的数据通过网络提供给客户端的过程。(我们这里以两个文件为例)
#include
#include
#include
#include
int main(){
int in,out;
in = open("./jenkin.txt",O_RDWR);
if(in==-1){
perror("open in file");
return 1;
}
out = open("./jenkin1.txt",O_RDWR|O_CREAT|O_TRUNC,0664);
if(out==-1){
perror("open out file");
close(in);
return 2;
}
char buf[1024];
ssize_t size;
while(size=read(in,buf,1024)){
write(out,buf,size);
}
close(in);
close(out);
return 0;
}
看起来很简单,你会认为只有两个系统调用没有太多的开销。事实上,这不能离真相更远。在这两个调用的背后,数据已被复制至少四次,并且执行了几乎同样多的用户/内核上下文切换。(其实这个过程要复杂得多,但我想保持简单)。为了更好地了解所涉及的过程,请看下图顶端显示上下文切换,底端显示复制操作。
第一步:读系统调用导致从用户模式到内核模式的上下文切换。第一个副本由DMA引擎执行,该引擎从磁盘读取文件内容并将其存储到内核地址空间缓冲区中。
第二步:将数据从内核缓冲区复制到用户缓冲区,然后读取系统调用返回。调用返回导致从内核切换到用户模式的上下文切换。现在数据存储在用户地址空间缓冲区中,并且可以再次开始。
第三步:写系统调用导致从用户模式到内核模式的上下文切换。执行第三个副本,将数据再次放入内核地址空间缓冲区。但是,这一次,数据被放入一个不同的缓冲区,一个与套接字相关的缓冲区。
第四步:写系统调用返回,创建我们的第四个上下文切换。独立和异步地,DMA引擎将数据从内核缓冲区传递到协议引擎时发生第四次复制。
#include
#include
#include
#include
#include
#include
#include
#define RT_SIGNAL_LEASE (SIGRTMIN+1)
#define F_SETSIG 10
#define F_SETLEASE 1024
int main(){
int in,out;
in = open("./jenkin.txt",O_RDWR);
if(in==-1){
perror("open in file");
return 1;
}
out = open("./jenkin1.txt",O_RDWR|O_TRUNC|O_CREAT,0664);
if(out==-1){
close(in);
perror("open in file");
return 2;
}
struct stat *buf;
fstat(in,buf);
void *p = mmap(NULL,buf->st_size,PROT_READ|PROT_WRITE,MAP_SHARED,in,0);
if(p==MAP_FAILED){
perror("map_failed");
return 3;
}
if(fcntl(in, F_SETSIG, RT_SIGNAL_LEASE) == -1){
perror("kernel lease set signal");
return 4;
}
if(fcntl(in,F_SETLEASE,F_WRLCK)){
perror("kernel lease set type");
return 5;
}
write(out,p,buf->st_size);
close(in);
close(out);
return 0;
}
第一步:mmap系统调用将文件内容复制到DMA引擎的内核缓冲区中。然后在用户进程中共享缓冲区,而不需要在内核和用户内存空间之间执行任何复制。
第二步:write系统调用导致内核将数据从原始内核缓冲区复制到与套接字关联的内核缓冲区中。
第三步:当DMA引擎将数据从内核套接字缓冲区传递到协议引擎时,第三次复制发生。
通过使用mmap而不是读取,我们将内核必须复制的数据量减少了一半。当大量数据被传输时,这将产生相当好的结果。然而,这种改进并不是没有代价的;使用mmap+write方法时存在一些隐藏的缺陷。当您的内存映射一个文件,然后调用write,而另一个进程截断相同的文件时,您将陷入其中之一。 您的write系统调用将被总线错误信号SIGBUS中断,因为您执行了一个糟糕的内存访问。该信号的默认行为是杀死进程并转储内核——而不是网络服务器最理想的操作。有两种方法可以解决这个问题。
第一种方法是为SIGBUS信号安装一个信号处理程序,然后在处理程序中简单地调用return。通过这样做,write系统调用将返回它在被中断之前所写的字节数,以及errno设置为成功。我必须指出,这将是一个糟糕的解决方案,一个治疗症状,而不是病根的解决方案。因为SIGBUS信号表明这个过程出现了严重的问题,所以我不建议使用这个作为解决方案。
第二个解决方案涉及文件租赁(在Microsoft Windows中称为“机会锁定”)。这是解决这个问题的正确方法。通过使用文件描述符上的租赁,你将在内核上租赁获取一个特定的文件。通过在文件描述符上使用租赁,可以在特定文件上使用内核进行租约。然后可以从内核请求读/写租约。 当另一个进程试图截断正在传输的文件时,内核会向您发送一个实时信号,即RT_SIGNAL_LEASE信号。它告诉您内核正在破坏您在该文件上的写或读租约。在程序访问一个无效的地址并被SIGBUS信号杀死之前,您的write调用会被中断。write调用的返回值是在中断之前写入的字节数,而errno将被设置为成功。
在内核版本2.1中,引入了sendfile系统调用,以简化网络和两个本地文件之间的数据传输。sendfile的引入不仅减少了数据复制,还减少了上下文切换。使用它是这样的:
#include
#include
#include
#include
#include
#include
int main(){
int in,out;
in = open("./jenkin.txt",O_RDWR);
if(in==-1){
perror("open in");
return 1;
}
struct stat *buf;
fstat(in,buf);
out = open("./jenkin1.txt",O_RDWR|O_CREAT,0664);
lseek(out,-1,SEEK_END);
if(out==-1){
perror("open out");
close(in);
return 2;
}
ssize_t r = sendfile(out,in,NULL,buf->st_size);
if(r==-1){
perror("sendfile");
close(in);
close(out);
return 3;
}
printf("send file success,source file size:%d\n",buf->st_size);
close(in);
close(out);
return 0;
}
第一步:sendfile系统调用将把文件内容复制到DMA引擎的内核缓冲区中。然后将数据复制到与套接字相关联的内核缓冲区中。
步骤二:当DMA引擎将数据从内核套接字缓冲区传递到协议引擎时,第三次复制发生。
您可能想知道,如果另一个进程截断了我们用sendfile系统调用发送的文件,会发生什么。如果我们不注册任何信号处理程序,sendfile调用只需返回它在被中断之前传输的字节数,而errno将被设置为成功。
如果我们在调用sendfile之前从文件的内核获得一个租约,但是,行为和返回状态完全相同。在sendfile调用返回之前,我们还获得了RT_SIGNAL_LEASE信号。
到目前为止,我们已经能够避免内核生成几个复制,但是我们仍然只剩下一个复制。这个可以避免吗? 当然,在硬件的帮助下。为了消除内核所做的所有数据复制,我们需要一个支持收集操作的网络接口。 这仅仅意味着等待传输的数据不需要在连续的内存中它可以分散在不同的内存位置。在内核版本2.4中,修改了套接字缓冲区描述符以适应这些需求——在Linux下称为零拷贝。这种方法不仅减少了多个上下文切换,还消除了处理器的数据复制。对于用户级应用程序,没有任何更改,因此代码仍然是这样:
sendfile(socket, file, len);
为了更好地了解过程,请查看图4。
第一步:sendfile系统调用将把文件内容复制到DMA引擎的内核缓冲区中。
第二步:没有将数据复制到套接字缓冲区中。相反,只有带有关于数据的位置和长度的信息的描述符被追加到套接字缓冲区。DMA引擎直接将数据从内核缓冲区传递到协议引擎,从而消除剩余的最终复制。
因为数据实际上仍然是从磁盘复制到内存和从存储器到导线,有些人可能会认为这不是一个真正的零拷贝。但是,这是从操作系统的角度来看是零拷贝,因为数据不是在内核缓冲区之间复制的。当使用零拷贝时,除了复制避免之外,还可以使用其他性能优势,例如更少的上下文切换、更少的CPU数据缓存污染和没有CPU校验和计算。