时间会让我们更好。   

零拷贝I:用户模式视角

英文原文地址:http://www.linuxjournal.com/article/6345。内容是关于 Zero Copy(零拷贝) 的详细介绍。在RocketMQ的Consumer 消费消息过程,使用了零拷贝技术。作用是即使被频繁调用,文件传输效率也很高。

 

    到目前为止,几乎每个人或多或少都听过Linux下所谓的"零拷贝"功能,但我经常遇到一些对这个概念没有充分理解的人。因此,我决定写一些文章来深入研究这个问题,希望能让大家认识到这个很有用的特性。在本文中,我们从用户模式的角度来看零拷贝,因此故意省略了内核级别的细节信息。

 

    什么是"零拷贝"?

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

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

    看起来很简单;你可能会认为只有这两个系统调用没有太大的开销。实际上,事实远非如此。在这两个调用的背后,数据就已经被拷贝至少四次,并且几乎已经执行了许多从用户态到内核态的切换。(实际上这个过程要复杂得多,但我想保持简单。)。为了更好地理解所涉及的过程,请看 图1.上半部分显示上下文切换,下半部分显示复制操作。

                   

                                      图 1. 两次系统调用中的拷贝过程

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

    第二步:将数据从内核缓冲区复制到用户缓冲区(第2次复制),并返回 read 系统调用。从调用返回导致从内核态切换回到用户态。现在数据存储在用户地址空间缓冲区中,程序可以继续往下执行。

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

    第四步: write 系统调用返回,第四次进行上下文切换-从内核态切换回用户态。当DMA引擎独立和异步地将数据从内核缓冲区传递到协议引擎的时候,会发送第4次数据复制。你可能会问自己,“你说的独立和异步地是什么意思?在调用返回之前是不是传输了数据?”调用返回,事实上并不保证传输成功;它甚至不能保证传输什么时候开始。它只是简单意味着以太网驱动程序在其队列中有自由描述符并已接受我们的数据进行传输。在我们之前可能有许多数据包排队。除非驱动程序/硬件实现优先级环或队列,否则数据以先进先出的方式传输。(图1中的叉状的 DMA copy 说明了最后一个副本可以被延迟)。

 

    正如你所看到的,实际上不需要进行大量的数据拷贝。可以减少一些拷贝,来减轻系统开销并提高性能。作为一名驱动程序开发人员,我使用过很多硬件的高级特性。某些硬件可以完全绕过主存储器并将数据直接传输到另外一个设置。这个特性消除了系统内存中的数据副本,这是一个很好事情,但并不是所有的硬件都支持它。此外还存在来自磁盘的数据必须为网络重新打包的问题,这增加了一些复杂性。为了减少系统开销,我们可以从减少内核和用户缓冲区之间的一些复制开始。

 

    减少拷贝次数的一种方法是跳过 read 系统调用,改为 mmap 系统调用。例如:

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

    为了更好地理解上面讲的过程,请看 图2.上下文切换保持不变。

                    

                                  图 2. mmap 系统调用中的拷贝过程

    第一步:mmap 系统调用,使得文件内容被DMA引擎复制到内核缓冲区中。然后与用户进程共享这块缓冲区,从而不用在内核和用户存储空间之间进行任何拷贝。

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

    第三步:第3次数据拷贝发生在DMA引擎将数据次内核套接字缓冲区传递到协议引擎时。

 

    通过使用 mmap 系统调用替换掉 read,我们减少了内核复制操作的一半。当传输大量数据的时候,这会产生相当好的效果。然而,这种提高并非没有代价的;使用 mmap + write 方法时存在着隐藏的陷阱。当内存映射文件然后调用write而另一个进程截断同一文件时,你就会掉进去。你的 write 系统调用将被总线错误信号 SIGBUS 给中断,因为你执行了错误的内存访问。该信号的默认行为是终止进程并生成 dump core 文件-这对网络服务器来说不是所期望的操作。有两种方法可以解决这个问题。

 

    第一种方法是为 SIGBUS 信号设置一个信号处理程序,然后在处理程序中调用return。通过这样做,写入系统调用返回它在被中断之前写入的字节数并且 errno 设置为成功。不过这是一个糟糕的解决方案 - 治标不治本。因为 SIGBUS 发出信号就表明该过程有严重错误,不鼓励采用这个解决方案。

    第二种方法涉及内核中的文件租用(在微软的Windows中称为"机会锁定")。这是解决此问题的正确方法。通过在文件描述符上使用租用,你可以在特定文件上使用内核。然后,你可以直接向内核申请 read 或者 write 租约。当另外一个进程试图截断你正在传输的文件时,内核会向你发送一个实时信号 - RT_SIGNAL_LEASE 信号。信号告诉你内核正在破坏该文件的 read 或者 write 租约。在程序访问无效地址并被 SIGBUS 信号杀死之前,你的 write 调用将被中断。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 操作文件之前获取租约,并在完成后中断租约。这是通过使用租约类型 F_UNLCK 调用 fcntl F_SETLEASE 来实现的。

 

    Sendfile

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

sendfile(socket, file, len);

    为了更好地连接sendfile中发生了什么,请看图3。

                    

                                  图 3. 使用Sendfile替换Read和Write

    第一步:sendfile 系统调用把文件内容从DMA引擎拷贝到内核缓冲区。然后内核将数据拷贝到与套接字关联的内核缓冲区中。

    第二步:第3次拷贝发生在DMA引擎将数据次内核套接字缓冲区传递到协议引擎时。

 

    你可以会问:如果另外一个进程截断我们使用 sendfile 系统调用传输的文件会发生什么。如果我们不注册任何信号处理程序,sendfile 调用只会返回它在被中断之前传输的字节数,并且 errno 将被设置为成功。

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

 

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

sendfile(socket, file, len);

    为了更好地理解上述过程,请看图4。

                   

                                  图 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。如果你的NIC已经过时且不支持这些功能,你仍然可以使用 sendfile 来传输文件。不同之处在于内核会在传输之前合并缓冲区。

 

    移植性问题

    通常,系统调用 sendfile 的一个问题是缺少标准实现,就像开放系统调用一样。Linux,Solaris或HP-UX中的Sendfile实现完全不同。这对于希望在其网络数据传输代码中使用零拷贝的开发人员来说是个问题。

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

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

 

    展望

    Linux下的零拷贝实现离最终实现还有点距离,并且很可能在不久的将来发生变化。会加入更多的功能。例如,sendfile调用不支持向量传输,而Samba和Apache等服务器必须使用多个sendfile调用并设置 TCP_CORK 标识。该标识告诉系统在下一个 sendfile 调用中会有更多数据通过。TCP_CORK 也与 TCP_NODELAY 不兼容,并且在我们想要在数据前添加或附加头部时使用。这是一个完美的例子,其中向量调用将消除对当前实现所强制的多个 sendfile 调用和延迟的需要。

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

 

    结论

    尽管现在的sendfile有一些缺点,但零复制sendfile确实是一个不错的功能,我希望你通过看完这篇文章后就能开始在你的程序中使用它。如果你对这个主题有更深入的兴趣,请留意我的第二篇文章-"零拷贝II: 内核视角",在这里我将深入研究零拷贝的内核内部机制。

发表新评论
选择表情