概念
由于Linux文件读写是对缓存进行操作的,虽说可以减少对磁盘的读写,但是数据拷贝时会消耗大量CPU资源.零拷贝zero-copy
这种技术可以有效地改善数据传输的性能,在内核驱动程序(比如网络堆栈或者磁盘存储驱动程序)处理 I/O 数据的时候,零拷贝技术可以在某种程度上减少甚至完全避免不必要 CPU 数据拷贝操作。
但是只能用于单纯的文件传输,无法实现文件修改后再传输
正常文件拷贝传输,是由内核拷贝到缓冲区,然后内核再把数据写到用户所给内存,再写到socket缓冲区,最后再交付
分类
- 直接 I/O:对于这种数据传输方式来说,应用程序可以直接访问硬件存储,操作系统内核只是辅助数据传输:这类零拷贝技术针对的是操作系统内核并不需要对数据进行直接处理的情况,数据可以在应用程序地址空间的缓冲区和磁盘之间直接进行传输,完全不需要 Linux 操作系统内核提供的页缓存的支持。
- 在数据传输的过程中,避免数据在操作系统内核地址空间的缓冲区和用户应用程序地址空间的缓冲区之间进行拷贝。有的时候,应用程序在数据进行传输的过程中不需要对数据进行访问,那么,将数据从 Linux 的页缓存拷贝到用户进程的缓冲区中就可以完全避免,传输的数据在页缓存中就可以得到处理。在某些特殊的情况下,这种零拷贝技术可以获得较好的性能。Linux 中提供类似的系统调用主要有
mmap()
,sendfile()
以及splice()
。 - 对数据在 Linux 的页缓存和用户进程的缓冲区之间的传输过程进行优化。该零拷贝技术侧重于灵活地处理数据在用户进程的缓冲区和操作系统的页缓存之间的拷贝操作。这种方法延续了传统的通信方式,但是更加灵活。在 Linux 中,该方法主要利用了写时复制技术
直接I/O
1 | // flag使用O_DIRECT |
针对数据传输不需要经过应用程序地址空间的零拷贝技术
mmap
1 | tmp_buf = mmap(file, len); |
应用程序调用了 mmap() 之后,数据会先通过 DMA 拷贝到操作系统内核的缓冲区中去。接着,应用程序跟操作系统共享这个缓冲区,这样,操作系统内核和应用程序存储空间就不需要再进行任何的数据拷贝操作。应用程序调用了 write() 之后,操作系统内核将数据从原来的内核缓冲区中拷贝到与 socket 相关的内核缓冲区中。接下来,数据从内核 socket 缓冲区拷贝到协议引擎中去,这是第三次数据拷贝操作
当对文件进行了内存映射,然后调用 write() 系统调用,如果此时其他的进程截断了这个文件,那么 write() 系统调用将会被总线错误信号 SIGBUS 中断,因为此时正在执行的是一个错误的存储访问。这个信号将会导致进程被杀死,解决这个问题可以通过以下这两种方法:
- 为 SIGBUS 安装一个新的信号处理器
- 第二种方法是通过文件租借锁来解决这个问题的,这种方法相对来说更好一些。我们可以通过内核对文件加读或者写的租借锁,当另外一个进程尝试对用户正在进行传输的文件进行截断的时候,内核会发送给用户一个实时信号:RT_SIGNAL_LEASE 信号,这个信号会告诉用户内核破坏了用户加在那个文件上的写或者读租借锁,那么 write() 系统调用则会被中断,并且进程会被 SIGBUS 信号杀死,返回值则是中断前写的字节数,errno 也会被设置为 success。文件租借锁需要在对文件进行内存映射之前设置。
sendfile
1 | ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count) |
sendfile() 系统调用利用 DMA 引擎将文件中的数据拷贝到操作系统内核缓冲区中,然后数据被拷贝到与 socket 相关的内核缓冲区中去。接下来,DMA 引擎将数据从内核 socket 缓冲区中拷贝到协议引擎中去。如果在用户调用 sendfile () 系统调用进行数据传输的过程中有其他进程截断了该文件,那么 sendfile () 系统调用会简单地返回给用户应用程序中断前所传输的字节数,errno 会被设置为 success。如果在调用 sendfile() 之前操作系统对文件加上了租借锁,那么 sendfile() 的操作和返回状态将会和 mmap()/write () 一样
sendfile() 系统调用不需要将数据拷贝或者映射到应用程序地址空间中去,所以 sendfile() 只是适用于应用程序地址空间不需要对所访问数据进行处理的情况。相对于 mmap() 方法来说,因为 sendfile 传输的数据没有越过用户应用程序 / 操作系统内核的边界线,所以 sendfile () 也极大地减少了存储管理的开销。
- sendfile() 局限于基于文件服务的网络应用程序
- 由于网络传输具有异步性,很难在 sendfile () 系统调用的接收端进行配对的实现方式,所以数据传输的接收端一般没有用到这种技术
- 基于性能的考虑来说,sendfile () 仍然需要有一次从文件到 socket 缓冲区的 CPU 拷贝操作,这就导致页缓存有可能会被传输的数据所污染
splice
1 |
|
在 Linux 2.6.23 中,sendfile由splice实现
调用 splice() 系统调用会导致操作系统内核从数据源 fdin 移动最多 len 个字节的数据到 fdout 中去,这个数据的移动过程只是经过操作系统内核空间,需要最少的拷贝次数。使用 splice() 系统调用需要这两个文件描述符中的一个必须是用来表示一个管道设备的。不难看出,这种设计具有局限性。
fd_in
参数是待输入描述符。如果它是一个管道文件描述符,则off_in
必须设置为NULL;如果off_in
不是一个管道文件描述符(比如socket),那么off_in
表示从输入数据流的何处开始读取数据,此时若为NULL,则从输入数据流的当前偏移位置读入fd_out/off_out
参数的含义与fd_in/off_in
相同,不过用于输出数据流len参数指定移动数据的长度
参数 flags 用于表示拷贝操作的执行方法,当前的 flags 有如下这些取值:
SPLICE_F_NONBLOCK
:splice 操作不会被阻塞。然而,如果文件描述符没有被设置为不可被阻塞方式的 I/O ,那么调用 splice 有可能仍然被阻塞。SPLICE_F_MORE
:告知操作系统内核下一个 splice 系统调用将会有更多的数据传来。SPLICE_F_MOVE
:如果输出是文件,这个值则会使得操作系统内核尝试从输入管道缓冲区直接将数据读入到输出地址空间,这个数据传输过程没有任何数据拷贝操作发生
1 | //创建管道 |
写时复制 Copy On Write(COW)
如果有多个应用程序需要同时访问同一块数据,那么可以为这些应用程序分配指向这块数据的指针,在每一个应用程序看来,它们都拥有这块数据的一份数据拷贝,当其中一个应用程序需要对自己的这份数据拷贝进行修改的时候,就需要将数据真正地拷贝到该应用程序的地址空间中去,也就是说,该应用程序拥有了一份真正的私有数据拷贝,这样做是为了避免该应用程序对这块数据做的更改被其他应用程序看到。这个过程对于应用程序来说是透明的,如果应用程序永远不会对所访问的这块数据进行任何更改,那么就永远不需要将数据拷贝到应用程序自己的地址空间中去。这也是写时复制的最主要的优点。
写时复制的实现需要 MMU 的支持,MMU 需要知晓进程地址空间中哪些特殊的页面是只读的,当需要往这些页面中写数据的时候,MMU 就会发出一个异常给操作系统内核,操作系统内核就会分配新的物理存储空间,即将被写入数据的页面需要与新的物理存储位置相对应。