到现在大家都听说过Linux下所谓的零拷贝特性,但我遇到了一些不太了解这个话题的人,因此,我决定写几篇文章更深入地研究这个问题,希望对这个有用的特性有所了解;在本文中,我们将从用户模式应用程序的角度来看零拷贝,从而省略复杂的内核级细节。什么是零拷贝?为了更好地理解问题的解决方案,我们需要先理解问题本身。让我们看一下网络客户端下载存储在守护服务器上的文件的简单过程。下面是一些示例代码:read(file,tmp_buf,len);write(socket,tmp_buf,len);看起来很简单,你可能认为只有两个系统调用没有太多开销,实际上这与现实相去甚远。这两次调用的背后,数据至少被复制了四次,并且进行了几乎一样多的用户/内核上下文切换(其实过程比较复杂,这里只是想简单点),为了更好的理解涉及的流程见图1,上半部分表示上下文切换,下半部分表示数据复制操作。如图1所示,两次系统调用过程中的数据拷贝。第一步:read系统调用导致用户空间切换到内核空间。第一次数据复制由DMA引擎执行,它读取文件内容并将其存储在内核地址空间缓冲区中。第二步:将数据从内核缓冲区复制到用户缓冲区,read系统调用返回。调用返回导致上下文从内核切换回用户模式,数据现在存储在用户地址空间的缓冲区中,向下复制数据可以重新开始。第3步:编写系统调用会导致从用户模式到内核模式的上下文切换。第三次数据拷贝,就是将数据再次拷贝到内核地址空间的缓冲区中,不过这次,数据放在了不同的缓冲区中,与socket相关联。第4步:write系统调用返回,创建第四个上下文切换。第四次数据拷贝由DMA引擎独立异步地从内核缓冲区传输到协议引擎。您可能会问自己,独立和异步是什么意思?数据不是在系统调用之前传输的吗?系统调用返回,这实际上并不能保证传输,甚至不能保证传输的开始。这只是意味着以太网驱动程序在队列中有空闲描述符,并且已经接受了我们的数据传输,可能有很多数据包在我们前面排队,除非驱动程序/硬件实现优先级环或队列,否则数据传输先进先出原则(上图中的DMA复制说明了最后一个复制可以延迟的事实)可以看到,不需要大量数据复制并且可以消除一些重复复制,以减少开销并提高性能;作为驱动程序开发人员,我使用硬件的一些高级功能,可以完全绕过主内存直接将数据传输到另一个设备,这个功能消除了系统内存中的数据复制,是个好东西,但并不是所有的硬件都支持它。还有磁盘数据必须转换回网络数据的问题,这带来了一些复杂性;为了消除开销,我们可以从消除内核和用户缓冲区之间的一些数据复制开始。消除副本的一种方法是跳过读取调用并改为调用mmap,如:tmp_buf=mmap(file,len);write(socket,tmp_buf,len);为了更好地理解所涉及的过程,请参见图2,上下文切换保持不变。图2,mmap调用的第一步:mmap系统调用导致文件内容被DMA引擎复制到内核缓冲区中。然后缓冲区与用户进程共享,而不在内核和用户内存空间之间执行任何数据复制。第二步:write系统调用使内核将原来内核缓冲区中的数据复制到与套接字关联的内核缓冲区中。第3步:第三次复制发生在DMA引擎将数据从内核套接字缓冲区传递到协议引擎时。使用mmap代替read,我们将内核的数据复制减少了一半,这在传输大量数据时产生了很好的效果。不过这种改进也不是没有代价的,使用mmap+write也有一些隐藏的缺陷。当你在内存中映射一个文件时,如果恰好有一个进程使用write修改文件使其变小,那么就有可能访问映射文件之外的内存,进程会收到一个SIGBUS信号并退出,这不是网络服务器最理想的行为,有两种方法可以解决这个问题。第一种方法是为SIGBUS信号安装一个信号处理程序,然后在该处理程序中简单地调用return。通过这样做,write系统调用返回它在被中断之前写入的字节数,并将errno设置为成功。让我指出,这将是一个糟糕的解决方案,它只关注问题的表面,并没有解决问题的本质,因为SIGBUS信号表明该过程存在严重错误,所以我不鼓励使用它作为解决方案。第二种解决方案涉及从内核租用文件(在MicrosoftWindows中称为“机会锁定”)。这是解决这个问题的正确方法。通过对文件描述符使用租约,可以在特定文件上使用内核。然后,您可以向内核请求读/写租约。当另一个进程试图截断您要传输的文件时,内核会向您发送实时信号RT_SIGNAL_LEASE信号。它告诉您内核正在中断对该文件的写入或读取租约。写入调用在程序访问无效地址之前被中断,并被SIGBUS信号终止。write调用的返回值是中断前写入的字节数,errno将设置为成功。下面是一些示例代码,展示了如何从内核中获取租约:return-1;}/*l_type可以是F_RDLCKF_WRLCK*/if(fcntl(fd,F_SETLEASE,l_type)){perror("kernelleasesettype");return-1;}您应该在映射文件之前获得租约,并在完成后终止租约。这是通过使用F_UNLCK的租约类型调用f??cntlF_SETLEASE来实现的。Sendfile在内核版本2.1中,引入了sendfile系统调用以简化网络上和两个本地文件之间的数据传输。sendfile的引入,不仅减少了数据拷贝,也减少了上下文切换。像这样使用它:sendfile(socket,file,len);为了更好地了解所涉及的流程,请参见图3图3,使用Sendfile代替读写步骤1:sendfile系统调用导致文件内容被引擎DMA复制到内核缓冲区中。然后内核将数据复制到与套接字关联的内核缓冲区中。第2步:第三次复制发生在DMA引擎将数据从内核套接字缓冲区传递到协议引擎时。您可能想知道如果另一个进程截断了我们使用sendfile系统调用传输的文件会发生什么。如果我们不注册任何信号处理程序,则sendfile调用仅返回它在中断前传输的字节数,成功时将设置errno。但是,如果文件的租约是在调用sendfile之前从内核获得的,则行为和返回状态完全相同。我们还在sendfile调用返回之前获得了RT_SIGNAL_LEASE信号。到目前为止,我们已经能够避免让内核制作多个副本,但我们仍然只剩下一个副本。这也能避免吗?当然,在硬件的帮助下。为了消除内核所做的所有数据重复,我们需要一个支持收集操作的网络接口。这只是意味着等待传输的数据不需要在连续的内存中;它可以分布在不同的内存位置。在内核版本2.4中,套接字缓冲区描述符被修改以适应这些要求——在Linux下称为零拷贝。这种方法不仅减少了多次上下文切换,还消除了处理器进行的数据重复。对于用户级应用程序,没有任何改变,所以代码仍然是这样的:sendfile(socket,file,len);为了更好地理解所涉及的过程,请参见图4在每个内存位置组装数据,从而消除另一个副本步骤1:sendfile系统调用导致文件内容被DMA引擎复制到内核缓冲区中。第2步:没有数据被复制到套接字缓冲区中。相反,只有包含有关数据位置和长度信息的描述符被附加到套接字缓冲区,DMA引擎将数据直接从内核缓冲区传递到协议引擎,从而消除了剩余的最终副本。因为数据实际上仍然是从磁盘复制到内存,并从内存中写出,所以有人可能会争辩说这不是真正的零拷贝。然而,从操作系统的角度来看,这是零拷贝,因为数据不会在内核缓冲区之间复制。使用零拷贝时,除了避免复制之外,您还可以获得其他性能优势,例如更少的上下文切换、更少的CPU数据缓存污染以及无需CPU校验和计算。Linux下零拷贝的实现远未完成,可能在不久的将来发生变化。应该添加更多功能。例如,sendfile调用不支持矢量传输,服务器(例如Samba和Apache)必须使用多个设置了TCP_CORK标志的sendfile调用。TCP_CORK也与TCP_NODELAY不兼容,当我们想要向数据添加标头时使用。这是一个很好的例子,说明单个矢量化调用如何消除对多个sendfile调用的需要以及当前实施强制实施的延迟。目前sendfile中一个相当不愉快的限制是它不能在传输大于2GB的文件时使用。如此大的文件在今天并不少见,而且不得不在退出时复制所有数据是相当令人失望的。由于sendfile和mmap方法在此示例中均不可用,因此sendfile64将在未来的内核版本中派上用场。结论尽管有一些缺点,零拷贝sendfile是一个有用的特性,我希望你已经发现这篇文章提供了足够的信息,可以开始在你的程序中使用它
