参考内容:
Two new system calls: splice() and sync_file_range()
Linux 中的零拷贝技术1
Linux 中的零拷贝技术2
Zero Copy I: User-Mode Perspective
Linux man-pages splice()
Nginx AIO 机制与 sendfile 机制
sendfile 适用场景
扯淡 Nginx 的 sendfile 零拷贝的概念
浅析 Linux 中的零拷贝技术
Linux man-pages sendfile
今天在看 Nginx 配置的时候,看到了一个sendfile
配置项,它可以配置在http、server、location
三个块中,出于好奇就去查了一下sendfile
的作用。
文件下载是服务器的基本功能,其基本流程就是循环的从磁盘读取文件内容到缓冲区,再将缓冲区内容发送到socket
文件,程序员基本都会写出类似下面看起来比较高效的程序。
while((n = read(diskfd, buf, BUF_SIZE)) > 0)
write(sockfd, buf , n);
上面程序中我们使用了read
和write
两个系统调用,看起来也已经没有什么优化空间了。这里的read
和write
屏蔽了系统内部的操作,我们并不知道操作系统做了什么,现实情况却是由于 Linux 的 I/O 操作默认是缓冲 I/O,上面的程序发生了多次不必要的数据拷贝与上下文切换。
上述两行代码执行流程大致可以描述如下:
- 系统调用
read
产生一个上下文切换,从用户态切换到内核态; - DMA 执行拷贝(现在都是 DMA 了吧!),把文件数据拷贝到内核缓冲区;
- 文件数据从内核缓冲区拷贝到用户缓冲区;
read
调用返回,从内核态切换为用户态;- 系统调用
write
产生一个上下文切换,从用户态切换到内核态; - 把步骤 3 读到的数据从用户缓冲区拷贝到 Socket 缓冲区;
- 系统调用
write
返回,从内核态切换到用户态; - DMA 从 Socket 缓冲区把数据拷贝到协议栈。
可以看到两行程序共发生了 4 次拷贝和 4 次上下文切换,其中 DMA 进行的数据拷贝不需要 CPU 访问数据,所以整个过程需要 CPU 访问两次数据。很明显中间有些拷贝和上下文切换是不需要的,sendfile
就是来解决这个问题的,它是从 2.1 版本内核开始引入的,这里放个 2.6 版本的源码。
系统调用sendfile
是将in_fd
的内容发送到out_fd
,描述符out_fd
在 Linux 2.6.33 之前,必须指向套接字文件,自 2.6.33 开始,out_fd
可以是任何文件;in_fd
只能是支持mmap
的文件(mmap
是一种内存映射方法,在被调用进程的虚拟地址空间中创建一个新的指定文件的映射)。
所以当 Nginx 是一个静态服务器时,开启sendfile
配置项是可以大大提高 Nginx 性能的,但是当把 Nginx 作为一个反向代理服务器时,sendfile
则没有什么用,因为当 Nginx 时反向代理服务器时,in_fd
就是一个套接字,这不符合sendfile
的参数要求。
可以看到现在我们只需要一次拷贝就可以完成功能了,但是能否把这一次拷贝也省略掉呢?我们可以借助硬件来实现,仅仅需要把缓冲区描述符和文件长度传过去,这样 DMA 直接将缓冲区的数据打包发送到网络中就可以了。
这样就实现了零拷贝技术,需要注意的是这里所说的零拷贝是相对操作系统而言的,即在内核空间不存在冗余数据。数据的实际走向是从硬盘到内存,再从内存到设备。
Nginx 中还有一个aio
配置,它的作用是启用内核级别的异步 I/O 功能,要使aio
生效需要将directio
开启(directio
对大文件的读取速度有优化作用),aio
很适合大文件的传送。需要注意的是sendfile
和aio
是互斥的,不可同时兼得二者,因此我们可以设置一个文件大小限制,超过该阀值使用aio
,低于该阀值使用sendfile
。
location /video/ {
sendfile on;
sendfile_max_chunk 256k;
aio threads;
directio 512k;
output_buffers 1 128k;
}
上面已经提到了零拷贝技术,它可以有效的改善数据传输的性能,但是由于存储体系结构非常复杂,而且网络协议栈有时需要对数据进行必要的处理,所以零拷贝技术有可能会产生很多负面影响,甚至会导致零拷贝技术自身的优点完全丧失。
零拷贝就是一种避免 CPU 将一块存储拷贝到另一块存储的技术。它可以减少数据拷贝和共享总线操作的次数,消除传输数据在存储器之间不必要的中间拷贝次数,从而有效的提高数据传输效率,而且零拷贝技术也减少了内核态与用户态之间切换所带来的开销。进行大量的数据拷贝操作是一件简单的任务,从操作系统的角度来看,如果 CPU 一直被占用着去执行这项简单的任务,是极其浪费资源的。如果是高速网络环境下,很可能就出现这样的场景。
零拷贝技术分类
现在的零拷贝技术种类很多,也并没有一个适合于所有场景的零拷贝零拷贝技术,概括起来总共有下面几种:
-
直接 I/O:对于这种数据传输方式来说,应用程序可以直接访问硬件存储,操作系统只是辅助数据传输,这类零拷贝技术可以让数据在应用程序空间和磁盘之间直接传输,不需要操作系统提供的页缓存支持。关于直接 I/O 可以参看Linux 中直接 I/O 机制的介绍。
-
避免数据在内核态与用户态之间传输:在一些场景中,应用程序在数据进行传输的过程中不需要对数据进行访问,那么将数据从页缓存拷贝到用户进程的缓冲区是完全没有必要的,Linux 中提供的类似系统调用主要有
mmap()
、sendfile()
和splice()
。 -
对数据在页缓存和用户进程之间的传输进行优化:这类零拷贝技术侧重于灵活地处理数据在用户进程的缓冲区和操作系统页缓存之间的拷贝操作,此类方法延续了传统的通信方式,但是更加灵活。在 Linux 中主要利用了「写时复制」技术。
前两类方法的目的主要是为了避免在用户态和内核态的缓冲区间拷贝数据,第三类方法则是对数据传输本身进行优化。我们知道硬件和软件之间可以通过 DMA 来解放 CPU,但是在用户空间和内核空间并没有这种工具,所以此类方法主要是改善数据在用户地址空间和操作系统内核地址空间之间传递的效率。
避免在内核与用户空间拷贝
Linux 主要提供了mmap()
、sendfile()
、splice()
三个系统调用来避免数据在内核空间与用户空间进行不必要的拷贝,在Nginx 文件操作优化对sendfile()
已经做了比较详细的介绍了,这里就不再赘述了,下面主要介绍mmap()
和splice()
。
mmap()
当调用mmap()
之后,数据会先通过 DMA 拷贝到操作系统的缓冲区,然后应用程序和操作系统共享这个缓冲区,这样用户空间与内核空间就不需要任何数据拷贝了,当大量数据需要传输的时候,这样做就会有一个比较好的效率。
但是这种改进是需要代价的,当对文件进行了内存映射,然后调用write()
系统调用,如果此时其它进程截断了这个文件,那么write()
系统调用将会被总线错误信号SIGBUG
中断,因为此时正在存储的是一个错误的存储访问,这个信号将会导致进程被杀死。
一般可以通过文件租借锁来解决这个问题,我们可以通过内核给文件加读或者写的租借锁,当另外一个进程尝试对用户正在进行传输的文件进行截断时,内核会给用户发一个实时RT_SIGNAL_LEASE
信号,这个信号会告诉用户内核破坏了用户加在那个文件上的写或者读租借锁,write()
系统调用就会被中断,并且进程会被SIGBUS
信号杀死。需要注意的是文件租借锁需要在对文件进行内存映射之前设置。
splice()
和sendfile()
类似,splice()
也需要两个已经打开的文件描述符,并且其中的一个描述符必须是表示管道设备的描述符,它可以在操作系统地址空间中整块地移动数据,从而减少大多数数据拷贝操作。适用于可以确定数据传输路径的用户应用程序,不需要利用用户地址空间的缓冲区进行显示的数据传输操作。
splice()
不局限于sendfile()
的功能,也就是说sendfile()
是splice()
的一个子集,在 Linux 2.6.23 中,sendfile()
这种机制的实现已经没有了,但是这个 API 以及相应的功能还存在,只不过内部已经使用了splice()
这种机制来实现了。
写时复制
在某些情况下,Linux 操作系统内核中的页缓存可能会被多个应用程序所共享,操作系统有可能会将用户应用程序地址空间缓冲区中的页面映射到操作系统内核地址空间中去。如果某个应用程序想要对这共享的数据调用write()
系统调用,那么它就可能破坏内核缓冲区中的共享数据,传统的write()
系统调用并没有提供任何显示的加锁操作,Linux 中引入了写时复制这样一种技术用来保护数据。
写时复制的基本思想是如果有多个应用程序需要同时访问同一块数据,那么可以为这些应用程序分配指向这块数据的指针,在每一个应用程序看来,它们都拥有这块数据的一份数据拷贝,当其中一个应用程序需要对自己的这份数据拷贝进行修改的时候,就需要将数据真正地拷贝到该应用程序的地址空间中去,也就是说,该应用程序拥有了一份真正的私有数据拷贝,这样做是为了避免该应用程序对这块数据做的更改被其他应用程序看到。这个过程对于应用程序来说是透明的,如果应用程序永远不会对所访问的这块数据进行任何更改,那么就永远不需要将数据拷贝到应用程序自己的地址空间中去。这也是写时复制的最主要的优点。
写时复制的实现需要 MMU 的支持,MMU 需要知晓进程地址空间中哪些特殊的页面是只读的,当需要往这些页面中写数据的时候,MMU 就会发出一个异常给操作系统内核,操作系统内核就会分配新的物理存储空间,即将被写入数据的页面需要与新的物理存储位置相对应。它最大好处就是可以节约内存,不过对于操作系统内核来说,写时复制增加了其处理过程的复杂性。