什么是 “零拷贝” ?

如今几乎全部人都听过 Linux 下的零拷贝技术,但我常常遇到对这个问题不能深刻理解的人。因此我写了这篇文章,来深刻研究这些问题。本文经过用户态程序的角度来看零拷贝,所以我有意忽略了内核级别的实现。linux

什么是 “零拷贝” ?

为了更好的理解这个问题,咱们首先须要了解问题自己。来看一个网络服务的简单运行过程,在这个过程当中将磁盘的文件读取到缓冲区,而后经过网络发送给客户端。下面是示例代码:segmentfault

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

这个例子看起来很是简单,你可能会认为只有两次系统调用不会产生太多的系统开销。实际上并不是如此,在这两次调用以后,数据至少被拷贝了 4 次,同时还执行了不少次 用户态/内核态 的上下文切换。(实际上这个过程是很是复杂的,为了解释我尽量保持简单)为了更好的理解这个过程,请查看下图中的上下文切换,图片上部分展现上下文切换过程,下部分展现拷贝操做。缓存

两次系统调用

  1. 程序调用 read 产生一次用户态到内核态的上下文切换。DMA 模块从磁盘读取文件内容,将其拷贝到内核空间的缓冲区,完成第 1 次拷贝。
  2. 数据从内核缓冲区拷贝到用户空间缓冲区,以后系统调用 read 返回,这回致使从内核空间到用户空间的上下文切换。这个时候数据存储在用户空间的 tmp_buf 缓冲区内,能够后续的操做了。
  3. 程序调用 write 产生一次用户态到内核态的上下文切换。数据从用户空间缓冲区被拷贝到内核空间缓冲区,完成第 3 次拷贝。可是此次数据存储在一个和 socket 相关的缓冲区中,而不是第一步的缓冲区。
  4. write 调用返回,产生第 4 个上下文切换。第 4 次拷贝在 DMA 模块将数据从内核空间缓冲区传递至协议引擎的时候发生,这与咱们的代码的执行是独立且异步发生的。你可能会疑惑:“为什么要说是独立、异步?难道不是在 write 系统调用返回前数据已经被传送了?write 系统调用的返回,并不意味着传输成功——它甚至没法保证传输的开始。调用的返回,只是代表以太网驱动程序在其传输队列中有空位,并已经接受咱们的数据用于传输。可能有众多的数据排在咱们的数据以前。除非驱动程序或硬件采用优先级队列的方法,各组数据是依照FIFO的次序被传输的(上图中叉状的 DMA copy 代表这最后一次拷贝能够被延后)。

mmap

如你所见,上面的数据拷贝很是多,咱们能够减小一些重复拷贝来减小开销,提高性能。做为一名驱动程序开发人员,个人工做围绕着拥有先进特性的硬件展开。某些硬件支持彻底绕开内存,将数据直接传送给其余设备。这个特性消除了系统内存中的数据副本,所以是一种很好的选择,但并非全部的硬件都支持。此外,来自于硬盘的数据必须从新打包(地址连续)才能用于网络传输,这也引入了某些复杂性。为了减小开销,咱们能够从消除内核缓冲区与用户缓冲区之间的拷贝开始。服务器

减小数据拷贝的一种方法是将 read 调用改成 mmap。例如:网络

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

为了方便你理解,请参考下图的过程。异步

mmap调用

  1. mmap 调用致使文件内容经过 DMA 模块拷贝到内核缓冲区。而后与用户进程共享缓冲区,这样不会在内核缓冲区和用户空间之间产生任何拷贝。
  2. write 调用致使内核将数据从原始内核缓冲区拷贝到与 socket 关联的内核缓冲区中。
  3. 第 3 次数据拷贝发生在 DMA 模块将数据从 socket 缓冲区传递给协议引擎时。

经过调用 mmap 而不是 read,咱们已经将内核拷贝数据操做减半。当传输大量数据时,效果会很是好。然而,这种改进并不是没有代价;使用 mmap + write 方式存在一些隐藏的陷阱。当内存中作文件映射后调用 write,与此同时另外一个进程截断这个文件时。此时 write 调用的进程会收到一个 SIGBUS 中断信号,由于当前进程访问了非法内存地址。这个信号默认状况下会杀死当前进程并生成 dump 文件——而这对于网络服务器程序而言不是最指望的操做。有两种方式可用于解决该问题:socket

第一种方法是处理收到的 SIGBUS 信号,而后在处理程序中简单地调用 return。经过这样作,write 调用会返回它在被中断以前写入的字节数,而且将全局变量 errno 设置为成功。我认为这是一个治标不治本的解决方案。由于收到 SIGBUS 信号表示程序发生了严重的错误,我不推荐使用它做为解决方案。tcp

第二种方式应用了文件租借(在Microsoft Windows系统中被称为“机会锁”)。这才是解劝前面问题的正确方式。经过对文件描述符执行租借,能够同内核就某个特定文件达成租约。从内核能够得到读/写租约。当另一个进程试图将你正在传输的文件截断时,内核会向你的进程发送实时信号——RT_SIGNAL_LEASE。该信号通知你的进程,内核即将终止在该文件上你曾得到的租约。这样,在write调用访问非法内存地址、并被随后接收到的SIGBUS信号杀死以前,write系统调用就被RT_SIGNAL_LEASE信号中断了。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;
}

在对文件进行映射前,应该先得到租约,并在结束 write 操做后结束租约。这是经过在 fcntl 调用中指定租约类型为 F_UNLCK 来实现的。编码

Sendfile

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

sendfile(socket, file, len);

一样的,为了理解起来方便,能够看下图的调用过程。

sendfile代替读写

  1. sendfile 调用会使得文件内容经过 DMA 模块拷贝到内核缓冲区。而后,内核将数据拷贝到与 socket 关联的内核缓冲区中。
  2. 第 3 次拷贝发生在 DMA 模块将数据从内核 socket 缓冲区传递到协议引擎时。

你可能想问当咱们使用 sendfile 调用传输文件时有另外一个进程截断会发生什么?若是咱们没有注册任何信号处理程序,sendfile 调用只会返回它在被中断以前传输的字节数,而且全局变量 errno 被设置为成功。

可是,若是咱们在调用 sendfile 以前从内核得到了文件租约,那么行为和返回状态彻底相同。咱们会在sendfile 调用返回以前收到一个 RT_SIGNAL_LEASE 信号。

到目前为止,咱们已经可以避免让内核产生屡次拷贝,但咱们还有一次拷贝。这能够避免吗?固然,在硬件的帮助下。为了不内核完成的全部数据拷贝,咱们须要一个支持收集操做的网络接口。这仅仅意味着等待传输的数据不须要在内存中;它能够分散在各类存储位置。在内核 2.4 版本中,修改了 socket 缓冲区描述符以适应这些要求 - 在 Linux 下称为零拷贝。这种方法不只减小了多个上下文切换,还避免了处理器完成的数据拷贝。对于用户的程序不用作什么修改,因此代码仍然以下所示:

sendfile(socket, file, len);

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

sendfile代替读写

  1. sendfile 调用会致使文件内容经过 DMA 模块拷贝到内核缓冲区。
  2. 没有数据被复制到 socket 缓冲区。相反,只有关于数据的位置和长度信息的描述符被附加到 socket 缓冲区。DMA 模块将数据直接从内核缓冲区传递到协议引擎,从而避免了剩余的最终拷贝。

由于数据实际上仍然是从磁盘复制到内存,从内存复制到总线,因此有人可能会认为这不是真正的零拷贝。但从操做系统的角度来看,这是零拷贝,由于内核缓冲区之间的数据不会产生多余的拷贝。使用零拷贝时,除了避免拷贝外,还能够得到其余性能优点,好比更少的上下文切换,更少的 CPU 高速缓存污染以及不会产生 CPU 校验和计算。

如今咱们知道了什么是零拷贝,把前面的理论经过编码来实践。你能够从 http://www.xalien.org/article... 下载源码。解压源码须要执行 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 */

除了 socket 操做须要的头文件 <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 协议族的流套接字。做为在服务端运行的一部分,咱们须要某种类型的数据传输到客户端,因此打开咱们的数据文件(data.bin)。因为咱们使用 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,选择这个端口号是由于它是一个容许访问的端口范围。

下面是服务端执行的代码分支:

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

做为服务端,咱们须要为 socket 描述符分配一个地址。这是经过系统调用 bind 实现的,它为 socket 描述符(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 调用从挂起链接队列上的第一个链接请求建立一个新的 socket 链接。调用的返回值是新建立的链接的描述符; socket 如今能够进行读、写或轮询/select 了:

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

在客户端 socket 描述符上创建链接,咱们能够开始将数据传输到远端。经过 sendfile 调用来实现,该调用是在 Linux 下经过如下方式原型化的:

extern ssize_t
sendfile (int __out_fd, int __in_fd, off_t *offset,
          size_t __count) __THROW;
  • 前两个参数是文件描述符。
  • 第 3 个参数指向 sendfile 开始发送数据的偏移量。
  • 第四个参数是咱们要传输的字节数。

为了使 sendfile 传输使用零拷贝功能,你须要从网卡得到内存收集操做支持。还须要实现校验和的协议的校验和功能,经过 TCP 或 UDP。若是你的 NIC 已过期不支持这些功能,你也可使用 sendfile 来传输文件,不一样之处在于内核会在传输以前合并缓冲区。

移植性问题

一般,sendfile 系统调用的一个问题是缺乏标准实现,就像开放系统调用同样。Linux、Solaris 或 HP-UX 中 的 Sendfile 实现彻底不一样。这对于想经过代码实现零拷贝的开发人员而言是个问题。

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

第二个区别是 Linux 没有实现向量传输。Solaris sendfile 和 HP-UX sendfile 有一些扩展参数,能够避免与正在传输的数据添加头部的开销。

展望

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

当前 sendfile 中一个至关使人不快的限制是它在传输大于2GB的文件时没法使用。如此大小的文件在今天并不罕见,而且在出路时复制全部数据至关使人失望。由于在这种状况下sendfile和mmap方法都不可用,因此sendfile64在将来的内核版本中会很是方便。

总结

尽管有一些缺点,不过经过 sendfile 来实现零拷贝也颇有用,我但愿你在阅读本文后能够开始在你的程序中使用它。若是想对这个主题有更深刻的兴趣,请留意个人第二篇文章,标题为 “零拷贝 - 内核态分析”,我将在零拷贝的内核内部挖掘更多内容。

英文原文: http://www.linuxjournal.com/article/6345

本文由博客一文多发平台 OpenWrite 发布!
相关文章
相关标签/搜索