零拷贝I:用户模式角度

零拷贝I:用户模式角度

       到目前为止,几乎所有人都听过linux下零拷贝功能,但我经常遇到对这个主题没有了解的人。基于此,我决定写几篇文章对这个问题进行深入的探讨,希望阐明这个有用的特点。在这个文章,我们从应用程序模式来看零拷贝,因而故意忽略残忍内核细节。

       什么是零拷贝?

       为了更好理解问题的方法,我们首先需要理解这个问题。让我们看一下网络服务器通过网络将文件中存储的数据提供给客户端的简单过程所涉及的内容。这是一个简单的例子。

       看起来足够简单,你可能想到仅这个两个系统调用不会有太多额开销。实际上,这离事实很远。通过这个两个调用,数据至少被拷贝四次,并且用户空间和内核空间的切换至少会被执行相同的次数。(事实上,这个过程是非常复杂的,但我仅可能保持简单)。为了对涉及的过程更改的理解,可以看图1,上面的线是说明内容的切换,下面的线是说明复制操作。

  

                                     (图片1)两个简单调用实现copy

   第一步:这个读系统调用导致了从用户空间到内核空间的切换。第一次拷贝是有DMA模块完成的,它从磁盘读取文件内容并且存在内核地址空间缓存。

   第二步:数据从内核缓冲区拷贝到用户缓存区,然后这个read系统调用返回。这个调用的返回导致上下文从内核返回到用户模式。现在,这些数据存储在用户地址空间缓存,并且可以重新开始。

  第三步:写系统调用导致了上下文从用户模式切换到内核模式。第三次拷贝把数据再一次放到内核地址空间缓存。但是这次,数据是放进一不同的缓存(该缓存专门与套接字关联)

  第四步:写系统调用返回,创建我们第四次上下文切换。独立并且异步,第四次拷贝发生在DMA引擎传递数据从内核缓存到协议引擎。你可能会问你自己,“什么是独立和异步?,调用返回之前数据是否已经完成发送?”事实上,调用返回,不能确保发送,甚至不能确保传输开始。它只是简单的说明网络驱动在它队列中有空闲的描述符并且已经接受我们的数据传输。在我们之前可能已经有几个包排队。除非这个驱动/硬件实现优先级环或者队列,否则数据就按照先进先出方式(在图1中DMA拷贝分支说明了复制可能被延迟的事实)

   正如你所见,大量的数据复制是不必须的。一些复制可以消除用来减少开销增加性能。作为一个驱动开发者,我用一些具有高级功能的硬件。某些硬件能够完全绕开内存,并直接传输数据到另一个设备。这些特性消除在内存中的副本,很不错,但是不是所有的硬件都支持它。还有一些问题是磁盘上的数据必须为网络重新打包,这就带来一些复杂性。为了减少开销,我们可以消除某些在内核和用户缓存之间的拷贝。

   消除拷贝的一种办法是用mmap代替read。举例如下:

 

    为了更好的了解涉及的过程,看图2,上下文切换保持不变。

     

                                     图2:调用mmap

    第一步:mmap系统调用使DMA引擎把文件内容拷贝到内核缓冲区。这个缓冲区是和用户进程共享的,不需再内核和用户内存空间产生任何的数据复制。

   第二步:write系统调用使内核复制数据从原始内核缓存区到与套接字相连的内核缓存区。

   第三步:第三次拷贝发生在DMA引擎传送数据从与套接字相连的内核缓存区到协议引擎。

   通过使用mmap代替read,我们把内核拷贝数据量减少一半。当大量的数据被传输时,这会产生好的结果。然后,这种性能提升不是没有代价的,使用mmap+write时有一个隐藏的缺陷。你将会触发这些缺陷,在你用文件映射后调用write时如果在另一个进程中截断文件。总线错误信号SIGBUS将中断你的写调用,因为你访问一个错误的内存路径。这个信号一般会杀掉进程并转储核心,不是网络服务最理想的操作,有两个办法可以解决这个问题。

   第一个办法为了SIGBUS信号安装信号处理程序,在这个处理程序中简单的返回。通过这样做,wrtie系统调用返回在中断之前已经写的字节数并且errno设置为成功。让我指出,这是一个糟糕的方法,只是解决症状并没有解决问题的根本原因。因为SIGBUS信号表明进程发生了很严重的问题,我不建议使用此解决方案。

   第二个方法涉从内核进行文件租赁(在微软电脑上被称为‘机会锁定’)。这是解决这个问题正确的方法。通过对文件描述符使用租赁,你可以向内核请求一个关于特殊文件的租赁。你可以向内核请求一个read/write租赁。当另外一个进程试图截断你正在传输的文件时,内核将会发送一个实时信号(RT_SIGNAL_LEASE)。它告诉你内核在破坏对改文件的write或者read租约。在你访问无效的地址和被SIGBUS信号杀死之前,你的wrie调用会被中断。wirte调用的返回值是中断之前已经写得字节数,errno被设置为成功。下面是一个简单的例子:怎么从内核拿到一个租约。

    

 

     你应该得到你的租约在映射文件之前,中断你的租约在你映射之后。通过调用租约类型为F_UNLCK的fcntl F_SETLEASE可以实现。

   SendFile

      在内核2.1版本中,系统调用SendFile被引进以简化网路和本地两个文件之间传递.引入sendfile不仅减少了数据拷贝,也减少了进程上下文切换.

使用如下:

         

 

      为了更好的了解涉及的过程,可以看图3

          

 

                                     图3:用sendfile代替write和read

    第一步:系统调用sendfile通过DMA拷贝文件内容到内核缓存.然后数据被内核拷贝到与套接字相连的内核缓存中.

    第二步:DMA把内核套接字缓存区传递到协议引擎时,发生了第三次拷贝.

     你可能在想我们在调用sendfile时另一个进程截断了文件会发生什么.如果我们没有注册任何的信号处理程序,sen

dfile返回在它中断之前传递的数据,并且erron被设置为成功.

     如果我们在sendfile调用之前从内核获得了文件租赁,行为和返回状态是完全相同的.在sendfile返回之前,我们也会

得到RT_SIGNAL_LEASE信号.

   到目前为止,我们已经能够避免让内核创建多个副本,但是我们仍然有一次拷贝.我们能够避免吗?必须可以的,在硬件

少于的帮助.为了消除所有通过内核的数据拷贝,我们需要一个支持收集操作的网络接口.这意味着数据等待的过程中不

需要连续的内存,它可以分散在各个存储位置.在内核2.4版本中,套接字缓存描述符被修改满足这些要求(就是liinux下著

名的0拷贝).这个方法不仅能够减少上下文的切换,也减少了处理器进行的数据复制.对于用户级程序没有任何变化,代码

如下:

     

     为了更好的了解涉及的过程,可以看图4

               

                 图4 支持收集的硬件可以从多个内存位置组合数据,从而消除了另一个副本

    第一步:sendfile通过DMA把数据从文件拷贝到内核缓存区.

    第二步:没有数据复制到套接字缓存区,而是,仅将描述符的位置和数据量长度信息给套接字

缓存.DMA直接传输数据从内核缓存区到协议引擎,减少了最后一次拷贝.

      因为这个数据仍然是从磁盘拷贝到内存,然后从内存拷贝到网络,所以有些人可能认为这不是

零拷贝.从操作系统看是零拷贝,因为在内核缓存区么有数据复制.当用零拷贝,时,不仅避免了复制,

还带来了别的性能增益,比如:更少的上下文切换,更少的cpu数据缓存污染以及无需cpu校验和计算.

       现在我们知道什么是零拷贝,让我们把原理付诸实践写一些代码.你可以下载源码从:

www.xalien.org/articles/source/sfl-src.tgz.要解压源代码,使用tar -xvzf. 为了编译源代码并创造一个

随机的数据文件,运行make指令.

查看以头文件开头的代码:

/* sfl.c sendfile example program
Dragan Stancevic <
header name                 function / variable
-------------------------------------------------*/
#include <stdio.h>          /* printf, perror */
#include <fcntl.h>          /* open */
#include <unistd.h>         /* close */
#include <errno.h>          /* errno */
#include <string.h>         /* memset */
#include <sys/socket.h>     /* socket */
#include <netinet/in.h>     /* sockaddr_in */
#include <sys/sendfile.h>   /* sendfile */
#include <arpa/inet.h>      /* inet_addr */
#define BUFF_SIZE (10*1024) /* size of the tmp
                               buffer */

     除了基本sokcet操作需要的常规的<sys/socket.h>和<netinet/in.h>,我们需要sendfile的原型定义.可以在<sys/sendfile.h>

服务器标准中发现.

/* are we sending or receiving */
if(argv[1][0] == 's') is_server++;
/* open descriptors */
sd = socket(PF_INET, SOCK_STREAM, 0);
if(is_server) fd = open("data.bin", O_RDONLY);

     相同的程序在可以充当服务器/发送者,也可以充当客户端/接受者.我们必须检查命令提示符参数,然后设置is_server flag在

发送模式运行.我们也可以打开一个INET协议家族的流套接字,作为服务器模式下运行的一部分,需要特定类型数据传递给客户,

因此我们打开我们的数据文件.我们正在使用系统调用来传递数据,因而我们不需要读真实的文件内容到内存中.这个是服务器地

/* clear the memory */
memset(&sa, 0, sizeof(struct sockaddr_in));
/* initialize structure */
sa.sin_family = PF_INET;
sa.sin_port = htons(1033);
sa.sin_addr.s_addr = inet_addr(argv[2]);

   我们清楚服务器地址结构,并且指定协议域,端口和服务器的IP地址.服务器地址是通过命令行参数传递的.端口被硬编码

为没有指定的端口1033,选择这个端口是因为他超出了需要root访问的端口范围.

   这是一个服务器执行分支

if(is_server){
    int client; /* new client socket */
    printf("Server binding to [%s]\n", argv[2]);
    if(bind(sd, (struct sockaddr *)&sa,
                      sizeof(sa)) < 0){
        perror("bind");
        exit(errno);
    }

    作为一个服务器,我们需要指定一个地址给我们套接字描述符,这通过bind实现,它指定套接字描述符分配了地址.

if(listen(sd,1) < 0){
    perror("listen");
    exit(errno);
}

        因为我们用流式套接字,我们必须表明我们愿意接受即将到来的连接并设置连接队列大小.我已经设置

代办队列设置为1,但通常会设置代办稍微大一点,以和等待接受的建立连接.在一些老的内核版本中,这个积压

队列经常被用来防止Syn Flood的攻击.因为系统调用listen改变设置参数仅会为了已经建立的连接,这个积压

队列的特点不被推荐使用为了这个调用.内核参数tcp_max_syn_backlog承担了保护系统免受Syn Flood攻击

的作用:

if((client = accept(sd, NULL, NULL)) < 0){
    perror("accept");
    exit(errno);
}

    accept根据对挂起的连接队列上第一个连接请求创建一个新的连接套接字.这个接口的返回值是新连接的得描述符

这个套接字可以进行read,write和poll/select调用.

if((cnt = sendfile(client,fd,&off,
                          BUFF_SIZE)) < 0){
    perror("sendfile");
    exit(errno);
}
printf("Server sent %d bytes.\n", cnt);
close(client);

     在客户端套接字描述符上建立了连接,因此我们就可以传输数据到远程的系统.这第四个参数时我们传输内容的

大小.为了实现零拷贝传输,需要你的网卡支持内存收集操作.对于例如tcp和udp实现校验和的协议,你还需要检查和

能力.如果你的NIC已经过时不支持这些特性,你仍然可以用sendfile传递数据,区别在于内核传输他们之前会合并缓

存.

    可移植性问题

     通常,sendfile系统调用的问题是,确实一些标准的实现.在linux,Solaris和HP-UX上sendfiel实现是非常不同的.对于

希望在网络数据传输代码中使用零拷贝的开发人员来说,将引入一个问题.

    实现的差异之一是,linux定义一个接口可以在file-to-file和file-to-socket传输数据.另一方面,HP-UX和Solaris,仅可

用于send-to-socket提交.

    第二个区别是linux不能实现向量的转换.Solaris和HP-UX sendfiel有额外的参数可以消除正在传输数据头标相关

的参数的消耗

      展望未来:

       linux下的零拷贝实现还远远没有完成,很有可能在不久的将来改变.更多的功能会添加.比如:sendfile不支持矢量

传输,Samba和Apache的服务器不得不用多重的设置过TCP_CORK标注的sendfile这个标志告诉系统在下一次调用

有更多的数据.TCP_CORK和TCP_NODELAY也是不兼容的,用它当我们想追加数据头.这是一个完美的例子,矢量调

用会消除多个sendfiel调用和当前实现的延迟.

     另外一个不好的限制是,当前的调用只能用来传输小于2G的内容.如此大的文件在今天不是很普遍,令人失望的是

不得不复制这些数据在它出现时.因为sendfile和mmap调用方法都不可用.sendfile64在未来版本的内核中会普遍.

    总结:

    忽略这些缺点,零拷贝 sendfiel是非常有用的特性,我希望你从这文章中学到足够的知识,用在你的代码中.如果你对

这个主题非常感兴趣,可以看我的第二篇文章,"零拷贝II:内核角度",我将会有大量的描述零拷贝的内核部分.

    更多信息:

     email:visitor@xalien.org

      Dragan Stancevic 是一个内核和硬件开发工程师,他也是一个专业的软件工程师,但同时对应用物理学有深厚的兴趣.

在它业余时间付出了极大的精力.

 感悟:

 大佬是真的大佬,涉及的领域是真的广(膜拜的榜样),同时自己参考了兰新宇大神的零拷贝的文章,扩充如下:

 Dragan的实际是:Bypass Userspace Copy 和Bypass CPU Copy,其实更进一步,可以Bypass System Memory。

 Bypass System Memory:

 NIC 都可以支持“分散-聚合”,那另一端的存储设备呢?

 目前支持 NVMe 协议的 SSD,很多也具备了 scatter-gather 功能,可以和(属于同一 PCI host bridge 的) NIC 配合实现 Peer-to-Peer 的直接传输,称为 p2pDMA。和普通 DMA(设备和内存之间的直接传输)相比,p2pDMA(设备和设备之间的直接传输) 不仅 bypass 了CPU, 还 bypass 了系统内存(有点 tunnel 的感觉)。

  

  

 

 

  所谓“设备之间的传输”,其实是在设备内存之间的数据传递。SSD 也好, NIC 也罢,都是位于 PCIe 插槽中的,在 PCIe 的拓扑体系里,属于 End Point(简称 EP),所以 p2pDMA 实际是在 EP memory 之间进行的。

  这通常是由其中一方(A)内部的 DMA engine 来完成的,不过它得知道对方(B)的数据地址,所以设备 B 需要通过某种方式,将自己的 PCIe BAR(代表 memory region)暴露给设备 A 的 DMA 引擎。

     void pci_p2pmem_publish(struct pci_dev *pdev, bool publish); 

Device Zone:

  p2pDMA 的传输过程本身的确是 CPU offload 的,但它离不开双方 device driver 的配置和管理。在 Linux 系统中,有一个重要的选项与此相关,那就是 CONFIG_ZONE_DEVICE(ARM 服务器通常不支持,参考内核源码的 "arch/arm64/Kconfig" 文件)。

Normal zone , DMA zone 这些,可能接触过 Linux 内存管理的都比较熟悉,但这个 Device zone 嘛,没有做过设备驱动的可能不会接触到。

EP memory 属于 I/O memory, 其页面一般是没有 "struct page" 描述的,但在某些场景下又是需要的(比如 p2pDMA 和 HMM),所以单独弄了个 Device zone 来处理这种情况。

  storage 和 network 都有着悠久的历史,"sendfile" 也是早在内核 2.1 版本就已见雏形。CPU 一直是 compute 的代表(要不然计算机为什么叫计算机),但随着 AI 大模型的横空出世,算力领域迎来了一位炙手可热的新红人。在 p2p 的世界里,这位新贵是如何发挥作用的呢,请看下文分解。

posted @ 2020-07-20 22:51  月光下的脚步  阅读(3)  评论(0)    收藏  举报