零拷贝:用户态视角

在Linux系统愈来愈多的人据说过所谓的零拷贝技术,可是我常常遇到不少对这个名词没有彻底理解的人。所以,我决定写一些文章,深挖这个问题,但愿能揭开这个有用的特性。在这篇文章,咱们从用户态角度来看零拷贝,因此特地忽略大量内核细节。linux

什么是零拷贝?

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

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

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

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

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

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

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

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

消除复制的一种方法是跳过系统调用并用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.

相关文章
相关标签/搜索