当前位置: 首页 > 科技观察

算得上是一篇彻底讲解Linux零拷贝的文章了!_0

时间:2023-03-18 14:38:12 科技观察

本文讨论了Linux中主要的零拷贝技术以及零拷贝技术的适用场景。图片来自Pexels为了快速建立零拷贝的概念,我们介绍一个常用的场景。在编写服务器程序(WebServer或文件服务器)时,文件下载是一个基本功能。此时服务器的任务是:将服务器主机磁盘中的文件从连接的Socket中原封不动地发送出去。我们通常使用如下代码来完成:while((n=read(diskfd,buf,BUF_SIZE))>0)write(sockfd,buf,n);基本操作是循环从磁盘读取文件内容到缓冲区,然后将缓冲区内容发送到Socket。但是因为linux的I/O操作默认是bufferedI/O。这里主要用到Read和Write这两个系统调用,不知道操作系统在里面做了什么。实际上,在上述I/O操作中,发生了多次数据拷贝。当应用程序访问一段数据时,操作系统首先检查该文件最近是否被访问过,文件内容是否缓存在内核缓冲区中。如果是,操作系统根据Read系统调用提供的buf地址,直接将内核缓冲区的内容复制到buf指定的用户空间缓冲区。如果没有,操作系统首先将磁盘上的数据复制到内核缓冲区中。这一步目前主要是依靠DMA进行传输,然后将内核缓冲区的内容复制到用户缓冲区中。接下来Write系统调用将用户缓冲区的内容复制到网络栈相关的内核缓冲区,最后Socket将内核缓冲区的内容发送给网卡。说了这么多还是看图清楚:数据副本从上图可以看出,一共生成了四个数据副本。即使使用DMA来处理与硬件的通信,CPU仍然需要处理两个数据副本。同时,用户态和内核态之间存在多次上下文切换,这无疑增加了CPU的负担。在这个过程中,我们并没有对文件内容做任何修改,所以在内核空间和用户空间之间来回拷贝数据无疑是一种浪费,而零拷贝主要就是为了解决这种低效率的问题。什么是零拷贝技术?零拷贝的主要任务是防止CPU将数据从一个存储复制到另一个。主要是利用各种零拷贝技术,避免让CPU做大量的数据拷贝任务,减少不必要的拷贝,或者让其他组件来做这类简单的数据传输任务,让CPU腾出时间专注于其他任务。这允许更有效地使用系统资源。我们回到上面的例子,我们如何减少数据副本的数量?一个明显的重点是减少内核空间和用户空间之间的数据来回拷贝,这也引入了一种零拷贝:使数据传输无需经过用户空间。我们可以使用mmap减少副本数量的一种方法是调用mmap()而不是读取调用:buf=mmap(diskfd,len);写(sockfd,buf,len);应用程序调用mmap(),磁盘上的数据会被DMA复制到内核缓冲区,然后操作系统会将这个内核缓冲区共享给应用程序,这样就不需要将内核缓冲区的内容复制到用户空间。应用程序再次调用write(),操作系统直接将内核缓冲区的内容复制到Socket缓冲区,这一切都发生在内核态,最后,Socket缓冲区将数据发送到网卡。同样的,看图也很简单:mmap用的是mmap而不是Read,明显减少了一份。当复制的数据量很大时,无疑会提高效率。但是使用mmap是有代价的。当你使用mmap时,你可能会遇到一些隐藏的陷阱。例如,当你的程序映射了一个文件,但是当该文件被另一个进程截断(truncate)时,Write系统调用将被SIGBUS信号终止以访问非法地址。SIGBUS信号默认会杀死你的进程并生成一个coredump,如果你的服务器这样终止,将会造成损失。通常我们采用以下解决方案来避免这个问题:①为SIGBUS信号建立一个信号处理程序。当遇到SIGBUS信号时,信号处理程序简单地返回,Write系统调用返回中断前已写入的字节数。并且errno会被设置为success,但这是一种糟糕的做事方式,因为你没有解决问题的真正核心。②使用文件租约锁通常我们使用这种方法是对文件描述符使用租约锁,我们向内核申请文件的租约锁。当其他进程要截断这个文件时,内核会给我们发送一个实时的RTSIGNALLEASE信号,告诉我们内核正在破坏你对该文件持有的读写锁。这样你的Write系统调用将在程序访问非法内存并被SIGBUS杀死之前被中断。Write返回写入的字节数并将errno设置为成功。我们应该在mmap之前锁定文件,在操作之后解锁:if(fcntl(diskfd,F_SETSIG,RT_SIGNAL_LEASE)==-1){perror("kernelleasesetsignal");return-1;}/*l_typecanbeF_RDLCKF_WRLCKlock*//*l_typecanbeF_UNLCKunlock*/if(fcntl(diskfd,F_SETLEASE,l_type)){perror("kernelleasesettype");return-1;}使用sendfile从2.1版内核开始,Linux引入sendfile来简化操作:#includessize_tsendfile(intout_fd,intin_fd,off_t*offset,size_tcount);系统调用sendfile()在表示输入文件的描述符infd和表示输出文件的描述符outfd之间传输文件内容(字节)。描述符outfd必须指向套接字,而infd指向的文件必须是可映射的。这些限制限制了sendfile的使用,因此sendfile只能将数据从一个文件传输到一个套接字,而不能反之。使用sendfile不仅减少了数据拷贝的次数,也减少了上下文切换,数据传输始终只发生在内核空间。sendfile系统调用进程如果其他进程在我们调用sendfile时截断了文件,会发生什么情况?假设我们没有设置任何信号处理程序,sendfile调用只返回它在被中断之前传输的字节数,并且errno将被设置为成功。如果我们在调用sendfile之前锁定文件,sendfile的行为仍然和以前一样,我们仍然会收到RTSIGNALLEASE信号。至此,我们减少了数据副本的数量,但是还有一份,就是从pagecache到socketcache的副本。那么这个副本也可以省略吗?在硬件的帮助下,我们可以做到。之前,我们将页面缓存数据复制到Socket缓存中。其实我们只需要将缓冲区描述符传递给Socket缓冲区,然后传递数据长度,这样DMA控制器就可以直接将pagecache中的数据打包发送到网络上了。总结一下:sendfile系统调用使用DMA引擎将文件内容复制到内核缓冲区,然后将带有文件位置和长度信息的缓冲区描述符添加到Socket缓冲区。这一步不会将内核中的数据复制到Socket缓冲区中,DMA引擎会将内核缓冲区中的数据复制到协议引擎中,避免了最后一次复制。SendfilewithDMA,但是这种采集和复制功能需要硬件和驱动支持。使用splicesendfile只适用于将数据从文件复制到socket,限制了它的使用范围。Linux在2.6.17版本中引入了splice系统调用,用于在两个文件描述符之间移动数据:#define_GNU_SOURCE/*Seefeature_test_macros(7)*/#includessize_tsplice(intfd_in,loff_t*off_in,intfd_out,loff_t*off_out,size_tlen,unsignedintflags);拼接调用在两个文件描述符之间移动数据,而无需在内核空间和用户空间之间来回复制数据。它将len长度的数据从fdin复制到fdout,但是其中一侧必须是管道设备,这也是splice的一些限制。flags参数具有以下值:SPLICEFMOVE:尝试移动数据而不是复制数据。这只是对内核的一个小提示:如果内核不能从管道中移动数据或者管道的缓存不是整页,数据仍然需要被复制。Linux最初的实现有一些问题,所以这个选项从2.6.21开始不起作用,它应该在以后的Linux版本中实现。SPLICEFNONBLOCK:拼接操作不会被阻塞。但是,如果未以非阻塞方式为I/O设置文件描述符,则对splice的调用可能仍会阻塞。SPLICEFMORE:后续的拼接调用将有更多的数据。splice调用使用了linux提出的pipebuffer机制,所以至少有一个描述符必须是pipe。上述零拷贝技术是通过减少用户空间和内核空间的数据拷贝来实现的,但有时,数据必须在用户空间和内核空间之间进行拷贝。这个时候我们只能在用户空间和内核空间的数据拷贝的时机上下功夫了。Linux通常使用写时复制(copyonwrite)来减少系统开销,这种技术通常被称为COW。由于篇幅原因,本文不详细介绍copy-on-write。大致的描述是:如果多个程序同时访问同一条数据,则每个程序都有一个指向这条数据的指针。从每个程序的角度来看,它独立拥有这条数据。只有当程序需要修改数据内容时,才会将数据内容复制到程序自身的应用空间中。这时候数据就变成了程序的私有数据。如果程序不需要修改数据,就永远不需要将数据拷贝到自己的应用空间,减少了数据的拷贝。此外,还有一些零拷贝技术,比如在传统的LinuxI/O上??加上O_DIRECT标志,用于直接I/O,避免自动缓存,还有不成熟的fbufs技术。本文并未涵盖所有零拷贝技术,只是介绍一些常用的,有兴趣的可以自行研究。一般成熟的服务器项目也会对内核的I/O相关部分进行修改,以提高其数据传输速率。作者:卡巴拉之树编辑:陶家龙来源:https://www.jianshu.com/p/fad3339e3448