Linux|Linux下零拷贝技术


Linux下零拷贝技术

  • 为什么提出零拷贝
  • sendfile函数实现的零拷贝
  • mmap函数实现的零拷贝和munmap函数
  • splice函数实现的零拷贝

为什么提出零拷贝 通常我们会有这样的需求:将本地磁盘上的一个文件通过网络发送给远端的另一个服务。在传统的I/O中,会经过下面几个步骤:
  1. 发出read()系统调用,这时处理器会从用户空间切换至内核空间;
  2. 向磁盘请求数据;
  3. 通过DMA将文件从磁盘上读取到内核空间缓冲区;
  4. read()系统调用返回,将数据从内核空间缓冲区拷贝至用户空间缓冲区,这时候处理器会从内核空间切换至用户空间;
  5. 发出write()系统调用,并将数据从用户空间缓冲区拷贝至目标socket在内核空间的缓冲区;这时候处理器会从用户空间切换至内核空间;
  6. write()调用返回;
  7. 通过DMA将数据从内核空间缓冲区中拷贝至协议引擎(该操作是独立且异步的)。
总的来说:传统的I/O操作在整个过程中将会产生4次上下文切换和4次数据拷贝。
零拷贝的实现:
Linux中提供类似的系统调用主要有sendfile()、mmap()和splice()。
sendfile函数实现的零拷贝 sendfile函数在两个文件描述符之间直接传递数据(完全在内核中操作),从而避免了内核缓冲区和用户缓冲区之间的数据拷贝,效率很高。
sendfile函数的定义如下:
#include ssize_t sendfile(int out_fd, int in_fd, off_t* offset, size_t count);

【Linux|Linux下零拷贝技术】in_fd参数是待读出内容的文件描述符,out_fd 参数是待写人内容的文件描述符。offset参数指定从读入文件流的哪个位置开始读,如果为空,则使用读人文件流默认的起始位置。count参数指定在文件描述符in_fd 和out_fd 之间传输的字节数。sendfile 成功时返回传输的字节数,失败则返回-1并设置errmo。该函数的man手册明确指出,in_fd 必须是一个支持类似mmap函数的文件描述符,即它必须指向真实的文件,不能是socket和管道;而out_fd则必须是一个socket。由此可见,sendfle 几乎是专门为在网络上传输文件而设计的。
sendfile的实现过程:
  1. 发出sendfile()系统调用,这是处理器会从用户空间切换至内核空间;
  2. 通过DMA将目标文件从磁盘上读取到内核空间缓冲区;
  3. 将数据从内核缓冲区拷贝到目标socket缓冲区;
  4. sendfile()返回,这是处理器从内核空间切换至用户空间;
  5. 通过DMA将数据从目标socket缓冲区拷贝至协议引擎。
整个过程产生了2次上下文切换和3次数据拷贝(其中2次DMA拷贝和1次CPU拷贝)。
该实现虽然减少了2次上下文切换,但仍然还有1次CPU拷贝。那这次拷贝是不是也可以省掉呢?答案是肯定的。但是需要底层操作系统的一些支持。那就是带有DMA收集功能的sendfile实现的零拷贝。
有DMA收集功能的sendfile实现的零拷贝
操作系统底层提供了带有scatter/gather的DMA来从内核空间缓冲区中将数据读取到协议引擎中。这就
意味着等待传输的数据不需要在连续存储器中,它可以分散在不同的内存位置。那这样一来,从文件中
读出的数据就不必拷贝至目标socket的缓冲区中,只需要将缓冲区描述符添加到目标socket的缓冲区
中,DMA收集操作会根据缓冲区描述符中的信息将内核空间缓冲区中的数据读取到协议引擎。这种方法不仅减少了上下文切换、还减少了由CPU参与的数据拷贝。
  1. 发出sendfile()系统调用,处理器从用户空间切换至内核空间;
  2. 通过DMA将目标数据copy至内核空间缓冲区;
  3. 将数据在内核空间缓冲区的地址和偏移量拷贝至目标socket的缓冲区;
  4. sendfile()返回,处理器从内核空间切换至用户空间。
  5. 带有scatter/gather 功能的DMA将数据直接从内核缓冲区读取到协议引擎,从而消除了CPU拷贝。
这种方法产生了2次上下文切换和2次数据拷贝。(和上面的实现比减少了第3步的数据拷贝)。
但如果先把数据从磁盘读出来后编辑一遍再发送出去,以上说的两种sendfile零拷贝则不能实现,这正是sendfile的缺点所在。
mmap函数实现的零拷贝和munmap函数 针对sendfile的缺点,Linux内核为我们提供了mmap方法。
mmap(内存映射):mmap操作提供了一种机制,让用户程序直接访问设备内存,这种机制,相比较在用户空间和内核空间互相拷贝数据,效率更高。
mmap函数用于申请一段内存空间。我们可以将这段内存作为进程间通信的共享内存,也可以将文件直接映射到其中。munmap函数则释放由mmap创建的这段内存空间。它们的定义如下:
#include void* mmap(void *start, size_t length, int prot, int flags, int fd, off_t offset); int munmap(void *start, size_t length);

start参数允许用户使用某个特定的地址作为这段内存的起始地址。如果它被设置成NULL,则系统自动分配一个地址。
length 参数指定内存段的长度。
prot参数用来设置内存段的访问权限。它可以取以下几个值的按位或:
  • PROT_READ,内存段可读;
  • PROT_WRITE,内存段可写;
  • PROT_EXEC,内存段可执行;
  • PROT_NONE,内存段不能被访问。
flags参数控制内存段内容被修改后程序的行为。它可以被设置为下表中的某些值(这里仅列出了常用的值)的按位或(其中MAP_SHARED和MAP_PRIVATE是互斥的,不能同时指定)。
常用值 含义
MAP_SHARED 在进程间共享这段内存。对该内存段的修改将反映到被映射的文件中。它提供了进程间共享内存的POSIX方法
MAP_PRIVATE 内存段为调用进程所私有。对该内存段的修改不会反映到被映射的文件中
MAP_ANONYMOUS 这段内存不是从文件映射而来的。其内容被初始化为全0。这种情况下,mmap函数的最后两个参数将被忽略
MAP_FIXED 内存段必须位于start参数指定的地址处。start 必须是内存页面大小(4096字节)的整数倍
MAP_HUGETLB 按照“大内存页面"来分配内存空间。“大内存页面”的大小可通过/proc/meminfo文件来查看
fd参数是被映射文件对应的文件描述符。它一般通过open系统调用获得。offset 参数设置从文件的何处开始映射(对于不需要读入整个文件的情况)。
mmap函数成功时返回指向目标内存区域的指针,失败则返回MAP_FAILED ((void*)-1)并设置errno。
munmap 函数成功时返回0,失败则返回-1并设置errno。
mmap实现零拷贝的过程:
  1. 发出mmap()系统调用,处理器从用户空间切换至内核空间。
  2. 通过DMA将目标数据从磁盘拷贝至内核空间缓冲区;
  3. mmap()调用返回,这时候用户程序和操作系统共享这个缓冲区,不需要再将数据从内核缓冲区
    拷贝至用户缓冲区,处理器从内核空间切换至用户空间;
  4. 用户逻辑处理;
  5. 发出write()系统调用,将数据从内核空间缓冲区拷贝至目标socket缓冲区,这时处理器从用户空间
    切换至内核空间;
  6. write()调用返回,处理器从内核空间切换至用户空间;
  7. 通过DMA将数据拷贝至协议引擎。
splice函数实现的零拷贝 sendfile只适用于将数据从文件拷贝到套接字上,限定了它的使用范围。
splice函数用于在两个文件描述符之间移动数据,也是零拷贝操作。splice 函数的定义如下:
#include ssize_t splice(int fd_in, loff_t* off_in, int fd_out, loff_t* off_out, size_t len, unsigned int flage);

fd_in参数是待输人数据的文件描述符。如果fd_in 是一个管道文件描述符,那么off_in参数必须被设置为NULL。如果fd_in 不是一个管道文件描述符(比如socket),那么off_in表示从输人数据流的何处开始读取数据。此时,若off_in被设置为NULL,则表示从输人数据流的当前偏移位置读入;若off_in 不为NULL,则它将指出具体的偏移位置。
fd_out/off_out参数的含义与fd_in/off_in 相同,不过用于输出数据流。
len 参数指定移动数据的长度;
flags参数则控制数据如何移动,它可以被设置为下表中的某些值的按位或。
常用值 含义
SPLICE_F_MOVE 如果合适的话,按整页内存移动数据。这只是给内核的一个提示。不过,因为它的实现存在BUG,所以自内核2.6.21后,它实际上没有任何效果
SPLICE_F_NONBLOCK 非阻塞的splice操作,但实际效果还会受文件描述符本身的阻塞状态的影响
SPLICE_F_MORE 给内核的一个提示:后续的splice调用将读取更多数据
SPLICE_F_GIFT 对splice没有效果
使用splice函数时,fd_in和fd_out必须至少有一个是管道文件描述符。splice函数调用成功时返回移动字节的数量。它可能返回0,表示没有数据需要移动,这发生在从管道中读取数据(fd_in是管道文件描述符)而该管道没有被写人任何数据时。splice 函数失败时返回-1并设置errno。常见的errno如下表所示。
错误 含义
EBADF 参数所指文件描述符有错
EINVAL 目标文件系统不支持splice,或者目标文件以追加方式打开,或者两个文件描述符都不是管道文件描述符,或者某个offset参数被用于不支持随机访问的设备( 比如字符设备)
ENOMEM 内存不够
ESPIPE 参数fd_in (或fd_out)是管道文件描述符,而off _in (或of _out) 不为NULL
使用splice在两个文件描述符之间传输数据,不用拷贝。但输入和输出文件描述符必须有一个是pipe。也就是说如果你需要从一个socket 传输数据到另外一个socket,是需要使用 pipe来做为中介的。 pipe buffer被抽象出来,当作 “内核缓存结构”, 一种流缓冲,可以理解成你的数据从写入 “内核流缓存”里面,然后在从一个“内核流缓存”复制到另外一个比如说socket的缓存。全部数据都是在内核空间进行。 当然你的数据复制也是不用复制,他那个pipe buffer本来就是使用page去管理缓存的,就是缓存地址加偏移地址的办法,只是Linux觉得splice的需要很像之前的pipe思想,所以splice就用这个pipe来作为“内核缓存结构”了。

    推荐阅读