许吉友 - 运维

sendfile 系统调用详解

首先我们来看看传统的 read/write 方式进行 socket 的传输。

当需要对一个文件进行传输的时候,具体流程细节如下:

  1. 调用 read 函数,导致了从用户空间到内核空间的上下文切换,DMA 模块从磁盘中读取文件数据 copy 到内核缓冲区
  2. 数据从内核空间缓冲区 copy 到用户空间缓冲区,read 函数返回,这导致了从内核空间向用户空间的上下文切换
  3. write 函数调用,导致从用户空间到内核空间的上下文切换,将文件数据从用户缓冲区 copy 到内核与 socket 相关的缓冲区
  4. DMA模块将数据从内核空间缓冲区传递至协议引擎,write 函数返回,导致了第4次上下文切换。

在这个过程中发生了四次copy操作:硬盘 --(DMA)--> 内核 ----> 用户 ----> socket缓冲区(内核)--(DMA)-->协议引擎。其中发生了 4 次上下文切换。

硬件优化

上面的过程中存在很多的数据冗余。某些冗余可以被消除,以减少开销、提高性能。作为一名驱动程序开发人员,我的工作围绕着拥有先进特性的硬件展开。某些硬件支持完全绕开内存,将数据直接传送给其他设备的特性。这一特性消除了系统内存中的数据副本,因此是一种很好的选择,但并不是所有的硬件都支持。此外,来自于硬盘的数据必须重新打包(地址连续)才能用于网络传输,这也引入了某些复杂性。为了减少开销,我们可以从消除内核缓冲区与用户缓冲区之间的复制入手。

mmap 系统调用

mmap 是用来创建共享内存的,如果使用 mmap 调用代替上面的 read 系统调用,上面的步骤就变成了:

  1. mmap系统调用导致文件的内容通过DMA模块被复制到内核缓冲区中,该缓冲区之后与用户进程共享,这样就内核缓冲区与用户缓冲区之间的复制就不会发生。
  2. write系统调用导致内核将数据从内核缓冲区复制到与socket相关联的内核缓冲区中。
  3. DMA模块将数据由socket的缓冲区传递给协议引擎时,第3次复制发生。

这种虽然减少了复制,但并没有减少上下文切换。

通过调用mmap而不是read,我们已经将内核需要执行的复制操作减半。当有大量数据要进行传输是,这将有相当良好的效果。然而,性能的改进需要付出代价的;是用mmap与write这种组合方法,存在着一些隐藏的陷阱。例如,考虑一下在内存中对文件进行映射后调用write,与此同时另外一个进程将同一文件截断的情形。此时write系统调用会被进程接收到的SIGBUS信号中断,因为当前进程访问了非法内存地址。对SIGBUS信号的默认处理是杀死当前进程并生成dump core文件——而这对于网络服务器程序而言不是最期望的操作。

有两种方式解决这个问题:

  1. 第一种方式是为SIGBUS信号设置信号处理程序,并在处理程序中简单的执行return语句。在这样处理方式下,write系统调用返回被信号中断前已写的字节数,并将errno全局变量设置为成功。必须指出,这并不是个好的解决方式——治标不治本。由于收到SIGBUS信号意味着进程发生了严重错误,我不鼓励采取这种解决方式。
  2. 第二种方式应用了文件租借(在Microsoft Windows系统中被称为“机会锁”)。这才是解决前面问题的正确方式。通过对文件描述符执行租借,可以同内核就某个特定文件达成租约。从内核可以获得读/写租约。当另外一个进程试图将你正在传输的文件截断时,内核会向你的进程发送实时信号——RT_SIGNAL_LEASE。该信号通知你的进程,内核即将终止在该文件上你曾获得的租约。这样,在write调用访问非法内存地址、并被随后接收到的SIGBUS信号杀死之前,write系统调用就被RT_SIGNAL_LEASE信号中断了。write的返回值是在被中断前已写的字节数,全局变量errno设置为成功。

Sendfile 系统调用

sendfile系统调用在内核版本2.1中被引入,目的是简化通过网络在两个本地文件之间进行的数据传输过程。sendfile系统调用的引入,不仅减少了数据复制,还减少了上下文切换的次数。

Sendfile传递数据的步骤:

  1. sendfile系统调用导致文件内容通过DMA模块被复制到某个内核缓冲区,之后再被复制到与socket相关联的缓冲区内。
  2. 当DMA模块将位于socket相关联缓冲区中的数据传递给协议引擎时,执行第3次复制。

我们在调用sendfile发送数据的期间,如果另外一个进程将文件截断的话,会发生什么事情?如果进程没有为SIGBUS注册任何信号处理函数的话,sendfile系统调用返回被信号中断前已发送的字节数,并将全局变量errno置为成功。

然而,如果在调用sendfile之前,从内核获得了文件租约,那么类似的,在sendfile调用返回前会收到RT_SIGNAL_LEASE。

到此为止,我们已经能够避免内核进行多次复制,然而我们还存在一分多余的副本。这份副本也可以消除吗?当然,在硬件提供的一些帮助下是可以的。为了消除内核产生的素有数据冗余,需要网络适配器支持聚合操作特性。该特性意味着待发送的数据不要求存放在地址连续的内存空间中;相反,可以是分散在各个内存位置。在内核版本2.4中,socket缓冲区描述符结构发生了改动,以适应聚合操作的要求——这就是Linux中所谓的"零拷贝“。这种方式不仅减少了多个上下文切换,而且消除了数据冗余。从用户层应用程序的角度来开,没有发生任何改动,所有代码仍然是类似下面的形式:

sendfile(socket, file, len);

步骤一:sendfile系统调用导致文件内容通过DMA模块被复制到内核缓冲区中。

步骤二:数据并未被复制到socket关联的缓冲区内。取而代之的是,只有记录数据位置和长度的描述符被加入到socket缓冲区中。DMA模块将数据直接从内核缓冲区传递给协议引擎,从而消除了遗留的最后一次复制。

由于数据实际上仍然由磁盘复制到内存,再由内存复制到发送设备,有人可能会声称这并不是真正的"零拷贝"。然而,从操作系统的角度来看,这就是"零拷贝",因为内核空间内不存在冗余数据。应用"零拷贝"特性,出了避免复制之外,还能获得其他性能优势,例如更少的上下文切换,更少的CPU cache污染以及没有CPU必要计算校验和。