你好,我是陶辉。
上一讲我们谈到,当索引的大小超过内存时,就会用磁盘存放索引。磁盘的读写速度远慢于内存,所以才针对磁盘设计了减少读写次数的B树索引。
磁盘是主机中最慢的硬件之一,常常是性能瓶颈,所以优化它能获得立竿见影的效果。
因此,针对磁盘的优化技术层出不穷,比如零拷贝、直接IO、异步IO等等。这些优化技术为了降低操作时延、提升系统的吞吐量,围绕着内核中的磁盘高速缓存(也叫PageCache),去减少CPU和磁盘设备的工作量。
这些磁盘优化技术和策略虽然很有效,但是理解它们并不容易。只有搞懂内核操作磁盘的流程,灵活正确地使用,才能有效地优化磁盘性能。
这一讲,我们就通过解决“如何高效地传输文件”这个问题,来分析下磁盘是如何工作的,并且通过优化传输文件的性能,带你学习现在热门的零拷贝、异步IO与直接IO这些磁盘优化技术。
你会如何实现文件传输?
服务器提供文件传输功能,需要将磁盘上的文件读取出来,通过网络协议发送到客户端。如果需要你自己编码实现这个文件传输功能,你会怎么实现呢?
通常,你会选择最直接的方法:从网络请求中找出文件在磁盘中的路径后,如果这个文件比较大,假设有320MB,可以在内存中分配32KB的缓冲区,再把文件分成一万份,每份只有32KB,这样,从文件的起始位置读入32KB到缓冲区,再通过网络API把这32KB发送到客户端。接着重复一万次,直到把完整的文件都发送完毕。如下图所示:

不过这个方案性能并不好,主要有两个原因。
首先,它至少经历了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次内存拷贝。

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

实际上,这就是零拷贝技术。
它是操作系统提供的新函数,同时接收文件描述符和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方法会在磁盘寻址过程中阻塞等待,导致进程无法并发地处理其他任务,如下图所示:

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

从图中可以看到,异步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一定不会阻塞进程吗?如果阻塞了进程,该如何解决呢?欢迎你在留言区与大家一起探讨。
感谢阅读,如果你觉得这节课对你有一些启发,也欢迎把它分享给你的朋友。
精选留言
2020-05-06 12:59:17
1、如果这里的异步IO指的是传统的异步阻塞IO,比如select,epoll等。这种情况下是可能阻塞进程的,在内核通知用户进程数据准备好了以后,用户进程发起read调用,此时内核拷贝数据期间,进程实际上是阻塞的。
解决方案:可以在内核完成拷贝后再通知用户进程,或者使用mmap方式,用户态和内核实际上是同一块内存就不存在拷贝了。
2、如果这里的异步IO指的是Linux内核2.6版本之后的AIO,那么不太可能阻塞进程,除非系统内核之后一直调度不到该进程(比如其他任务不断的抢占式或绑定内核运行)。
解决方案:可以使用绑定内核的运行机制阻值其他高优先级进程或中断进行抢占。
只能想到这么多了,希望看看其他大佬的答案。
2020-05-07 13:09:11
服务器提供文件传输功能,首先从磁盘读取文件,然后通过网络协议发送给客户端。最直接的办法是根据客户端的请求从磁盘上找到文件位置,然后从磁盘把部分文件(一般文件比较大时,需要对文件进行切分)读入到缓冲区,然后再通过网络把数据发送给客户端。
方法的不足
1.上下文切换
该方案在一次收发过程中涉及到 4 次用户态和内核态的上下文切换,没处理缓冲区大小的数据需要一次 read 调用和一次 write 调用,每次调用都需要从用户态切换到内核态,然后等内核态完成任务后,再切回用户态。因为文件比较大,读取次数比较多,所以上下文切换的成本不容小觑
2.多次内存拷贝
磁盘 -> PageCache
PageCache -> 用户缓冲区
用户缓冲区 -> Socket 缓冲区
Socket 缓冲区 -> 网卡
因为涉及到多次内存拷贝,消耗过多的 CPU 资源,降低系统并发处理能力
想要优化传输文件的性能,需要从降低上下文切换的频率和内存拷贝次数入手
零拷贝
降低上下文切换频率
读取磁盘文件的上下文切换是一定会做的,因为读取磁盘和操作网卡都是由操作系统内核完成。所以我们在执行 read 或 write 这种系统调用时,一定会经过 2 次上下文切换:先从用户态切换到内核态,当内核态任务完成后,再切换回用户态交由进程代码执行
所以,要想降低上下文切换频率的要点就是减少系统调用的次数。解决办法是把 read 和 write 两次系统调用合并为一次(可以通过 sendfile 一次系统调用完成),在内核态中完成磁盘与网卡的数据交换操作
减少内存拷贝次数
一次收发过程中有两次与物理设备相关的内存拷贝是必不可少的:把磁盘的数据拷贝到内存;把内存的数据拷贝到网卡。而与用户缓冲区相关的内存拷贝不是必须的
综上所述,可以在内核读取文件后,直接把 PageCache 中的数据拷贝到 socket 缓冲区中,这样就只有 2 次上下文切换 和 3 次内存拷贝。如果网卡支持 SG-DMA 技术,还可以把拷贝到 socket 缓冲区的步骤给省略掉
PageCache 磁盘高速缓存
根据时间局部性原理(刚被访问到的数据在短时间被再次访问的概率高),通常将最近访问的数据放到 PageCache 中,当空间不足时通过 LRU 算法或变种算法淘汰最久未被访问的数据
PageCache 还提供预读功能
但 PageChache 不适应传输大文件的场景,大文件容易把 PageCache 占满,而且由于文件太大,文件中某一个部分被再次访问的概率低。这样会导致大文件在 PageCache 中没有享受到缓存的优势,同时也因为 PageCache 被大文件占据,影响其他热点小文件的缓存
异步 IO 可以把读操作分为两部分,前半部分向内核发起读请求,但不用等待数据就位就返回,然后可以继续处理其他任务。当内核把磁盘中的数据拷贝到进程缓冲区后,会通知进程去处理数据。异步 IO 是不会阻塞用户进程的
对于磁盘,异步 IO 只支持直接 IO
直接 IO
直接 IO 是应用程序绕过 PageCache,即不经过内核缓冲区,直接访问磁盘中的数据,从而减少了内核缓存与用户程序之间的数据拷贝
应用场景
1.应用程序已经自己实现了磁盘文件的缓存,不需要 PageCache 再次进行缓存,引发额外的性能消耗
2.高并发下传输大文件,因为大文件难以命中 PageCache 缓存,又会影响其他热点小文件的缓存
直接 IO 的不足
因为直接 IO 不适用 PageCache 缓存,所以享受不到内核针对 PageCache 做的一些优化,比如内核会试图缓存更多的连续 IO 在 PageCache 中,然后合并成一个更大的 IO 后发给磁盘,可以减少磁盘的寻址操作;另外,内核还会进行数据的预读,把数据缓存到 PageCache 中,较少磁盘操作
方法论
1.大文件交给异步 IO 和直接 IO 处理,小文件交给零拷贝处理
文件传输的性能优化思路
1.减少磁盘的工作量(PageCache 技术)
2.提高内存的利用率(零拷贝)
3.较少 CPU 的工作量(直接 IO)
案例
Kafka 的高性能的原因就包括使用了零拷贝技术和 PageCache 缓存
2020-05-10 22:58:48
2020-05-06 12:42:12
2020-07-04 15:40:30
1. mmap
2. open("", O_DIRECT)
2020-05-08 11:18:28
一个具有大带宽时延乘积的网络也被称之为长胖网络(long fat network,简写为LFN,经常发音为“elephen”)。根据RFC 1072中的定义,如果一个网络的带宽时延乘积显著大于105比特(12500字节),该网络被认为是长肥网络。
2020-05-13 13:00:23
那是不是使用SSD这类固态硬盘(不用旋转磁头),PageCache就没有很大的影响?
2020-06-14 21:11:02
2020-05-07 11:24:36
1.在介绍零拷贝时,降低上下文切换频率提到的将 read 和 write 两次系统调用合并为一次是通过 sendfile 实现吗?
2.异步 IO 没有使用 PageCache,因为与虚拟内存系统耦合太紧,这块没有看明白
思考题
异步 IO 一定不会阻塞进程吗?如果阻塞了进程,该如何解决?
这个问题我觉得要看怎么定义异步 IO
Posix 对异步 IO 的定义为:异步 IO 操作不引起请求进程阻塞。老师在文中对异步 IO 的定义也类似,当内核把磁盘中的数据拷贝到进程缓冲区后,会通知进程去处理数据,所以按照这样的定义,异步 IO 不会阻塞进程
《UNIX网络编程》这本书把 IO 模型分成了5类
1.阻塞 IO
2.非阻塞 IO
3.IO 复用(select 和 poll)
4.信号驱动
5.异步 IO(Posix.1 的 aio 系统函数)
一篇介绍IO模型的文章
https://mp.weixin.qq.com/s/RiWEVd9IvG0Vlbz6Swn5bg
如果把信号驱动的 IO 模型也看成异步 IO,因为用户进程在调用 sigaction 后,会继续执行其他任务,这里是非阻塞的,内核会在数据准备好时通知用户进程,然后由用户进程发起 recvfrom 的系统调用,把数据从内核缓冲区拷贝到用户空间,此时用户进程是阻塞的
所以还是看如何定义异步 IO,至于那些伪异步 IO,怎么解决,我觉得解决办法就一个,就是等内核把数据准备好后,自己把数据从内核缓冲区复制到用户空间,然后通知用户进程进行数据的处理
2020-05-08 07:28:21
2020-05-06 20:12:50
2020-05-06 14:26:58
陶老师 我在异步IO图中,看到的是把磁盘数据拷贝到PageCache,是图错了嘛,如果是直接IO的话,是直接拷贝到用户进程缓存区嘛,这个过程就是绕过了内核态嘛
2021-06-24 11:10:55
----------------------------------------------------------
Scatter-gather DMA方式是与block DMA方式相对应的一种DMA方式。在DMA传输数据的过程中,要求源物理地址和目标物理地址必须是连续的。但是在某些计算机体系中,如IA架构,连续的存储器地址在物理上不一定是连续的,所以DMA传输要分成多次完成。如果在传输完一块物理上连续的数据后引起一次中断,然后再由主机进行下一块物理上连续的数据传输,那么这种方式就为block DMA方式。Scatter-gather DMA方式则不同,它使用一个链表描述物理上不连续的存储空间,然后把链表首地址告诉DMA master。DMA master在传输完一块物理连续的数据后,不用发起中断,而是根据链表来传输下一块物理上连续的数据,直到传输完毕后再发起一次中断。很显然,scatter-gather DMA方式比block DMA方式效率高。
2020-07-31 21:11:33
2020-05-06 09:42:27
2024-07-12 18:58:07
2022-05-28 12:06:09
2021-06-21 09:02:37
2021-06-03 10:32:53
2020-11-09 19:49:01
这个不能理解,我理解大文件也是能享受到pagecache带来的优势吧,毕竟是预读了一部分,即使是大文件预读的那部分,下次还是会读到吧。这里真正的影响是把pagecache沾满,影响其他文件把?