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