71°

零拷贝:用户态视角

在Linux系统越来越多的人听说过所谓的零拷贝技术,但是我经常遇到很多对这个名词没有完全理解的人。因此,我决定写一些文章,深挖这个问题,希望能揭开这个有用的特性。在这篇文章,我们从用户态角度来看零拷贝,所以特意忽略大量内核细节。

什么是零拷贝?

为了更好的理解解决问题的方法,我们首先需要理解问题本身。让我们看下网络服务器将存储的文件通过网络发送给客户端涉及的简单流程,下面是简单的代码示例:

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

看起来很简单,你应该认为这里只有两次系统调用。实际上,这远远不是事实。在这两次调用之间,数据至少被复制了四次,并且执行了很多次用户/内核之间的上下文切换(这个过程很复杂,我想简单说下)。为了更好的了解涉及的工程,请看图1。顶部显示上下文切换,底部显示复制操作。

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

第一步:读取系统的调用导致用户态到内核态的切换,第一次复制由DMA引擎执行,从磁盘读取内容并存储到内核空间的缓冲区。

第二部:数据从内核缓冲区复制到用户缓冲区,并返回读取系统调用,从执行返回导致内核态切换回用户态。现在数据存储在用户地址空间的缓冲区,可以按照这种方法往下进行了。

第三部:写入系统调用导致从用户态到内核态的上下文切换,第三次复制是将数据再一次放入内核地址空间缓冲区。但是这一次,数据被放到一个不同的缓冲区,一个和Socket关联的缓冲区。

第四部:写入系统执行返回,创建我们的第四次上下文切换。独立且异步,第四次复制通过DMA引擎将数据从内核缓冲区传到协议引擎。你也许会问自己,什么是独立且异步?是不是在执行返回之前传输了数据?实际上执行返回,不能保证数据传输,它甚至不能保证传输的开始,只是意味着以太驱动程序队列有空闲的描述符并可以接收传输的数据。在我们前面可能有很多数据包在排队,除非驱动 / 硬件支持优先级响应或者队列,数据会按照先进先出次序传输(图1的DMA复制说明了最后一次复制事实上可以延迟)。

正如所见,实际上并不需要大量的数据复制,可以消除一些重复用来减少开销并提高执行效率。作为一名驱动工程师,我在工作中使用过一些具有高级特性的硬件。有的硬件可以绕过主内存直接传输数据到另一台设备。这个特性消除了系统内存之间的复制,这是个好事,但不是所有的硬件都支持这个特性。这里还存在磁盘数据为网络传输重新打包的问题,引入一些复杂度。为了节省开销,我们从消除内核和用户缓冲区之间的复制开始。

消除复制的一种方法是跳过系统调用并用mmap调用代替。例如:

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

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

mmap调用

第一步:mmap调用会触发文件内容由DMA引擎复制到内存缓冲区。然后和用户进程共享缓冲区,内核和用户内存之间不执行任何复制操作。

第二步:写入系统会触发内核从原始内核缓冲区复制数据到与套接字关联的内核缓冲区。

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

通过使用mmap代替读取,我们可以减少一半内核复制数据量。当大量数据复制时会得到相当好的结果。当然,这种改进并非没有代价,使用mmap+写入方法存在隐藏的陷阱。当内存映射一个文件然后调用另一个进程截取同一个文件时调用write方法时就会陷入其中一个。由于执行了错误的内存访问,你的写入系统调用会被总线错误信号SIGBUS中断。为这个信号设置的默认行为时杀死进程并记录核心数据-而不是大多数网络服务器希望的那样。有两种方法可以解决这个问题。

第一种方案是为SIGBUS信号安装信号处理程序,然后在处理程序中简单的低矮用return方法。这样做的话,写入系统会在中断之前写入一部分字节并设置异常为成功。在我看来这是个不好的解决方案,治标不治本。因为SIGBUS信号表示进程发生了严重的错误,我不鼓励这样处理。

第二种方案涉及到内核的文件租用(微软称作“opportunistic locking”)。这是解决问题的正确方法。通过在文件描述符使用租用,可以在特定文件上使用内核。你可以从内核租借读/写操作。当你在传输时另一个进程尝试截取文件时,内核会为你发送一条实时信号。在程序访问非法地址之前,你的写入调用会被中断并被SIGBUS信号杀掉。中断之前会返回传输的字节数,error会被设置为成功。这里有个从内核调用租约的例子:

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实现的。

在2.1的内核版本,为了简化网络和两个本地文件之间的简单调用引入了文件发送系统。引入不仅仅为了降低数据复制,也是为了减少上下文切换。使用方法如下:

sendfile(socket, file, len);

为了更好的理解涉及的过程,请查看图3

用Sendfile代替读写

第一步:文件发送系统调用会通过DMA引擎复制文件内容到内核缓冲区。然后内核将数据复制到和套接字相关联的内核缓冲区。

第二步:第三次复制发生在DMA引擎从内核缓冲区传送数据到协议引擎。 你可能会想如果我们使用文件发送系统传输数据时另一个进程截取文件会发生什么。如果我们没有注册信号处理程序,文件发送程序会在中断之前返回已经发送的字节数吗,error会被设置为成功。 如果我们在调用文件发送程序之前从内核获得租约,无论如何,精准返回状态时相同的。我们也可以在递送会回之前获取RT_SIGNAL_LEASE信号。 到现在为止,我们能够避免内核生成多个重复副本,但是我们仍有一个副本。这个也可以避免吗?当然,借助硬件的一点帮助。为了消除内核的数据复制,我们需要支持搜集操作的网络接口。这仅仅意味着等待传输时不需要连续内存,可以分散到多个内存位置。在2.4的内核版本,修改了套接字缓冲区描述符来适应这些请求-也就是linux所说的零拷贝。这种方法不仅减少了多次上下文切换,也消除了处理器之间的数据复制,因此代码如下:

sendfile(socket, file, len);

为了更好的理解涉及的过程,请查看图4.

支持收集的硬件可以从多个内存位置组装数据,从而消除了其它复制

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

第二步:没有需要复制到套接字缓冲区的数据,相反,只有文件的地址和长度相关的描述符追加到套接字缓冲区。DMA引擎将内核缓冲区的数据直接传输到协议引擎,这样消除了保留的最后复制。 因为数据实际上仍从磁盘到内存,从内存到线路,一些人认为这不是真正的零拷贝。从操作系统的角度来看这就是零拷贝,因为内核缓冲区之间的数据没有重复。当使用零拷贝除了避免复制发生外,还有其它操作的好处,比如更少的上下文切换,更少的CPU数据缓存污染,也不需要CPU校验和计算。

现在我们知道零拷贝是什么了,让我们写一些代码实践下理论。你可以从ww.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>需要的基本套接字操作,我们需要文件发送调用的原型定义。可以在<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协议族的套接字流。作为服务器模式的组成部分我们需要传送到客户端一些数据类型,素以我们打开数据文件。使用文件发送系统来传输数据,我们不需要读取文件实际内容并存储带我们的程序内存缓冲区。这是服务器地址:

/* 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攻击。因为系统调用只能监听到确定连接的参数修改。内核参数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);

在客户端套接字描述符建立连接后,我们开始传输数据到远程系统。我们做的只是调用文件发送系统,在Linux下通过以下原型实现:

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

开始的两个参数都是文件描述符,第三个参数指的是发送文件的起点,第四个参数是我们想传输的字节数。为了在数据传输时使用零拷贝,你需要从网卡获取内存搜集操作支持,还需要为协议提供校验能力,比如TCP或者UDP。如果你的NIC已经过期且不支持这些特性,你仍需要使用sendfile传输文件。不同点在于内核在传输之前会合并缓冲区。

常见问题

文件发送系统调用的一个问题是缺少标准实现,正如开放系统调用,文件发送的实现在Linux,Solaris或者HP-UX都不相同。这会给开发者在他们网络数据传输代码中使用零拷贝带来问题。

第二个差异是linux不支持向量传输,Solaris和HP-UX的sendfile为了消除为数据传输准备的头部信息,需要额外的参数。

前景展望

linux下实现的零拷贝还远未完成并且很可能在不久发生变化。更多的函数湖北添加。比如sendfile调用并不支持向量传输,像Samba和Apache这样的服务器必须使用设置了TCP_CORK标志实现多个sendfile调用。这个标志告诉系统下一次sendfile调用会有更多的数据通过。TCP-CORK也与TCP_NODELAY不兼容,并且在我们想要在数据前添加或附加标头时使用。这是一个完美的例子,其中向量调用将消除对当前实现所强制的多个sendfile调用和延迟的需要。

还有一个令人不快的限制是当前的sendfile不支持超过2GB的数据传输。这个大小在当前很常见,并且用这种方法复制所有同样数据令人失望。由于sendfile和mmap方法在这种场景下不适用,sendfile64会在未来内核版本中使用。

结尾

尽管有一些缺点,零复制sendfile是一个有用的功能,我希望你已经发现这篇文章的信息足以开始在你的程序中使用它.

翻译自https://www.linuxjournal.com/article/6345.

本文由【凌渡】发布于开源中国,原文链接:https://my.oschina.net/u/3779841/blog/3054262

全部评论: 0

    我有话说: