You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

128 lines
14 KiB
Markdown

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

# 04 | 零拷贝:如何高效地传输文件?
你好,我是陶辉。
上一讲我们谈到当索引的大小超过内存时就会用磁盘存放索引。磁盘的读写速度远慢于内存所以才针对磁盘设计了减少读写次数的B树索引。
**磁盘是主机中最慢的硬件之一,常常是性能瓶颈,所以优化它能获得立竿见影的效果。**
因此针对磁盘的优化技术层出不穷比如零拷贝、直接IO、异步IO等等。这些优化技术为了降低操作时延、提升系统的吞吐量围绕着内核中的磁盘高速缓存也叫PageCache去减少CPU和磁盘设备的工作量。
这些磁盘优化技术和策略虽然很有效,但是理解它们并不容易。只有搞懂内核操作磁盘的流程,灵活正确地使用,才能有效地优化磁盘性能。
这一讲我们就通过解决“如何高效地传输文件”这个问题来分析下磁盘是如何工作的并且通过优化传输文件的性能带你学习现在热门的零拷贝、异步IO与直接IO这些磁盘优化技术。
## 你会如何实现文件传输?
服务器提供文件传输功能,需要将磁盘上的文件读取出来,通过网络协议发送到客户端。如果需要你自己编码实现这个文件传输功能,你会怎么实现呢?
通常你会选择最直接的方法从网络请求中找出文件在磁盘中的路径后如果这个文件比较大假设有320MB可以在内存中分配32KB的缓冲区再把文件分成一万份每份只有32KB这样从文件的起始位置读入32KB到缓冲区再通过网络API把这32KB发送到客户端。接着重复一万次直到把完整的文件都发送完毕。如下图所示
![](https://static001.geekbang.org/resource/image/65/ee/6593f66902b337ec666551fe2c6f5bee.jpg)
不过这个方案性能并不好,主要有两个原因。
首先,它至少**经历了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次内存拷贝。
![](https://static001.geekbang.org/resource/image/bf/a1/bf80b6f858d5cb49f600a28f853e89a1.jpg)
如果网卡支持SG-DMAThe Scatter-Gather Direct Memory Access技术还可以再去除Socket缓冲区的拷贝这样一共只有2次内存拷贝。
![](https://static001.geekbang.org/resource/image/0a/77/0afb2003d8aebaee763d22dda691ca77.jpg)
**实际上,这就是零拷贝技术。**
它是操作系统提供的新函数同时接收文件描述符和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方法会在磁盘寻址过程中阻塞等待导致进程无法并发地处理其他任务如下图所示
![](https://static001.geekbang.org/resource/image/9e/4e/9ef6fcb7da58a007f8f4e3e67442df4e.jpg)
异步IO异步IO既可以处理网络IO也可以处理磁盘IO这里我们只关注磁盘IO可以解决阻塞问题。它把读操作分为两部分前半部分向内核发起读请求但**不等待数据就位就立刻返回**此时进程可以并发地处理其他任务。当内核将磁盘中的数据拷贝到进程缓冲区后进程将接收到内核的通知再去处理数据这是异步IO的后半部分。如下图所示
![](https://static001.geekbang.org/resource/image/15/f3/15d33cf599d11b3188253912b21e4ef3.jpg)
从图中可以看到异步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一定不会阻塞进程吗如果阻塞了进程该如何解决呢欢迎你在留言区与大家一起探讨。
感谢阅读,如果你觉得这节课对你有一些启发,也欢迎把它分享给你的朋友。