大家好,又见面了,我是你们的朋友全栈君。考虑将一个本地文件通过socket发送出去的问题。我们通常的做法是:打开文件fd和一个socket,然后循环地从文件fd中read数据,并将读取的数据send到socket中。这样,每次读写我们都需要两次系统调用,并且数据会被从内核拷贝到用户空间(read),再从用户空间拷贝到内核(send)。
而sendfile就将整个发送过程封装在一个系统调用中,避免了多次系统调用,避免了数据在内核空间和用户空间之间的大量拷贝。
ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count);
虽然这个系统调用接收in和out两个fd,但是有所限制,in只能是普通文件,out只能是socket(这个限制不知道后来的内核版本有没有放宽)。
一、什么是“零拷贝”
零拷贝(zero-copy)基本思想是:数据报从网络设备到用户程序空间传递的过程中,减少数据拷贝次数,减少系统调用,实现CPU的零参与,彻底消除CPU在这方面的负载。实现零拷贝用到的最主要技术是DMA数据传输技术和内存区域映射技术。
先看普通网络服务守护进程的一般服务方法:
表面上看来,系统的负荷似乎只是两个系统调用,而没什么开销。如果这么认为,那么这就大错特错了。在这两个函数的背后,数据至少已经被复制了 4 次,以及几乎一样的用户空间到内核空间的切换次数(上下文切换)。下图可以很好的说明这一个过程:
分析上图中的步骤:
第一步,系统调用 read() 导致了用户空间到内核空间的切换。这时,DMA模块将磁盘上的文件内容拷贝(CMA COPY)到内核缓冲区,这就完成了第 1 次复制。
第二步,将内核缓冲区中的内容拷贝到用户缓冲区(kernel buffer –> user buffer),这样又导致了内核空间到用户空间的上下文切换。这时,我们需要的数据已经存放在 tmp_buf 中,这是第 2 次复制。
第三步,在调用 write() 时,又导致了用户空间到内核空间的上下文切换。数据从用户空间缓冲区再次被复制到内核空间缓冲区。这时完成了第 3 次的复制。这里需要注意的是,这次所用的内核缓冲区是与 socket 相关的缓冲区,而非“第一步”中的缓冲区。
第四步,write() 系统调用返回,这时导致了第 4 次的上下文切换。这一次是 DMA 模块将内核中的数据(socket buffer)拷贝到协议引擎中。这些动作是与代码的执行是独立并且是异步发生的。反过来说,假如是非独立且同步的,那么函数返回时那么数据也同时被发送。事实上并非如此,函数返回并不能说明数据被发送出去了,甚至是 write() 的返回都无法保证传输的开始!为什么这么说呢?这是因为,调用的返回,只是表明以太网驱网卡的动程序在其传输队列中有空位,并且已经接受我们的数据用于传输。这时候,有一种情况可能是,还有许多数据还排在我们这些数据的前头,除非我们的数据拥有特权或优先级很高(如果以太网驱动程序是采用队列优先级的方法来发送数据且我们的数据的优先级确实很高),否则数据按照 FIFO 这种先进先出的方法传送。所以在上图中,红色虚线箭头正表示了我们的传输可能发生延迟,若是实线,则刚好发送而没延迟。
正如上面所分析,整个过程中存在着数据冗余。某些冗余可以消除以减少开销并提升性能。有些硬件支持完全绕开内存,可以将数据直接传递给其它设备,这样的特性避免了系统内存中的数据副本,虽然这是一种很好的选择,但并非所有的硬件都能如此。此外,来自硬盘的数据必须重新打包(地址连续)才能用于网络传输,这让情况也会变得有些复杂。
为了减少开销,我们就从消除内核缓冲区和用户缓冲区之间的复制入手。
消除的一种方法就是使用 mmap() 系统调用来替代 read() 系统调用,例如:
tmp_buf = mmap(file, len); write(socket, tmp_buf, len);
工作原理如下图:
从上图可见:
第一步,调用 mmap() 后,文件的内容通过 DMA 模块复制到内核缓冲区中,该缓冲区之后与用户进程共享,这样一来,就无需再进行内核缓冲区和用户缓冲区的切换,也就是它们之间的复制就不会再发生。
第二步,在调用 write() 后,内核缓冲区中的数据被复制到与 socket 相关联的内核缓冲区中。
第三步,DMA 模块将数据由 socket 的缓冲区复制到协议引擎,这时第 3 次复制发生。
通过调用 mmap() 而不是 read(),我们已经将内核需要执行的复制操作减半。当有大量的数据传输时,有相当好的效果。但是性能改进的同时,也潜藏着一定的代价与陷阱。比如,在对文件进行内存映射后调用 write(),而这时有另外一个进程将映射的文件截断,此时 write() 系统调用会被进程接收到的 SIGBUS 信号而中断,SIGBUS 信号往往意味着尝试进行非法地址访问。对 SIGBUS 信号的默认处理方式是杀死当前进程并生成 core dump 文件 — 这对于网络服务器来说是极不期望的!
有两种方式可以解决该问题:
第一种是为 SIGBUS 信号设置处理程序,并在处理中简单的执行 return 语句。这样,write() 系统调用将返回被信号中断前已写的字节数,同时设置 errno 变量。但是这样的做法并不值得鼓励,因为收到 SIGBUS 信号意味着发生了严重错误!
第二种是采用文件租约的方式。文件租约是指,通过对文件描述符执行租借,你可以和内核对某个文件达成租约,从内核可以获得读/写租约。当另外的一个进程试图将你正在传输的文件截断时,内核会向你发出实时信号 RT_SIGNAL_LEASE 。该信号通知你的进程,内核即将终止你在该文件上曾经获得的租约。这样,当 write() 访问非法地址时,并即被随后到来的 SIGBUS 杀诉之前,write() 系统调用会被 RT_SIGNAL_LEASE 信号中断。write() 的返回值就是被中断之前已写的字节数,全局变量 errno 设置为成功。下面是一段示例租约的代码:
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; }
在文件进行映射之前 (通过 mmap() 系统调用),应该先获得租约,并在 write() 结束之后结束租约。这通过在 fcntl() 函数中指定租约类型为 F_UNLCK 来实现。
sendfile
sendfile() 的目的是简化通过网络在两个本地之间的数据传输过程。sendfile() 系统调用的引入,不仅减少了数据的复制,还减少了上下文切换的次数。 使用方法如下:
#include <sys/sendfile.h> ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count);
它的原理如下图所示:
在上图中,
第一步,sendfile() 导致文件内容通过 DMA 模块复制到某个内核缓冲区,之后被复制到与 soket 相关联的的缓冲区中。
第二步,DMA 模块将 socket 缓冲区中的数据复制到协议亲引擎,这时进行第 3 次复制。
在调用 sendfile() 期间,如果有另外一个进程将文件截断,且进程没有为 SIGBUS 注册任何的信号处理函数时,sendfile() 调用仍会返回进程被信号中断前已发送的字节数,并将全局变量 errno 设置为成功。然而,类似的,如果在调用 sendfile() 前,从内核里获得了文件租约,那么 sendfile() 在返回前也会收到 RT_SIGNAL_LEASE。
到此为止,我们已经能够避免内核的多次复制了,然而我们还存在一个多余的副本,这里就是 socket buffer。那么,这个副本是否可以消除? 确实可以,但这需要特定硬件的支持。为了消除内核产生的冗余数据,需要网络适配器支持聚合操作特性。该特性被支持意味着要发送的数据无须存放在地址连续的内存空间中,相反可以存放在各个内存位置。在 2.4 版本的内核中,socket 缓冲区描述符发生了变动,以适合聚合(将零散分布的数据聚合起来发送)操作的要求 –这就是 Linux 中所谓的“零拷贝”。这种方式不但减少了多个上下文的切换,而且消除了数据冗余。从用户层程序的角度来看,没有发生任何改动,所有的代码还是和以前一样。这里所描述原理如下图所示:
在上图中,
第一步,sendfile() 系统调用导致 DMA 模块将文件内容复制到内核缓冲区中。
第二步,数据并未复制到 socket 缓冲区中,取而代之的是,只有记录数据位置和长度的描述符被加入到 socket 缓冲区中。DMA 模块将内核缓冲区中的数据传递给协议引擎,从而消除了遗留的最后一次复制。
由于数据实际上仍然要通过从硬盘拷贝到内存,再由内存再发送到设备,有人可能会觉得这不是真正的“零拷贝”。然而,从操作系统的角度来看,这就是“零拷贝”,因为内核空间不存在冗余数据(既不会存在将数据存放内核一块数据缓冲区中,又将数据存放在 socket 缓冲区中)。应用“零拷贝”特性,除了避免了重复复制外,还可以获得其他性能的优势,例如更少的上下文切换,更少的 CPU cache污染和没有必要的 CPU 必要校验。
发布者:全栈程序员-用户IM,转载请注明出处:https://javaforall.cn/140915.html原文链接:https://javaforall.cn
【正版授权,激活自己账号】: Jetbrains全家桶Ide使用,1年售后保障,每天仅需1毛
【官方授权 正版激活】: 官方授权 正版激活 支持Jetbrains家族下所有IDE 使用个人JB账号...