用户角度下的零拷贝

转译:https://www.linuxjournal.com/article/6345
到目前为止,几乎每个人都听说过Linux下所谓的零拷贝功能,但我经常遇到对这个主题没有完全理解的人。因此,我决定写几篇文章来更深入地研究这个问题,希望能够揭示这个有用的特性。在本文中,我们从用户模式应用程序的角度来研究零拷贝,因此有意省略了内核级的详细信息。

一、零拷贝是什么?

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

1、系统调用中的拷贝

read(file, tmp_buf, len);
write(socket, tmp_buf, len);

看起来很简单;您可能会认为只使用这两个系统调用不会有太多的开销。事实上,这与事实相去甚远。在这两个调用之后,数据至少被复制了四次,并且几乎执行了同样多的(四次)用户/内核上下文切换。(实际上这个过程要复杂得多,但我想保持简单)。为了更好地了解所涉及的流程,请查看图1。顶部显示上下文切换,底部显示复制操作。
在这里插入图片描述
图1。在两个示例系统调用中拷贝

  • 第一步:read系统调用导致从用户模式到内核模式的上下文切换。第一次拷贝由DMA引擎执行,它从磁盘读取文件内容并将其存储到内核地址空间缓冲区中。
  • 第二步:数据从内核缓冲区拷贝到用户缓冲区,读取系统调用返回。调用的返回导致上下文从内核模式切换回用户模式。现在数据存储在用户地址空间缓冲区中,它可以再次开始下行。
  • 第三步:write系统调用导致从用户模式到内核模式的上下文切换。执行第三次拷贝,将数据再次放入内核地址空间缓冲区。不过,这一次,数据被放入了一个不同的缓冲区,一个专门与套接字关联的缓冲区。
  • 第四步:write系统调用返回,创建我们的第四次上下文切换
    当DMA引擎将数据从内核缓冲区传递给协议引擎时,会独立地、异步地进行第四次拷贝

你可能会问自己:“独立和异步是什么意思?在呼叫返回之前,数据不是已经传输了吗?”调用返回,实际上,并不保证传输;它甚至不能保证传输的开始。这仅仅意味着以太网驱动程序在其队列中有自由描述符,并已接受我们的数据进行传输。可能有很多包在我们前面排队。除非驱动程序/硬件实现了优先级环或队列,否则数据是按照先进先出的方式传输的。(图1中的分叉DMA副本说明了最后一个副本可以延迟的事实)。

正如您所看到的,大量的数据拷贝并不是真正需要的。可以消除一些重复,以减少开销并提高性能。作为一名驱动程序开发人员,我使用的硬件具有一些相当高级的特性。有些硬件可以完全绕过主存,直接将数据传输到另一个设备。这个特性消除了系统内存中的拷贝,是一件很好的事情,但不是所有的硬件都支持它。还有一个问题是,磁盘中的数据必须为网络重新打包,这带来了一些复杂的问题。为了消除开销,我们可以从消除内核和用户缓冲区之间的一些拷贝开始。

2. mmap

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

tmp_buf = mmap(file, len);
write(socket, tmp_buf, len);

为了更好地了解所涉及的流程,请查看图2。上下文切换保持不变。
在这里插入图片描述
图2。调用mmap

  • 第一步:mmap系统调用导致DMA引擎将文件内容拷贝到内核缓冲区中。然后,缓冲区被用户进程共享,而不需要在内核和用户内存空间之间执行任何拷贝。
  • 第二步:write系统调用导致内核将数据从原始内核缓冲区拷贝到与套接字相关的内核缓冲区。
  • 第三步:当DMA引擎将数据从内核套接字缓冲区传递到协议引擎时,将进行第三次拷贝

通过使用mmap而不是read,我们将内核需要复制的数据量减少了一半(注:指缓冲区之间的拷贝由两次减少为一次,即图中的CPU拷贝)。当传输大量数据时,这将产生相当好的结果。然而,这种改善是有代价的;在使用mmap+write方法时,有一些隐藏的陷阱。当您在内存中映射一个文件,然后调用write,而另一个进程截断相同的文件时,您将陷入其中之一。您的写系统调用将被总线错误信号SIGBUS中断,因为您执行了一个糟糕的内存访问。该信号的默认行为是终止进程并转储核心—这对于网络服务器来说不是最理想的操作。有两种方法可以解决这个问题。

  • 第一种方法是为SIGBUS信号安装一个信号处理程序,然后在处理程序中简单地调用return。通过这样做,write系统调用返回它被中断之前写入的字节数,并将errno设置为成功。我要指出,这将是一个糟糕的解决方案,它治标不治本。因为SIGBUS信号表明进程出现了严重的问题,所以我不鼓励将此作为解决方案。
  • 第二种解决方案涉及到内核的文件租赁(在Microsoft Windows中称为“机会锁定”)。这是解决这个问题的正确方法。通过对文件描述符使用租赁,您就获得了内核对特定文件的租赁。然后可以向内核请求读/写租约。当另一个进程试图截断您正在传输的文件时,内核会向您发送一个实时信号,RT_SIGNAL_LEASE信号。它告诉您内核破坏了您对该文件的写或读租约。在程序访问无效地址之前,写调用被中断,并被SIGBUS信号终止。write调用的返回值是中断之前写入的字节数,并且errno将被设置为success。下面是一些示例代码,展示了如何从内核获得租约:
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;
}

您应该在映射文件之前获得租约,并在完成后打破租约。这是通过调用租期类型为F_UNLCK的fcntl F_SETLEASE实现的。

3. sendfile

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

sendfile(socket, file, len);

为了更好地了解所涉及的流程,请查看图3。
在这里插入图片描述
图3。用Sendfile替换Read和Write

  • 第一步:sendfile系统调用导致DMA引擎将文件内容拷贝到内核缓冲区中。然后,数据被内核拷贝到与套接字相关的内核缓冲区中。
  • 第二步:当DMA引擎将数据从内核套接字缓冲区传递到协议引擎时,将进行第三次拷贝

您可能想知道,如果另一个进程截断了我们使用sendfile系统调用传输的文件,会发生什么。如果我们不注册任何信号处理程序,sendfile调用只是返回它被中断之前传输的字节数,并且errno将被设置为success。
如果我们在调用sendfile之前从内核获得了文件的租约,然而,行为和返回状态是完全相同的。我们还在sendfile调用返回之前获得RT_SIGNAL_LEASE信号。
到目前为止,我们已经能够避免让内核产生几个拷贝,但是我们仍然只剩下一个拷贝(内核缓冲区之间的CPU拷贝)。这也能避免吗?当然,在硬件的帮助下。
为了消除内核所做的所有数据重复,我们需要一个支持收集操作的网络接口。这意味着等待传输的数据不需要在连续的内存中;它可以分散在不同的存储位置。在内核版本2.4中,套接字缓冲区描述符被修改以适应这些需求—在Linux下称为零拷贝。这种方法不仅减少了多次上下文切换,还消除了处理器所做的数据重复。对于用户级应用程序,什么都没有改变,所以代码仍然是这样的:

sendfile(socket, file, len);

为了更好地了解所涉及的流程,请查看图4。
在这里插入图片描述
图4。支持收集的硬件可以从多个内存位置收集数据,消除另一个拷贝。

  • 第一步:sendfile系统调用导致DMA引擎将文件内容拷贝到内核缓冲区中。
  • 第二步:不将数据拷贝到套接字缓冲区中。相反,只有包含有关数据位置和长度的信息的描述符才被追加到套接字缓冲区。DMA引擎直接将数据从内核缓冲区传递到协议引擎,从而消除了剩余的最终拷贝。

因为数据实际上还是从磁盘拷贝到内存,又从内存拷贝到网络,所以有人可能会说这不是真正的零拷贝。但是,从操作系统的角度来看,这是零拷贝,因为数据不会在内核缓冲区之间拷贝。当使用零拷贝时,除了避免拷贝之外,还可以获得其他性能上的好处,比如更少的上下文切换、更少的CPU数据缓存污染和没有CPU校验和计算

二、源码

现在我们知道了什么是零拷贝,让我们将理论付诸实践并编写一些代码。您可以从ftp.ssc.com/pub/lj/listings/issue105/6345.tgz下载完整的源代码。输入tar -zxvf sfl-src.tgz 来解包它并使用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 */

除了基本套接字操作所需的常规<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设置为在发送者模式下运行。我们还打开了一个INET协议族的流套接字。作为在服务器模式下运行的一部分,我们需要将某些类型的数据传输给客户端,因此我们打开数据文件。我们使用系统调用sendfile来传输数据,因此我们不需要读取文件的实际内容并将其存储在程序内存缓冲区中。这是服务器地址:

/* 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实现的,它给套接字描述符(sd)分配了一个服务器地址(sa):

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

因为我们使用的是流套接字,所以我们必须通告我们接受传入连接的意愿,并设置连接队列大小。我已经将积压队列设置为1,但是对于等待被接受的已建立连接,通常会将积压队列设置得稍高一些。在旧版本的内核中,backlog队列用于防止syn flood攻击。由于系统调用监听被更改为仅为已建立的连接设置参数,因此该调用的backlog队列特性已弃用。内核参数tcp_max_syn_backlog已经接管了保护系统免受syn flood攻击的角色:

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

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

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

在客户端套接字描述符上建立连接,这样我们就可以开始向远程系统传输数据。我们通过调用sendfile系统调用来实现这一点,它在Linux下的原型是这样的:

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

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

三、可能存在的问题:

  • 通常,sendfile系统调用的问题之一是缺乏标准实现,就像开放系统调用一样。Linux、Solaris或HP-UX中的Sendfile实现是完全不同的。这给希望在网络数据传输代码中使用零拷贝的开发人员带来了一个问题。
  • 实现上的差异之一是Linux提供了一个sendfile,它定义了一个接口,用于在两个文件描述符(file-to-file)和(file-to-socket)之间传输数据。另一方面,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将非常方便。

更多的信息

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值