剖析linux下的零拷贝技术(zero-copy)

背景

  大多数的网络服务器是基于server-client模式的。在这当中,下载是一个很常见的功能。此时服务器端需要将主机磁盘上的文件发送到客户端上去。传统的 Linux 操作系统的标准 I/O 接口是基于数据拷贝操作的,即 I/O 操作会导致数据在操作系统内核地址空间的缓冲区和应用程序地址空间定义的缓冲区之间进行传输。那么传统的I/O操作过程是咋样的呢?(下面是具体说明,以read和write为例)
这里写图片描述
  在执行read操作时,操作系统首先会检查,文件内容是否缓存在内核缓冲区,如果在内核缓冲区,则不用去磁盘中读取文件,而是直接将内核缓冲区的内容拷贝到用户空间缓冲区中去。如果不是,操作系统则首先将磁盘上的数据拷贝的内核缓冲区(DMA),然后再把内核缓冲区上的内容拷贝到用户缓冲区中。接下来,write系统调用再把用户缓冲区的内容拷贝到网络堆栈相关的内核缓冲区中,最后再往对方的sockfd中些数据。并且在这个过程中还涉及到了四次的上下文切换。
  那传统的I/O操作会带来什么问题呢?
  在高速网络中,大量传统的I/O操作导致的数据拷贝工作会占用 CPU 时间片,同时也需要占用额外的内存带宽。使cpu将大多数的时间用于I/O操作上,从而无法处理其他的任务,很大程度上影响了系统的性能,使服务器成为性能瓶颈。而本身我们可以看出:很多数据复制操作并不是真正需要的。所以可以消除一些复制操作以减少开销并提高性能。这就引出了我们今天要介绍的“零拷贝”技术。

概念

  零拷贝(Zero-copy)技术是指计算机执行操作时,CPU不需要先将数据从某处内存复制到另一个特定区域。这种技术通常用于通过网络传输文件时节省CPU周期和内存带宽。零拷贝技术可以减少数据拷贝和共享总线操作的次数,消除传输数据在存储器之间不必要的中间拷贝次数,从而有效地提高数据传输效率。而且,零拷贝技术减少了用户进程地址空间和内核地址空间之间因为上下文切换而带来的开销。

零拷贝技术的分类

直接 I/O:

  对于这种数据传输方式来说,应用程序可以直接访问硬件存储,操作系统内核只是辅助数据传输:这类零拷贝技术针对的是操作系统内核并不需要对数据进行直接处理的情况,数据可以在应用程序地址空间的缓冲区和磁盘之间直接进行传输,完全不需要 Linux 操作系统内核提供的页缓存的支持。

数据传输不经过用户进程地址空间

  在数据传输的过程中,避免数据在操作系统内核地址空间的缓冲区和用户应用程序地址空间的缓冲区之间进行拷贝。有的时候,应用程序在数据进行传输的过程中不需要对数据进行访问,那么,将数据从 Linux 的页缓存拷贝到用户进程的缓冲区中就可以完全避免,传输的数据在页缓存中就可以得到处理。在某些特殊的情况下,这种零拷贝技术可以获得较好的性能。Linux 中提供类似的系统调用主要有 mmap(),sendfile() 以及 splice()。

写时复制

  对数据在 Linux 的页缓存和用户进程的缓冲区之间的传输过程进行优化。该零拷贝技术侧重于灵活地处理数据在用户进程的缓冲区和操作系统的页缓存之间的拷贝操作。这种方法延续了传统的通信方式,但是更加灵活。在  Linux  中,该方法主要利用了写时复制技术。
  
  前两类方法的目的主要是为了避免应用程序地址空间和操作系统内核地址空间这两者之间的缓冲区拷贝操作。这两类零拷贝技术通常适用在某些特殊的情况下,比如要传送的数据不需要经过操作系统内核的处理或者不需要经过应用程序的处理。第三类方法则继承了传统的应用程序地址空间和操作系统内核地址空间之间数据传输的概念,进而针对数据传输本身进行优化。

  在本文中,主要介绍针对数据传输不需要经过应用程序地址空间的零拷贝技术;即:mmap()、sendfile()、slipce(),其他两类有兴趣可自行了解。

mmap()

利用mmap()代替read()

buf = mmap(file, len);
write(sockfd, buf, len);

示意图

这里写图片描述

过程:

  ①、应用进程调用了 mmap() 之后,数据会先通过 DMA 拷贝到操作系统内核缓冲区中去。接着,应用进程跟操作系统共享这个缓冲区。这样,操作系统内核和应用进程存储空间就不需要再进行任何的数据拷贝操作。
  ②、应用进程再调用write(),操作系统直接将内核缓冲区的内容拷贝到socket缓冲区中,这一切都发生在内核态
  ③、socket缓冲区再把数据发往对方。

带来的问题:

  通过使用 mmap() 来代替 read(), 已经可以减少一次数据拷贝。可以提高效率。但是,这种改进也是需要代价的,使用 mmap() 过程中。当对文件进行了内存映射,然后调用 write() 系统调用,如果此时其他的进程截断了这个文件,那么 write() 系统调用将会被总线错误信号 SIGBUS 中断,因为此时正在执行的是一个错误的存储访问。该信号的默认行为是杀死进程和转储核心,这个结果显然不是我们想看到的。

解决方法:

  有两种解决方法
  ①,对SIGBUS捕捉处理
  对SIGBUS 信号进行简单处理并返回,这样,write() 系统调用在它被中断之前就返回已经写入的字节数目,errno 会被设置成 success。
  缺点:它不能反映出产生这个问题的根源所在,因为 BIGBUS 信号只是显示某进程发生了一些很严重的错误。
  ②,文件租借锁
  通过内核对文件加租借锁,当另外一个进程尝试对用户正在进行传输的文件进行截断的时候,内核会发送给用户一个实时信号:RT_SIGNAL_LEASE 信号,这个信号会告诉用户内核破坏了用户加在那个文件上的租借锁,那么 write() 系统调用则会被中断,并且进程会被 SIGBUS 信号杀死,返回值则是中断前写的字节数,errno 也会被设置为 success。
  注意:文件租借锁需要在对文件进行内存映射之前设置。

if(fcntl(diskfd, F_SETSIG, RT_SIGNAL_LEASE) == -1) {
perror("kernel lease set signal");
return -1;
}
/* l_type can be F_RDLCK F_WRLCK  加锁*/
/* l_type can be  F_UNLCK 解锁*/
if(fcntl(diskfd, F_SETLEASE, l_type)){
    perror("kernel lease set type");
    return -1;
}

使用 mmap 并不一定能获得理想的数据传输性能。数据传输的过程中仍然需要一次 CPU 拷贝操作,而且映射操作也是一个开销很大的虚拟存储操作,这种操作需要通过更改页表以及冲刷 TLB (使得 TLB 的内容无效)来维持存储的一致性。

sendfile()

  为了简化用户接口,同时减少 CPU 的拷贝次数,Linux 在版本 2.1 中引入了 sendfile() 这个系统调用。

ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count);

这里写图片描述

  值得一提的是:sendfile() 不仅减少了数据拷贝操作,它也减少了上下文切换。

过程

  ①,sendfile() 系统调用利用 DMA 引擎将文件中的数据拷贝到操作系统内核缓冲区中,
  ②,然后数据被拷贝到与 socket 相关的内核缓冲区中去。
  ③,接下来,DMA 引擎将数据从内核 socket 缓冲区中拷贝到协议引擎中去。
  

问题

  那么当其他的进程截断了这个文件,会发生什么呢?
  若不设置信号捕捉函数,那么 sendfile () 系统调用会简单地返回给用户应用程序中断前所传输的字节数,errno 会被设置为 success。
  如果在调用 sendfile() 之前操作系统对文件加上了租借锁,那么 sendfile() 的操作和返回状态将会和 mmap()一样。

局限性

  sendfile() 系统调用不需要将数据拷贝或者映射到应用程序地址空间中去,所以 sendfile() 只是适用于应用程序地址空间不需要对所访问数据进行处理的情况。相对于 mmap() 方法来说,因为 sendfile 传输的数据没有越过用户应用程序 / 操作系统内核的边界线,所以 sendfile () 也极大地减少了存储管理的开销。
  (1),sendfile() 局限于基于文件服务的网络应用程序,比如 web 服务器。据说,在 Linux 内核中实现 sendfile() 只是为了在其他平台上使用 sendfile() 的 Apache 程序。
  (2),由于网络传输具有异步性,很难在 sendfile () 系统调用的接收端进行配对的实现方式,所以数据传输的接收端一般没有用到这种技术。
  (3),基于性能的考虑来说,sendfile () 仍然需要有一次从文件到 socket 缓冲区的 CPU 拷贝操作,这就导致页缓存有可能会被传输的数据所污染。

进一步优化

  sendfile() 技术在进行数据传输仍然还需要一次多余的数据拷贝操作:即从文件到 socket 缓冲区的 CPU 拷贝操作。想要进一步提高性能,就得将这一步也省去,那么,应该咋样做呢?
  这时就借助于硬件上的帮助。需要用到一个支持收集操作的网络接口,这也就是说,待传输的数据可以分散在存储的不同位置上,而不需要在连续存储中存放。这样一来从文件中读出的数据就根本不需要被拷贝到 socket 缓冲区中去,而只是需要将缓冲区描述符传到网络协议栈中去,之后其在缓冲区中建立起数据包的相关结构,然后通过 DMA 收集拷贝功能将所有的数据结合成一个网络数据包。网卡的 DMA 引擎会在一次操作中从多个位置读取包头和数据。
###优化后的数据传输的过程
  (1),sendfile() 系统调用利用 DMA 引擎将文件内容拷贝到内核缓冲区去;
  (2),将带有文件位置和长度信息的缓冲区描述符添加到 socket 缓冲区中去,此过程不需要将数据从操作系统内核缓冲区拷贝到 socket 缓冲区中,
  (3),DMA 引擎会将数据直接从内核缓冲区拷贝到协议引擎中去,这样就避免了最后一次数据拷贝。
  这里写图片描述
  这种方法不但减少了因为多次上下文切换所带来开销,同时也减少了数据拷贝。

splice()

  splice() 可以被看成是类似于基于流的管道的实现,管道可以使得两个文件描述符相互连接,splice 的调用者则可以控制两个设备(或者协议栈)在操作系统内核中的相互连接。

适用场景

  splice() 可以在操作系统地址空间中整块地移动数据,从而减少大多数数据拷贝操作。splice() 适用于可以确定数据传输路径的用户应用程序,它不需要利用用户地址空间的缓冲区进行显式的数据传输操作。那么,当数据只是从一个地方传送到另一个地方,过程中所传输的数据不需要经过用户应用程序的处理的时候,spice() 就成为了一种比较好的选择。

splice() 和 sendfile() 的区别与联系

联系:用户应用进程必须拥有两个已经打开的文件描述符,一个用于表示输入设备,一个用于表示输出设备。
区别:splice() 允许任意两个文件之间互相连接sendfile()只适用于文件到 socket 进行数据传输。

函数说明

ssize_t splice(int fd_in, loff_t *off_in, int fd_out, loff_t *off_out, size_t len, unsigned int flags);

   splice()在两个文件描述符之间移动数据,而不在内核地址空间和用户地址空间之间进行复制。它将文件描述符fd_in中的len个字节的数据传输到文件描述符fd_out,其中一个描述符必须引用一个管道
  
  如果fd_in引用一个管道,那么off_in必须为NULL。如果fd_in没有引用管道并且off_in为NULL,则从当前文件偏移量开始从fd_in读取字节,并且适当地调整当前文件偏移量。如果fd_in没有引用管道并且off_in不是NULL,那么off_in必须指向一个缓冲区,该缓冲区指定从fd_in读取字节的起始偏移量; 在这种情况下,fd_in的当前文件偏移量不会改变。类似的语句适用于fd_out和off_out。

   flags参数是一个位掩码,它由零个或多个下列值组成:

SPLICE_F_NONBLOCK:   splice 操作不会被阻塞。然而,如果文件描述符没有被设置为不可被阻塞方式的 I/O 
                     ,那么调用 splice 有可能仍然被阻塞。

SPLICE_F_MORE:       告知操作系统内核下一个 splice 系统调用将会有更多的数据传来。

SPLICE_F_MOVE:       如果输出是文件,这个值则会使得操作系统内核尝试从输入管道缓冲区直接将数据读入
                      到输出地址空间,这个数据传输过程没有任何数据拷贝操作发生。如果内核不能从pipe
                      移动数据或者pipe的缓存不是一个整页面,仍然需要拷贝数据。

参考简书:https://www.jianshu.com/p/fad3339e3448

猜你喜欢

转载自blog.csdn.net/z_ryan/article/details/79604192