0%

Linux - Zero-copy(零拷贝)

Linux - 用户空间与内核空间简述一文的最后我们举了一个例子,来介绍读取一个文件并通过socket发送给用户的过程中,数据在Linux中经历了怎样的传递。例子中指出数据会经过2次用户空间与内核之间的拷贝:

  • 内核读取文件数据之后,从内核空间拷贝到用户空间;
  • 调用socket的write时,将数据从用户空间拷贝到内核空间。

以及4次上下文切换:

  • 调用read读取文件时,从用户态切换到内核态;
  • 读取完成之后,切换回来;
  • 调用write发送数据时,从用户态切换到内核态;
  • 发送完成之后,切换回来。

如果你只需要实现一个简单的,不需要考虑高性能的业务,那么到这一步基本上就完成了,但是如果你做的是一个有一定性能要求的框架,比如Netty,Kafka,MQ等等,那么这里还是有优化空间的,优化点就在于本文要介绍的zero-copy(零拷贝)技术。

传统的Linux系统中,标准的I/O接口(例如read,write)都是基于数据拷贝操作的,即是I/O操作会导致数据在内核地址空间的缓冲区和用户地址空间的缓冲区之间进行拷贝,所以标准I/O也被称作缓存I/O。这样做的好处是,如果所请求的数据已经存放在内核的高速缓冲存储器中,那么就可以减少实际的I/O操作,但坏处就是数据拷贝的过程,会导致CPU开销。

1

DMA(Direct Memory Access):直接存储器访问。DMA是一种无需CPU的参与,让外设和系统内存之间进行双向数据传输的硬件机制。使用DMA可以使系统CPU从实际的I/O数据传输过程中摆脱出来,从而大大提高系统的吞吐率。

零拷贝技术,就是避免将数据从一块存储拷贝到另外一块存储的技术,从而节省拷贝带来的CPU开销,零拷贝并不是将拷贝操作完全消除掉。目前零拷贝技术主要有三种类型:

  • 直接I/O:数据直接跨过内核,在用户地址空间与I/O设备之间传递,内核只是进行必要的虚拟存储配置等辅助工作;
  • 避免内核和用户空间之间的数据拷贝:当应用程序不需要对数据进行访问时,则可以避免将数据从内核空间拷贝到用户空间;
  • copy on write:写时拷贝技术,数据不需要提前拷贝,而是当需要修改的时候再进行部分拷贝。

直接I/O

使用直接I/O的方式,可以将跨过内核,直接将I/O设备中的数据传递到用户空间中。

2

以Linux中读取文件为例,open函数的原型为:

1
int open(const char *pathname, int oflag, … /*, mode_t mode * / );

采用直接I/O时,需要在调用open的时候,传入O_DIRECT标识符,让操作系统知道接下来对文件的读写操作使用直接I/O的方式。这种类型的零拷贝多用于数据库系统中,他们更倾向于自己实现一套缓存机制,以更好的提供业务服务。

避免内核和用户空间之间的数据拷贝

mmap

Linux - 用户空间与内核空间简述一文中,我们提到用户态的进程是不能随意操作内核地址空间的,mmap也没有提供用户进程直接操作内核地址空间的能力,而是通过内存映射的机制,把内核中的部分内存空间映射到用户空间的内存,用户空间和内核空间共享一块相同的物理内存,从而提供用户进程对内存直接访问的能力。

mmap的函数原型如下:

1
2
#include <sys/mman.h>
void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset);

注意,调用mmap之后,并不会立即读取文件内容并加载到物理内存中,而是会在虚拟内存中分配地址空间,而实际要访问数据的时候,会因为内存地址对应的物理内存中没有数据,产生“缺页”异常,然后触发数据加载。

有了mmap的支持,数据从文件中读取到内核空间之后,就不会再拷贝到用户空间,当调用socket的write时,数据会直接从内核缓存中直接拷贝到Socket的缓冲区中,避免了在用户空间中多中转一次。

3

mmap虽然能减少一次数据拷贝,但是还是需要4次上下文切换:

  • 调用mmap读取文件时,从用户态切换到内核态;
  • 读取完成之后,切换回来;
  • 调用write发送数据时,从用户态切换到内核态;
  • 发送完成之后,切换回来。

sendfile

sendfile内核调用是在Linux 2.1版本开始引入的,主要功能是在内核态中,在两个文件描述符之间传递数据,避免了用户空间和内核空间之间的数据拷贝操作,函数原型为:

1
2
#include<sys/sendfile.h>
ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count);

in_fd参数是数据源的文件描述符,out_fd参数是待输出的文件描述符,in_fd必须是一个可以mmap的文件描述符,必须指向真实的文件,不能是socket等,而out_fd必须是一个socket。

使用sendfile时,数据中转与mmap类似,不经过用户空间,但是由于sendfile全程在内核态执行,因此只需要2次上下文切换:

  • 调用sendfile将文件内容通过socket发送出去时候,从用户态切换到内核态;
  • 任务完成之后,切换回来。

在Linux 2.4版本中,对sendfile进一步做了优化,之前从“文件数据缓存”到“socket缓存”时候,也需要一次拷贝,优化之后,“socket缓存”中只存储要发送的数据在“文件数据缓存”中的位置和偏移量,在实际发送时,根据位置和偏移量直接将“文件数据缓存”中的数据拷贝到网卡设备中,又省掉了一次拷贝操作。

4

splice && tee

这两个函数与sendfile的功能类似,其原型为:

1
2
3
#include <fcntl.h>
ssize_t splice(int fd_in, loff_t *off_in, int fd_out, loff_t *off_out, size_t len, unsigned int flags);
ssize_t tee(int fd_in, int fd_out, size_t len, unsigned int flags);

二者与sendfile的区别是对文件描述符的限制不同:

  • splice:fd_in和fd_out中,必须至少有一个是管道文件描述符(pipe);
  • tee:fd_in和fd_out都必须是管道文件描述符(pipe);

sockmap

上面的几种方式,都不支持从socket到socket的转发,而Linux 4.14带来的sockmap,解决了这一问题。sockmap支持在内核态,从socket到socket的数据转发。

sockmap的知识可以参考用Linux内核的瑞士军刀-eBPF实现socket转发offload

copy on write

写时拷贝技术,也算是一种零拷贝技术,其核心思想是:当有多个调用者都需要请求相同资源时,一开始资源只会有一份,多个调用者共同读取这一份资源,当某个调用者需要修改数据的时候,才会分配一块内存,将数据拷贝过去,供这个调用者使用,而其他调用者依然还是读取最原始的那份数据。每次有调用者需要修改数据时,就会重复一次拷贝流程,供调用者修改使用。

可以参考Copy-on-write(写时拷贝)

参考



-=全文完=-