二维码

零拷贝I:用户模式角度

1502 人阅读 | 时间:2020年01月03日 13:48

到目前为止,几乎每个人都听说过Linux下的所谓的零复制功能,但是我经常遇到对这一主题没有完全了解的人。因此,我决定写几篇文章对这个问题进行更深入的探讨,以期阐明此有用的功能。在本文中,我们从用户模式应用程序的角度来看零拷贝,因此故意删除了繁琐的内核级细节。

什么是零复制?

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

读取(文件,tmp_buf,len); 写(socket,tmp_buf,len);

看起来很简单;您会认为仅使用这两个系统调用就不会有太多开销。实际上,这离事实还远。在这两个调用之后,数据已被至少复制了四次,并且几乎执行了许多用户/内核上下文切换。(实际上,此过程要复杂得多,但我想保持简单)。为了更好地了解所涉及的过程,请看一下图1。上面显示了上下文切换,下面显示了复制操作。

零拷贝I:用户模式角度

图1.复制两个示例系统调用

第一步:读取系统调用导致上下文从用户模式切换到内核模式。第一个副本由DMA引擎执行,该引擎从磁盘读取文件内容并将其存储到内核地址空间缓冲区中。

第二步:将数据从内核缓冲区复制到用户缓冲区,然后读取的系统调用返回。调用返回导致上下文从内核切换回用户模式。现在,数据存储在用户地址空间缓冲区中,并且可以重新开始。

第三步:写系统调用导致上下文从用户模式切换到内核模式。执行第三次复制以再次将数据放入内核地址空间缓冲区。但是,这次将数据放入另一个缓冲区中,该缓冲区专门与套接字关联。

第四步:write系统调用返回,创建我们的第四个上下文开关。独立且异步地,当DMA引擎将数据从内核缓冲区传递到协议引擎时,发生第四次复制。您可能会问自己:“独立和异步是什么意思?呼叫返回之前是否发送了数据?”实际上,返回呼叫并不能保证传输。它甚至不能保证传输的开始。这仅表示以太网驱动程序在其队列中具有免费的描述符,并已接受我们的数据进行传输。在我们之前可能有许多数据包排队。除非驱动程序/硬件实现优先级环或队列,否则将以先进先出的方式传输数据。(图1中的分叉DMA副本说明了可以延迟最后一个副本的事实)。

如您所见,实际上并不需要大量的数据复制。可以消除某些重复以减少开销并提高性能。作为驱动程序开发人员,我使用具有一些高级功能的硬件。某些硬件可以完全绕开主内存,并将数据直接传输到另一台设备。此功能消除了系统内存中的副本,这很不错,但是并非所有硬件都支持它。还有来自磁盘的数据必须为网络重新打包的问题,这带来了一些复杂性。为了消除开销,我们可以从消除内核和用户缓冲区之间的某些复制开始。

消除副本的一种方法是跳过调用read,而是调用mmap。例如:

tmp_buf = mmap(文件,len); 写(socket,tmp_buf,len);

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

零拷贝I:用户模式角度

图2.调用mmap

第一步:mmap系统调用使DMA引擎将文件内容复制到内核缓冲区中。然后与用户进程共享缓冲区,而无需在内核和用户内存空间之间执行任何复制。

第二步:写系统调用使内核将数据从原始内核缓冲区复制到与套接字关联的内核缓冲区中。

第三步:第三份复制发生在DMA引擎将数据从内核套接字缓冲区传递到协议引擎时。

通过使用mmap而非读取,我们减少了内核必须复制的数据量的一半。当传输大量数据时,这会产生相当好的结果。但是,这种改进并非没有代价。使用mmap + write方法时存在隐患。当您在内存中映射文件时,当另一个进程将同一个文件截断时,您将调用write方法。总线错误信号SIGBUS将中断您的写入系统调用,因为您执行了错误的内存访问。该信号的默认行为是杀死进程并转储核心,这不是网络服务器最理想的操作。有两种方法可以解决此问题。

第一种方法是为SIGBUS信号安装信号处理程序,然后在处理程序中简单地调用return。通过这样做,write系统调用将返回在中断和将errno设置为成功之前写入的字节数。让我指出,这将是一个糟糕的解决方案,只能解决症状而不是问题的原因。由于SIGBUS表示该过程出现了严重问题,因此我不建议使用此解决方案。

第二种解决方案涉及从内核进行文件租赁(在Microsoft Windows中称为“机会锁定”)。这是解决此问题的正确方法。通过对文件描述符使用租赁,您可以与特定文件的内核进行租赁。然后,您可以从内核请求读/写租约。当另一个进程试图截断正在传输的文件时,内核会向您发送一个实时信号RT_SIGNAL_LEASE信号。它告诉您内核正在破坏对该文件的写或读租约。在程序访问无效地址并被SIGBUS信号杀死之前,您的写调用被中断。写调用的返回值是中断之前写的字节数,并且errno将被设置为成功。以下是一些示例代码,显示了如何从内核中获得租约:

if(fcntl(fd,F_SETSIG,RT_SIGNAL_LEASE)== -1){     perror(“内核租用设置信号”);     返回-1; } / * l_type可以是F_RDLCK F_WRLCK * / if(fcntl(fd,F_SETLEASE,l_type)){     perror(“内核租用集类型”);     返回-1; }

在映射文件之前,应先获取租约,并在完成后中断租约。这可以通过使用租约类型为F_UNLCK的fcntl F_SETLEASE来实现。

发送文件

在内核版本2.1中,引入了sendfile系统调用,以简化网络上以及两个本地文件之间的数据传输。sendfile的引入不仅减少了数据复制,还减少了上下文切换。像这样使用它:

sendfile(socket,file,len);

为了更好地了解所涉及的过程,请看一下图3。

零拷贝I:用户模式角度

图3.用Sendfile代替读写

第一步:sendfile系统调用使DMA引擎将文件内容复制到内核缓冲区中。然后,数据被内核复制到与套接字关联的内核缓冲区中。

第二步:第三份复制发生在DMA引擎将数据从内核套接字缓冲区传递到协议引擎时。

您可能想知道如果另一个进程截断了我们使用sendfile系统调用传输的文件会发生什么情况。如果我们不注册任何信号处理程序,则sendfile调用将简单返回其在中断之前传输的字节数,并且errno将被设置为成功。

但是,如果在调用sendfile之前从内核获得了对该文件的租约,则其行为和返回状态是完全相同的。在sendfile调用返回之前,我们还获得了RT_SIGNAL_LEASE信号。

到目前为止,我们已经能够避免让内核创建多个副本,但是我们仍然只剩下一个副本。也可以避免吗?绝对在硬件的帮助下。为了消除内核完成的所有数据重复,我们需要一个支持收集操作的网络接口。这只是意味着等待传输的数据不需要在连续的内存中;它可以分散在各个存储位置。在内核版本2.4中,对套接字缓冲区描述符进行了修改,以适应这些要求-在Linux中称为零拷贝。这种方法不仅减少了多个上下文切换,而且还消除了处理器进行的数据重复。对于用户级应用程序,没有任何变化,因此代码仍如下所示:

sendfile(socket,file,len);

为了更好地了解所涉及的过程,请看一下图4。

零拷贝I:用户模式角度

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

第一步:sendfile系统调用使DMA引擎将文件内容复制到内核缓冲区中。

第二步:没有数据复制到套接字缓冲区中。而是仅将具有有关数据的行踪和长度信息的描述符附加到套接字缓冲区。DMA引擎将数据直接从内核缓冲区传递到协议引擎,从而消除了剩余的最终副本。

由于实际上数据仍然是从磁盘复制到内存以及从内存复制到线路,因此有人可能会认为这不是真正的零副本。但是,从操作系统的角度来看,这是零副本,因为在内核缓冲区之间不重复数据。当使用零拷贝时,除了避免拷贝外,还可以享受其他性能优势,例如更少的上下文切换,更少的CPU数据缓存污染以及无需CPU校验和计算。

现在我们知道什么是零拷贝了,让我们将理论付诸实践并编写一些代码。您可以从www.xalien.org/articles/source/sfl-src.tgz下载完整的源代码 要解压缩源代码,在提示符下键入tar -zxvf sfl-src.tgz要编译代码并创建随机数据文件data.bin,请运行make

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

/ * sfl.c sendfile示例程序 德拉甘·斯坦切维奇< 标头名称函数/变量 ------------------------------------------------- * / #include <stdio.h> / * printf,perror * / #include <fcntl.h> / *打开* / #include <unistd.h> / *关闭* / #include <errno.h> / * errno * / #include <string.h> / * memset * / #include <sys / socket.h> / *套接字* / #include <netinet / in.h> / * sockaddr_in * / #include <sys / sendfile.h> / *发送文件* / #include <arpa / inet.h> / * inet_addr * / #定义BUFF_SIZE(10 * 1024)/ * tmp的大小                                缓冲 */

除了基本套接字操作所需的常规<sys / socket.h>和<netinet / in.h>外,我们还需要sendfile系统调用的原型定义。可以在<sys / sendfile.h>服务器标志中找到:

/ *是我们发送还是接收* / if(argv [1] [0] =='s')is_server ++; / *打开描述符* / sd = socket(PF_INET,SOCK_STREAM,0); if(is_server)fd = open(“ data.bin”,O_RDONLY);

相同的程序既可以充当服务器/发送者,也可以充当客户端/接收者。我们必须检查命令提示符参数之一,然后将标志is_server设置为在发送方模式下运行。我们还打开了INET协议系列的流套接字。作为在服务器模式下运行的一部分,我们需要某种类型的数据来传输到客户端,因此我们打开数据文件。我们正在使用系统调用sendfile来传输数据,因此我们不必读取文件的实际内容并将其存储在程序存储器缓冲区中。这是服务器地址:

/ *清除内存* / memset(&sa,0,sizeof(struct sockaddr_in)); / *初始化结构* / 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){     诚信客户;/ *新的客户端套接字* /     printf(“服务器绑定到[%s] \ n”,argv [2]);     if(bind(sd,(struct sockaddr *)&sa,                       sizeof(sa))<0){         perror(“ bind”);         退出(errno);     }

作为服务器,我们需要为套接字描述符分配一个地址。这是通过系统调用绑定实现的,该绑定为套接字描述符(sd)分配了服务器地址(sa):

if(listen(sd,1)<0){     perror(“ listen”);     退出(errno); }

因为我们使用的是流套接字,所以我们必须宣传我们愿意接受传入的连接并设置连接队列大小。我将待办事项队列设置为1,但是通常将待办事项的待办事项设置得更高一些,以等待已建立的建立连接。在较早版本的内核中,积压队列用于防止Syn Flood攻击。由于系统调用侦听已更改为仅为已建立的连接设置参数,因此该调用不推荐使用积压队列功能。内核参数tcp_max_syn_backlog承担了保护系统免受Syn Flood攻击的作用:

if((client = accept(sd,NULL,NULL))<0){     perror(“ accept”);     退出(errno); }

系统调用accept根据挂起的连接队列上的第一个连接请求创建一个新的连接套接字。调用的返回值是新创建的连接的描述符。套接字现在可以进行读取,写入或轮询/选择系统调用了:

if((cnt = sendfile(client,fd,&off,                           BUFF_SIZE))<0){     perror(“ sendfile”);     退出(errno); } printf(“服务器发送了%d个字节。\ n”,cnt); 关闭(客户);

在客户端套接字描述符上建立了连接,因此我们可以开始将数据传输到远程系统。为此,我们调用sendfile系统调用,该调用在Linux下以以下方式原型化:

外部ssize_t sendfile(int __out_fd,int __in_fd,off_t * offset,           size_t __count)__THROW;

前两个参数是文件描述符。第三个参数指向sendfile应该开始发送数据的偏移量。第四个参数是我们要传输的字节数。为了使sendfile发送使用零拷贝功能,您需要网卡的内存收集操作支持。对于实现校验和的协议,例如TCP或UDP,您还需要校验和功能。如果您的NIC已过时并且不支持这些功能,则仍然可以使用sendfile来传输文件。区别在于内核将在传输缓冲区之前合并缓冲区。可移植性问题

通常,与sendfile系统调用有关的问题之一是缺乏标准的实现,就像开放系统调用一样。Linux,Solaris或HP-UX中的Sendfile实现非常不同。对于希望在其网络数据传输代码中使用零复制的开发人员而言,这带来了一个问题。

实现上的差异之一是Linux提供了一个sendfile,该sendfile定义了一个接口,用于在两个文件描述符(文件到文件)和(文件到套接字)之间传输数据。另一方面,HP-UX和Solaris仅可用于文件到套接字的提交。

第二个区别是Linux不实现向量传输。Solaris sendfile和HP-UX sendfile具有额外的参数,这些参数消除了与正在传输的数据的头标相关的开销。

展望未来

Linux下零拷贝的实现还远远没有完成,并且可能会在不久的将来发生变化。应该添加更多功能。例如,sendfile调用不支持矢量传输,并且Samba和Apache之类的服务器必须使用设置了TCP_CORK标志的多个sendfile调用。该标志告诉系统在下一个sendfile调用中有更多数据通过。TCP_CORK也与TCP_NODELAY不兼容,当我们想在数据头中添加或添加头时使用。这是一个完美的例子,其中矢量调用将消除对多个sendfile调用的需要以及当前实现所要求的延迟。

当前sendfile中的一个相当不愉快的限制是,当传输大于2GB的文件时,无法使用它。如此大的文件在今天并不是很普遍,而令人失望的是必须在出路时复制所有这些数据。因为在这种情况下sendfile和mmap方法都不可用,所以sendfile64在将来的内核版本中将非常方便。

结论

尽管存在一些缺点,但零拷贝sendfile是一个有用的功能,希望您已发现本文内容足够,可以在程序中开始使用它。如果您对该主题有更深入的兴趣,请关注我的第二篇名为“零复制II:内核透视图”的文章,在这里我将深入探讨零复制的内核内部。

更多信息


零拷贝I:用户模式角度电子邮件:visitor@xalien.org

Dragan Stancevic 是二十多岁的内核和硬件开发工程师。他是专业的软件工程师,但是对应用物理学有浓厚的兴趣,并且在业余时间以极高的电压工作。


©著作权归作者所有:来自ZhiKuGroup博客作者没文化的原创作品,如需转载,请注明出处,否则将追究法律责任 来源:ZhiKuGroup博客,欢迎分享。

评论专区
  • 昵 称必填
  • 邮 箱选填
  • 网 址选填
◎已有 0 人评论
搜索
作者介绍
30天热门
×
×
本站会员尊享VIP特权,现在就加入我们吧!登录注册×
»
会员登录
新用户注册
×
会员注册
已有账号登录
×