零拷贝 I:用户模式视角

作者:Dragan Stancevic

到现在几乎每个人都听说过 Linux 下所谓的零拷贝功能,但我经常遇到对这个主题没有完全理解的人。因此,我决定写几篇文章来更深入地探讨这个问题,希望能解开这个有用的功能。在本文中,我们从用户模式应用程序的角度来看零拷贝,因此有意省略了内核级别的详细信息。

什么是零拷贝?

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

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

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

Zero Copy I: User-Mode Perspective

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

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

步骤二:数据从内核缓冲区复制到用户缓冲区,并且 read 系统调用返回。从调用返回导致从内核模式切换回用户模式。现在数据存储在用户地址空间缓冲区中,它可以再次开始向下传递。

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

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

正如您所看到的,许多数据重复实际上对于保持事情顺利进行并不是必要的。可以消除一些重复以减少开销并提高性能。作为驱动程序开发人员,我使用的硬件具有一些非常高级的功能。一些硬件可以完全绕过主内存并将数据直接传输到另一个设备。此功能消除了系统内存中的复制,这是一件好事,但并非所有硬件都支持它。还存在必须为网络重新打包磁盘数据的问题,这引入了一些复杂性。为了消除开销,我们可以从消除内核缓冲区和用户缓冲区之间的一些复制开始。

消除复制的一种方法是跳过调用 read 而改为调用 mmap。例如

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

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

Zero Copy I: User-Mode Perspective

图 2. 调用 mmap

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

步骤二:write 系统调用导致内核将数据从原始内核缓冲区复制到与套接字关联的内核缓冲区。

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

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

第一种方法是为 SIGBUS 信号安装一个信号处理程序,然后在处理程序中简单地调用 return。通过这样做,write 系统调用将返回中断之前写入的字节数,并将 errno 设置为成功。让我指出,这将是一个糟糕的解决方案,它治疗症状而不是问题的根源。因为 SIGBUS 信号表明进程的某些地方出了严重问题,所以我建议不要将其用作解决方案。

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

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;
}

您应该在 mmap 文件之前获取租约,并在完成后打破租约。这可以通过调用 fcntl F_SETLEASE 并将租约类型设置为 F_UNLCK 来实现。

Sendfile

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

sendfile(socket, file, len);

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

Zero Copy I: User-Mode Perspective

图 3. 用 Sendfile 替换 Read 和 Write

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

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

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

但是,如果我们从内核获取文件的租约,然后再调用 sendfile,则行为和返回状态完全相同。在 sendfile 调用返回之前,我们也会收到 RT_SIGNAL_LEASE 信号。

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

sendfile(socket, file, len);

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

Zero Copy I: User-Mode Perspective

图 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 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,但通常会将积压队列设置得更高一些,以用于等待接受的已建立连接。在旧版本的内核中,积压队列用于防止 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);
连接在客户端套接字描述符上建立,因此我们可以开始将数据传输到远程系统。我们通过调用 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 系统调用普遍存在的问题之一是缺乏标准实现,就像 open 系统调用一样。Linux、Solaris 或 HP-UX 中的 Sendfile 实现差异很大。这给希望在其网络数据传输代码中使用零拷贝的开发人员带来了问题。

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

第二个区别是 Linux 没有实现向量传输。Solaris sendfile 和 HP-UX sendfile 具有额外的参数,可以消除与将标头添加到正在传输的数据相关的开销。

展望未来

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

当前 sendfile 中一个相当令人不快的限制是它不能用于传输大于 2GB 的文件。今天,如此大小的文件并非罕见,并且不得不在文件输出时复制所有数据是相当令人失望的。因为 sendfile 和 mmap 方法在这种情况下都不可用,所以在未来的内核版本中,sendfile64 将非常方便。

结论

尽管存在一些缺点,但零拷贝 sendfile 仍然是一个有用的功能,我希望您发现本文内容丰富,足以开始在您的程序中使用它。如果您对该主题有更深入的兴趣,请关注我的第二篇文章,标题为“零拷贝 II:内核视角”,我将在其中更深入地探讨零拷贝的内核内部原理。

更多信息

Zero Copy I: User-Mode Perspective
电子邮件:visitor@xalien.org

Dragan Stancevic 是一位二十多岁的内核和硬件启动工程师。他是一名专业软件工程师,但对应用物理学有着浓厚的兴趣,并且以在业余时间玩弄极高电压而闻名。

加载 Disqus 评论