<audio id="audio" title="04 | 零拷贝：如何高效地传输文件？" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/f6/3d/f674dd9b550efc1310ee509bd656693d.mp3"></audio>

你好，我是陶辉。

上一讲我们谈到，当索引的大小超过内存时，就会用磁盘存放索引。磁盘的读写速度远慢于内存，所以才针对磁盘设计了减少读写次数的B树索引。

**磁盘是主机中最慢的硬件之一，常常是性能瓶颈，所以优化它能获得立竿见影的效果。**

因此，针对磁盘的优化技术层出不穷，比如零拷贝、直接IO、异步IO等等。这些优化技术为了降低操作时延、提升系统的吞吐量，围绕着内核中的磁盘高速缓存（也叫PageCache），去减少CPU和磁盘设备的工作量。

这些磁盘优化技术和策略虽然很有效，但是理解它们并不容易。只有搞懂内核操作磁盘的流程，灵活正确地使用，才能有效地优化磁盘性能。

这一讲，我们就通过解决“如何高效地传输文件”这个问题，来分析下磁盘是如何工作的，并且通过优化传输文件的性能，带你学习现在热门的零拷贝、异步IO与直接IO这些磁盘优化技术。

## 你会如何实现文件传输？

服务器提供文件传输功能，需要将磁盘上的文件读取出来，通过网络协议发送到客户端。如果需要你自己编码实现这个文件传输功能，你会怎么实现呢？

通常，你会选择最直接的方法：从网络请求中找出文件在磁盘中的路径后，如果这个文件比较大，假设有320MB，可以在内存中分配32KB的缓冲区，再把文件分成一万份，每份只有32KB，这样，从文件的起始位置读入32KB到缓冲区，再通过网络API把这32KB发送到客户端。接着重复一万次，直到把完整的文件都发送完毕。如下图所示：

<img src="https://static001.geekbang.org/resource/image/65/ee/6593f66902b337ec666551fe2c6f5bee.jpg" alt="">

不过这个方案性能并不好，主要有两个原因。

首先，它至少**经历了4万次用户态与内核态的上下文切换。**因为每处理32KB的消息，就需要一次read调用和一次write调用，每次系统调用都得先从用户态切换到内核态，等内核完成任务后，再从内核态切换回用户态。可见，每处理32KB，就有4次上下文切换，重复1万次后就有4万次切换。

上下文切换的成本并不小，虽然一次切换仅消耗几十纳秒到几微秒，但高并发服务会放大这类时间的消耗。

其次，这个方案做了**4万次内存拷贝，对320MB文件拷贝的字节数也翻了4倍，到了1280MB。**很显然，过多的内存拷贝无谓地消耗了CPU资源，降低了系统的并发处理能力。

所以要想提升传输文件的性能，需要从**降低上下文切换的频率和内存拷贝次数**两个方向入手。

## 零拷贝如何提升文件传输性能？

首先，我们来看如何降低上下文切换的频率。

为什么读取磁盘文件时，一定要做上下文切换呢？这是因为，读取磁盘或者操作网卡都由操作系统内核完成。内核负责管理系统上的所有进程，它的权限最高，工作环境与用户进程完全不同。只要我们的代码执行read或者write这样的系统调用，一定会发生2次上下文切换：首先从用户态切换到内核态，当内核执行完任务后，再切换回用户态交由进程代码执行。

因此，如果想减少上下文切换次数，就一定要减少系统调用的次数。解决方案就是把read、write两次系统调用合并成一次，在内核中完成磁盘与网卡的数据交换。

其次，我们应该考虑如何减少内存拷贝次数。

每周期中的4次内存拷贝，其中与物理设备相关的2次拷贝是必不可少的，包括：把磁盘内容拷贝到内存，以及把内存拷贝到网卡。但另外2次与用户缓冲区相关的拷贝动作都不是必需的，因为在把磁盘文件发到网络的场景中，**用户缓冲区没有必须存在的理由**。

如果内核在读取文件后，直接把PageCache中的内容拷贝到Socket缓冲区，待到网卡发送完毕后，再通知进程，这样就只有2次上下文切换，和3次内存拷贝。

<img src="https://static001.geekbang.org/resource/image/bf/a1/bf80b6f858d5cb49f600a28f853e89a1.jpg" alt="">

如果网卡支持SG-DMA（The Scatter-Gather Direct Memory Access）技术，还可以再去除Socket缓冲区的拷贝，这样一共只有2次内存拷贝。

<img src="https://static001.geekbang.org/resource/image/0a/77/0afb2003d8aebaee763d22dda691ca77.jpg" alt="">

**实际上，这就是零拷贝技术。**

它是操作系统提供的新函数，同时接收文件描述符和TCP socket作为输入参数，这样执行时就可以完全在内核态完成内存拷贝，既减少了内存拷贝次数，也降低了上下文切换次数。

而且，零拷贝取消了用户缓冲区后，不只降低了用户内存的消耗，还通过最大化利用socket缓冲区中的内存，间接地再一次减少了系统调用的次数，从而带来了大幅减少上下文切换次数的机会！

你可以回忆下，没用零拷贝时，为了传输320MB的文件，在用户缓冲区分配了32KB的内存，把文件分成1万份传送，然而，**这32KB是怎么来的？**为什么不是32MB或者32字节呢？这是因为，在没有零拷贝的情况下，我们希望内存的利用率最高。如果用户缓冲区过大，它就无法一次性把消息全拷贝给socket缓冲区；如果用户缓冲区过小，则会导致过多的read/write系统调用。

那用户缓冲区为什么不与socket缓冲区大小一致呢？这是因为，**socket缓冲区的可用空间是动态变化的**，它既用于TCP滑动窗口，也用于应用缓冲区，还受到整个系统内存的影响（我在《Web协议详解与抓包实战》第5部分课程对此有详细介绍，这里不再赘述）。尤其在长肥网络中，它的变化范围特别大。

**零拷贝使我们不必关心socket缓冲区的大小。**比如，调用零拷贝发送方法时，尽可以把发送字节数设为文件的所有未发送字节数，例如320MB，也许此时socket缓冲区大小为1.4MB，那么一次性就会发送1.4MB到客户端，而不是只有32KB。这意味着对于1.4MB的1次零拷贝，仅带来2次上下文切换，而不使用零拷贝且用户缓冲区为32KB时，经历了176次（4 * 1.4MB/32KB）上下文切换。

综合上述各种优点，**零拷贝可以把性能提升至少一倍以上！**对文章开头提到的320MB文件的传输，当socket缓冲区在1.4MB左右时，只需要4百多次上下文切换，以及4百多次内存拷贝，拷贝的数据量也仅有640MB，这样，不只请求时延会降低，处理每个请求消耗的CPU资源也会更少，从而支持更多的并发请求。

此外，零拷贝还使用了PageCache技术，通过它，零拷贝可以进一步提升性能，我们接下来看看PageCache是如何做到这一点的。

## PageCache，磁盘高速缓存

回顾上文中的几张图，你会发现，读取文件时，是先把磁盘文件拷贝到PageCache上，再拷贝到进程中。为什么这样做呢？有两个原因所致。

第一，由于磁盘比内存的速度慢许多，所以我们应该想办法把读写磁盘替换成读写内存，比如把磁盘中的数据复制到内存中，就可以用读内存替换读磁盘。但是，内存空间远比磁盘要小，内存中注定只能复制一小部分磁盘中的数据。

选择哪些数据复制到内存呢？通常，刚被访问的数据在短时间内再次被访问的概率很高（这也叫“时间局部性”原理），用PageCache缓存最近访问的数据，当空间不足时淘汰最久未被访问的缓存（即LRU算法）。读磁盘时优先到PageCache中找一找，如果数据存在便直接返回，这便大大提升了读磁盘的性能。

第二，读取磁盘数据时，需要先找到数据所在的位置，对于机械磁盘来说，就是旋转磁头到数据所在的扇区，再开始顺序读取数据。其中，旋转磁头耗时很长，为了降低它的影响，PageCache使用了**预读功能**。

也就是说，虽然read方法只读取了0-32KB的字节，但内核会把其后的32-64KB也读取到PageCache，这后32KB读取的成本很低。如果在32-64KB淘汰出PageCache前，进程读取到它了，收益就非常大。这一讲的传输文件场景中这是必然发生的。

从这两点可以看到PageCache的优点，它在90%以上场景下都会提升磁盘性能，**但在某些情况下，PageCache会不起作用，甚至由于多做了一次内存拷贝，造成性能的降低。**在这些场景中，使用了PageCache的零拷贝也会损失性能。

具体是什么场景呢？就是在传输大文件的时候。比如，你有很多GB级的文件需要传输，每当用户访问这些大文件时，内核就会把它们载入到PageCache中，这些大文件很快会把有限的PageCache占满。

然而，由于文件太大，文件中某一部分内容被再次访问到的概率其实非常低。这带来了2个问题：首先，由于PageCache长期被大文件占据，热点小文件就无法充分使用PageCache，它们读起来变慢了；其次，PageCache中的大文件没有享受到缓存的好处，但却耗费CPU（或者DMA）多拷贝到PageCache一次。

所以，高并发场景下，为了防止PageCache被大文件占满后不再对小文件产生作用，**大文件不应使用PageCache，进而也不应使用零拷贝技术处理。**

## 异步IO + 直接IO

高并发场景处理大文件时，应当使用异步IO和直接IO来替换零拷贝技术。

仍然回到本讲开头的例子，当调用read方法读取文件时，实际上read方法会在磁盘寻址过程中阻塞等待，导致进程无法并发地处理其他任务，如下图所示：

<img src="https://static001.geekbang.org/resource/image/9e/4e/9ef6fcb7da58a007f8f4e3e67442df4e.jpg" alt="">

异步IO（异步IO既可以处理网络IO，也可以处理磁盘IO，这里我们只关注磁盘IO）可以解决阻塞问题。它把读操作分为两部分，前半部分向内核发起读请求，但**不等待数据就位就立刻返回**，此时进程可以并发地处理其他任务。当内核将磁盘中的数据拷贝到进程缓冲区后，进程将接收到内核的通知，再去处理数据，这是异步IO的后半部分。如下图所示：

<img src="https://static001.geekbang.org/resource/image/15/f3/15d33cf599d11b3188253912b21e4ef3.jpg" alt="">

从图中可以看到，异步IO并没有拷贝到PageCache中，这其实是异步IO实现上的缺陷。经过PageCache的IO我们称为缓存IO，它与虚拟内存系统耦合太紧，导致异步IO从诞生起到现在都不支持缓存IO。

绕过PageCache的IO是个新物种，我们把它称为直接IO。对于磁盘，异步IO只支持直接IO。

直接IO的应用场景并不多，主要有两种：第一，应用程序已经实现了磁盘文件的缓存，不需要PageCache再次缓存，引发额外的性能消耗。比如MySQL等数据库就使用直接IO；第二，高并发下传输大文件，我们上文提到过，大文件难以命中PageCache缓存，又带来额外的内存拷贝，同时还挤占了小文件使用PageCache时需要的内存，因此，这时应该使用直接IO。

当然，直接IO也有一定的缺点。除了缓存外，内核（IO调度算法）会试图缓存尽量多的连续IO在PageCache中，最后**合并**成一个更大的IO再发给磁盘，这样可以减少磁盘的寻址操作；另外，内核也会**预读**后续的IO放在PageCache中，减少磁盘操作。直接IO绕过了PageCache，所以无法享受这些性能提升。

有了直接IO后，异步IO就可以无阻塞地读取文件了。现在，大文件由异步IO和直接IO处理，小文件则交由零拷贝处理，至于判断文件大小的阈值可以灵活配置（参见Nginx的directio指令）。

## 小结

基于用户缓冲区传输文件时，过多的内存拷贝与上下文切换次数会降低性能。零拷贝技术在内核中完成内存拷贝，天然降低了内存拷贝次数。它通过一次系统调用合并了磁盘读取与网络发送两个操作，降低了上下文切换次数。尤其是，由于拷贝在内核中完成，它可以最大化使用socket缓冲区的可用空间，从而提高了一次系统调用中处理的数据量，进一步降低了上下文切换次数。

零拷贝技术基于PageCache，而PageCache缓存了最近访问过的数据，提升了访问缓存数据的性能，同时，为了解决机械磁盘寻址慢的问题，它还协助IO调度算法实现了IO合并与预读（这也是顺序读比随机读性能好的原因），这进一步提升了零拷贝的性能。几乎所有操作系统都支持零拷贝，如果应用场景就是把文件发送到网络中，那么我们应当选择使用了零拷贝的解决方案。

不过，零拷贝有一个缺点，就是不允许进程对文件内容作一些加工再发送，比如数据压缩后再发送。另外，当PageCache引发负作用时，也不能使用零拷贝，此时可以用异步IO+直接IO替换。我们通常会设定一个文件大小阈值，针对大文件使用异步IO和直接IO，而对小文件使用零拷贝。

事实上PageCache对写操作也有很大的性能提升，因为write方法在写入内存中的PageCache后就会返回，速度非常快，由内核负责异步地把PageCache刷新到磁盘中，这里不再展开。

这一讲我们从零拷贝出发，看到了文件传输场景中内核在幕后所做的工作。这里面的性能优化技术，要么减少了磁盘的工作量（比如PageCache缓存），要么减少了CPU的工作量（比如直接IO），要么提高了内存的利用率（比如零拷贝）。你在学习其他磁盘IO优化技术时，可以延着这三个优化方向前进，看看究竟如何降低时延、提高并发能力。

## 思考题

最后，留给你一个思考题，异步IO一定不会阻塞进程吗？如果阻塞了进程，该如何解决呢？欢迎你在留言区与大家一起探讨。

感谢阅读，如果你觉得这节课对你有一些启发，也欢迎把它分享给你的朋友。
