什么是io
Linux 最经典的一句话是:「一切皆文件」
,不仅普通的文件和目录,就连块设备、管道
、socket
等,也都是统一交给文件系统管理的。
io其实是input
和output
的缩写,顾名思义就是输入和输出。而我们经常接触的文件、目录都是存储在磁盘的,相对应的就是读取磁盘的信息read()和将数据写入磁盘write()。
io类型
其实在我写这边文章时候对io的读写了解很少(我是菜鸡),大概的写数据就是从应用程序写入到磁盘,读数据就是从磁盘将数据读取应用程序,中间具体的细节根本不知道。
那么这遍文章的主要目的就是对读写io有个更细致的了解,先从io的基本类型说起。
缓存io和直接io
我们都知道磁盘 I/O 是非常慢的,所以 Linux 内核为了减少磁盘 I/O 次数,在系统调用后,会把进程内的用户数据拷贝到进程的内核空间中缓存起来,这个内核空间缓存
也就是「页缓存」
,这种io方式就叫做缓存io。
那么,根据是「否利用内核缓存
」,可以把文件 I/O 分为缓存 I/O 与直接 I/O。
直接 I/O就是不会发生内核缓存和用户空间的数据复制,而是直接经过文件系统访问磁盘。
如果你在使用文件操作类的系统调用函数时,指定了 O_DIRECT
标志,则表示使用直接 I/O。如果没有设置过,默认使用的是非直接 I/O
。
缓存io的读写过程
读过程read():
- 首先由用户空间上下文切换到内核空间,查询内核缓存是否存在。
- 如果内核空间存在查询数据,则直接将内核空间的数据copy到用户空间同时伴随着上下文切换到了用户空间,用户空间copy数据完成后读取结束。
- 如果内核空间不存在查询数据则通过文件系统获取磁盘数据并copy到内核空间,当内核空间数据准备完成后,接着将内存空间的数据在copy到用户空间同时伴随着上下文切换到用户空间,用户空间copy数据完成后读取结束。
下图为读数据时未命中内核缓存的时完整流程图:
其实图中是最传统的且没有DMA时的io模型,整个数据的传输过程,都要需要 CPU 亲自参与搬运数据的过程,而且这个过程,CPU 是不能做其他事情的。io模型的演进可以看这篇文章零拷贝。
可以发现未命中页缓存
时的缓存io的读取涉及到俩次的的上下文切换
和俩次的数据复制
;而命中页缓存的情况下设计到俩次的的上下文切换
和一次的数据复制
。
写过程write():
- 上下文由用户空间切换到内核空间上同时将用户空间数据copy到内核空间缓存上。
- 内核空间数据copy完成后,将上下文切换到用户空间并完成写入响应。
- 写到内核空间缓存上的数据会在合适的时机将缓存刷到磁盘上。
可以发现缓存io的写操作涉及到俩次的的上下文切换
和一次的数据复制
。
直接io的读写过程
- Write 操作:由于其不使用内核缓存(页缓存),所以其进行写文件,如果返回成功,数据就真的落盘了(不考虑磁盘自带的缓存);
- Read 操作:由于其不使用 page cache,每次读操作是真的从磁盘中读取,不会从文件系统的缓存中读取。
内核缓存的刷盘时机
如果用了缓存 I/O 进行写数据操作,内核什么情况下才会把缓存数据写入到磁盘?以下几种场景会触发内核缓存的数据写入磁盘:
在调用 write 的最后,当发现内核缓存的数据太多的时候,内核会把数据写到磁盘上;
内核缓存的数据的缓存时间超过某个时间时,也会把数据刷到磁盘上;
用户主动调用 sync,内核缓存会刷到磁盘上;
内存缓存(页缓存)也是占用物理内存的,当内存十分紧张,无法再分配进程时,会使用swap内存交换技术,将内核缓存的数据刷到磁盘上;
疑问?
了解了缓存io和直接io后,你可能会有个疑问:既然缓存io避免了直接读写磁盘,读写速度会比直接io快很多,那么直接io还是适用的场景吗?还是目前都适用缓存io来实现读写了?
首先明确的告诉你:有的场景只适用直接io而不适用缓存io,直接io还是有用武之地的,这里先卖个关子,具体原因继续往下看吧。
阻塞与非阻塞 I/O
先说明一点,阻塞与非阻塞都是基于上面介绍的缓存io
来开讲的。
阻塞io
先来看看阻塞 I/O,当用户程序执行 read ,线程会被阻塞,一直等到内核数据准备好,并把数据从内核缓冲区拷贝到应用程序的缓冲区中,当拷贝过程完成,read 才会返回。
线程被阻塞的过程cpu是不能执行别的任务的
,只能执行完当前线程的任务,cpu才会被释放。
上面介绍的缓存io和直接io都是属于阻塞io。
非阻塞io
非阻塞的 read 请求在数据未准备好的情况下立即返回,可以继续往下执行,此时应用程序不断轮询内核,直到数据准备好,内核将数据拷贝到应用程序缓冲区,read 调用才可以获取到结果。过程如下图:
总结
当涉及到文件 I/O 时,有两种主要的访问方式:Page Cache 和 Direct I/O(也称为裸 I/O)。Page Cache就是缓存io,Direct I/O就是直接io。
而阻塞io和非阻塞io是逻辑上的一个io,Page Cache 和 Direct I/O都属于阻塞io。
页缓存
上面介绍了那么多,终于来到正题了,页缓存。
其实上面已经介绍的过了,缓存io
读写时存储在内核空间的缓存
其实就是页缓存
,页缓存也是占用的物理内存的。
页缓存的特点
页缓存有俩个非常明显的特点:预读和合并。
预读
操作系统为基于页缓存
的读缓存机制
提供预读机制
。
举个🌰说明下什么是预读机制
:
- 比如用户线程仅仅请求读取磁盘上文件 A 的 offset 为 0-3KB 范围内的数据,由于磁盘的基本读写单位为
block(4KB)
,于是操作系统至少会读 0-4KB 的内容,这恰好可以在一个 page 中装下。 - 但是操作系统出于
局部性原理
会选择将磁盘块 offset [4KB,8KB)、[8KB,12KB) 以及 [12KB,16KB) 都加载到内存
,于是额外在内存中申请了3 个 page
;
下图中,应用程序利用 read 系统调动读取 4KB 数据,实际上内核使用 readahead 机制完成了 16KB 数据的读取。
为什么要有预读这个机制呢?
因为系统出于空间局部性原理考虑
,靠近当前被访问数据的数据,在未来很大概率会被访问到,所以如果使用的是缓存io的话会读取更多的数据加载页缓存中;
通常靠近当前的数据真的大概率会在接下来时间被访问,那么这些数据都不需要再次读取磁盘了,而是可以直接从页缓存中读取了,增加了缓存的命中率
。
预读并不是只有优点,缺点也很明显,如果小概率那些数据没有被访问到时,那么多读取的数据就是浪费,主要体现在俩方面:
- 多读取的那部分数据也是从磁盘中读取的,读io时间相对来说更长。
- 读取到数据都是存储在页缓存的,而页缓存是存储在屋里内存的,也就是说其实多读取的那部分数据是浪费内存了。
其实预读机制还会带很多问题,比如下面要介绍的预读失效
和缓存污染
。
合并io请求
即内核会将许多I/O请求暂时存储
在页缓存中,然后在适当的时机将它们合并成更大的I/O请求
,再发送到磁盘上进行读取或写入操作。这个做法的目的是为了减少磁盘的寻址操作
,从而提高I/O操作的效率。
让我通过一个具体的例子来解释:
假设你有一个程序,需要从磁盘上读取多个小文件。如果每个小文件都触发一个单独的磁盘读取请求,那么磁盘在读取完一个文件后,需要停下来,重新定位磁头位置
,然后再开始读取下一个文件。这些寻址操作会导致磁盘的机械部件运动,需要一定的时间。这种情况下,磁盘的性能可能会受到限制,因为寻址操作耗费了相当多的时间。
然而,如果操作系统使用了页缓存,它会将这些小文件的I/O请求暂时保存在缓存中,不立即发送给磁盘。当有足够多的请求积累在缓存中,操作系统会考虑将它们合并成一个大的I/O请求,然后再发送给磁盘。这个大的请求涵盖了多个小文件,这样磁盘就可以连续地读取数据,而不需要频繁的寻址操作
。因此,通过合并请求,磁盘的机械部件可以更有效地工作,从而提高了I/O操作的速度和效率。
总之,使用页缓存和合并I/O请求的方法可以减少磁盘的寻址操作,提高I/O操作的效率,从而更有效地利用硬件资源。
页缓存的优点
1.加快数据访问
如果数据能够在内存中进行缓存,那么下一次访问就不需要通过磁盘 I/O 了,直接命中内存缓存即可。
由于内存访问比磁盘访问快很多,因此加快数据访问是 Page Cache 的一大优势。
2.减少 I/O 次数,提高系统磁盘 I/O 吞吐量
得益于 Page Cache 的缓存以及预读能力,而程序又往往符合局部性原理,因此通过一次 I/O 将多个 page 装入 Page Cache 能够减少磁盘 I/O 次数, 进而提高系统磁盘 I/O 吞吐量。
页缓存的缺点
1. 占用物理内存
最直接的缺点是需要占用额外物理内存空间,物理内存在比较紧俏的时候可能会导致频繁的 swap 操作,最终导致系统的磁盘 I/O 负载的上升。
2. 应用层没有提供方便的api
另一个缺陷是对应用层并没有提供很好的管理 API,几乎是透明管理。应用层即使想优化 Page Cache 的使用策略也很难进行。因此一些应用选择在用户空间实现自己的 page 管理,而不使用 page cache,例如 MySQL InnoDB 存储引擎以 16KB 的页进行管理。
页缓存适用的场景和不适用的场景
我们知道页缓存的优点主要是三个:
- 缓存最近被访问的数据;
- 预读功能;
- 合并io请求
这三个做法,将大大提高读写磁盘的性能。
但是,在传输大文件(GB 级别的文件)的时候,PageCache 会不起作用。
这是因为如果你有很多 GB 级别文件需要传输,每当用户访问这些大文件的时候,内核就会把它们载入 PageCache 中,于是 PageCache 空间很快被这些大文件占满。
另外,由于文件太大,可能某些部分的文件数据被再次访问的概率比较低,这样就会带来 2 个问题:
- PageCache 由于长时间被大文件占据,其他「热点」的小文件可能就无法充分使用到 PageCache,于是这样磁盘读写的性能就会下降了;
- PageCache 中的大文件数据,由于没有享受到缓存带来的好处,但却耗费 DMA 多拷贝到 PageCache 一次;
所以,小文件
的传输适用于页缓存
的方式读取,而针对大文件
的传输,不应该使用 页缓存
,因为可能由于 PageCache 被大文件占据,而导致「热点」小文件无法利用到 PageCache,这样在高并发的环境下,会带来严重的性能问题。
那对于大文件的传输适合什么方式呢?
我们先来看看最初的例子,当调用 read 方法读取文件时,进程实际上会阻塞在 read 方法调用,因为要等待磁盘数据的返回,如下图:
具体过程如下:
- 当调用 read 方法时,会阻塞着,此时内核会向磁盘发起 I/O 请求,磁盘收到请求后,便会寻址,当磁盘数据准备好后,就会向内核发起 I/O 中断,告知内核磁盘数据已经准备好;
- 内核收到 I/O 中断后,就将数据从磁盘控制器缓冲区拷贝到 PageCache 里;
- 最后,内核再把 PageCache 中的数据拷贝到用户缓冲区,于是 read 调用就正常返回了。
上述是正常使用缓存io
即页缓存
的方式的io请求模型,但是对于大文件的请求就不应该用上述这种方式了。
应该使用直接io
的方法,但是直接io的方式是阻塞
的,且传输大文件时阻塞时间会更长,所以大文件的读取应该使用直接io+异步io
的方式,如下图
它把读操作分为两部分:
- 前半部分,内核向磁盘发起读请求,但是可以
不等待数据
就位就可以返回,于是进程此时可以处理
其他任务; - 后半部分,当内核将磁盘中的数据拷贝到进程缓冲区后,进程将接收到内核的通知,再去处理数据;
通常使用直接 I/O 应用场景常见的两种:
- 应用程序已经实现了磁盘数据的缓存,那么可以不需要 PageCache 再次缓存,减少额外的性能损耗。在 MySQL 数据库中,可以通过参数设置开启直接 I/O,默认是不开启;
- 传输大文件的时候,由于大文件难以命中 PageCache 缓存,而且会占满 PageCache 导致「热点」文件无法充分利用缓存,从而增大了性能开销,因此,这时应该使用直接 I/O。
疑问
问题:进程写文件(使用缓冲 IO)过程中,写一半的时候,进程发生了崩溃,已写入的数据会丢失吗?
答案:不会。
因为进程在执行 write (使用缓冲 IO)系统调用的时候,实际上是将文件数据写到了内核的 page cache,它是文件系统中用于缓存文件数据的缓冲,所以即使进程崩溃了,文件数据还是保留在内核的 page cache,我们读数据的时候,也是从内核的 page cache 读取,因此还是依然读的进程崩溃前写入的数据。
内核会找个合适的时机,将 page cache 中的数据持久化到磁盘。但是如果 page cache 里的文件数据,在持久化到磁盘化到磁盘之前,系统发生了崩溃,那这部分数据就会丢失了。
当然, 我们也可以在程序里调用 fsync 函数,在写文文件的时候,立刻将文件数据持久化到磁盘,这样就可以解决系统崩溃导致的文件数据丢失的问题。
页缓存常见的问题
上面的介绍大致了解了页缓存其实就是内核空间的缓存,也了解它的特点以及优缺点。
我们知道页缓存其实是存储在物理内存的,那么页缓存资源就是有限的,所以你知道它的淘汰策略是什么吗?
上面有说由于页缓存预读会多读取更多的磁盘数据到页缓存中,如果这部分数据在后面的时间里没有被读取,那这部分数据岂不是占用了多余的内存资源吗?
上面有说大文件不不适合使用页缓存来读取,因为会占用大量的页缓存,挤到原来的热点数据。如果仍然使用页缓存来读取大文件该怎么解决这个问题呢?
带着上面这个几个疑问,继续往下看。
在应用程序读取文件的数据的时候,Linux 操作系统是会对读取的文件数据进行缓存的,会缓存在内核空间,该缓存称为
页缓存
。
由于操作系统的页缓存对于用户空间没有方便的api,所以MySQL Innodb 存储引擎设计了在用户空间
的“页缓存“:Buffer Pool
。
传统 LRU 是如何管理内存数据的?
Linux 的 Page Cache 和 MySQL 的 Buffer Pool 的大小是有限
的,并不能无限的缓存
数据,对于一些频繁访问的数据我们希望可以一直留在内存中,而一些很少访问的数据希望可以在某些时机可以淘汰
掉,从而保证内存不会因为满了而导致无法再缓存新的数据,同时还能保证常用数据留在内存中。
要实现这个,最容易想到的就是 LRU(Least recently used)算法。
LRU 算法一般是用「链表」作为数据结构来实现的,链表头部的数据是最近使用的,而链表末尾的数据是最久没被使用的。那么,当空间不够了,就淘汰最久没被使用的节点,也就是链表末尾的数据,从而腾出内存空间。
传统的 LRU 算法的实现思路是这样的:
- 当访问的页在内存里,就直接把该页对应的 LRU 链表节点移动到链表的头部。
- 当访问的页不在内存里,除了要把该页放入到 LRU 链表的头部,还要淘汰 LRU 链表末尾的页。
比如下图,假设 LRU 链表长度为 5,LRU 链表从左到右有编号为 1,2,3,4,5 的页。
如果访问了 3 号页,因为 3 号页已经在内存了,所以把 3 号页移动到链表头部即可,表示最近被访问了。
而如果接下来,访问了 8 号页,因为 8 号页不在内存里,且 LRU 链表长度为 5,所以必须要淘汰数据,以腾出内存空间来缓存 8 号页,于是就会淘汰末尾的 5 号页,然后再将 8 号页加入到头部。
传统的 LRU 算法并没有被 Linux 和 MySQL 使用,因为传统的 LRU 算法无法避免下面这两个问题:
- 预读失效导致缓存命中率下降;
- 缓存污染导致缓存命中率下降;
那么linux操作系统和mysql的buffer pool是怎么改造lru的淘汰策略的呢?继续往下看
预读失效
我们知道页缓存会根据局部性原理,将访问数据的周围数据也会加载到页缓存中,因为大概率在接下来的时间周围数据也是会被访问到的。
如果这些周围数据,也就是被提前加载进来的页,并没有被访问,相当于这个预读工作是白做了,这个就是预读失效
。
如果使用传统的 LRU 算法,就会把「预读页」放到 LRU 链表头部,而当内存空间不够的时候,还需要把末尾的页淘汰掉。
如果这些「预读页」如果一直不会被访问到,就会出现一个很奇怪的问题,不会被访问的预读页却占用了 LRU 链表前排的位置,而末尾淘汰的页,可能是热点数据,这样就大大降低了缓存命中率 。
我们不能因为害怕预读失效,而将预读机制去掉,大部分情况下,空间局部性原理还是成立的
。
linux操作系统是怎么避免预读失效?
Linux 操作系统实现两个了 LRU 链表:活跃 LRU 链表
(active_list)和非活跃 LRU 链表
(inactive_list);
-
active list
活跃内存页链表,这里存放的是最近被访问过(活跃)的内存页; -
inactive list
不活跃内存页链表,这里存放的是很少被访问(非活跃)的内存页;
有了这两个 LRU 链表后,预读页就只需要加入到 inactive list 区域的头部,当页被真正访问的时候,才将页插入 active list 的头部。如果预读的页一直没有被访问,就会从 inactive list 移除,这样就不会影响 active list 中的热点数据。
接下来,给大家举个例子。
-
假设 active list 和 inactive list 的长度为 5,目前内存中已经有如下 10 个页:
- 现在有个编号为 20 的页被预读了,
这个页只会被插入到 inactive list 的头部,而不会插入到active list列表内
,然后 inactive list 末尾的页(10号)会被淘汰掉。
即使编号为 20 的预读页一直不会被访问,它也没有占用到 active list 的位置,这样就不会将active list中的热点数据给挤出去
,造成热点数据命中率下降。
- 如果 20 号页被预读后,立刻被访问了,那么就会将它插入到
active list
的头部,active list
末尾的页(5号),会被降级到 inactive list
,作为 inactive list 的头部,这个过程并不会有数据被淘汰。
可以看出:
- 预读的数据只有在真正被访问时才会加载到active list列表。
- active list中的尾部数据被淘汰时会降级到inactive list的头部。
mysql是怎么避免预读失效?
MySQL 的 Innodb 存储引擎的buffer pool
是在一个 LRU
链表上划分来 2 个区域
,young
区域 和 old
区域。
这俩种改进方式,其是都是将数据分为了冷数据
和热数据
,然后分别进行 LRU 算法。区别是操作系统的页缓存的是用俩条炼焦来区分冷热数据,而mysql的buffer poll是使用一个链表划分出俩个区域来进行区分冷热数据的。
young 区域在 LRU 链表的前半部分,old 区域则是在后半部分,这两个区域都有各自的头和尾节点,如下图:
young 区域与 old 区域在 LRU 链表中的占比关系
并不是一比一
的关系,而是 63:37
(默认比例)的关系。
划分这两个区域后,预读的页就只需要加入到old 区域的头部
,当页被真正访问的时候,才将页插入young 区域的头部
。如果预读的页一直没有被访问,就会从 old 区域移除,这样就不会影响 young 区域中的热点数据。这个是和页缓存的操作逻辑是一致的。
接下来,给大家举个例子。
-
假设有一个长度为 10 的 LRU 链表,其中 young 区域占比 70 %,old 区域占比 30 %。
-
现在有个编号为 20 的页被预读了,这个页只会被插入到 old 区域头部,而 old 区域末尾的页(10号)会被淘汰掉。
-
如果 20 号页被预读后,立刻被访问了,那么就会将它插入到 young 区域的头部,young 区域末尾的页(7号),会被挤到 old 区域,作为 old 区域的头部,这个过程并不会有页被淘汰。
缓存污染
虽然 Linux (实现两个 LRU 链表)和 MySQL (划分两个区域)通过改进传统的 LRU 数据结构,避免了预读失效带来的影响。
但是如果还是使用「只要数据被访问一次,就将数据加入到活跃 LRU 链表头部(或者 young 区域)」
这种方式的话,那么还存在缓存污染
的问题。
当我们在批量读取数据或者读取大文件的时候,由于数据被访问了一次
,这些大量数据都会被加入到「活跃 LRU 链表」或者 young 区域)
里,然后之前缓存在活跃 LRU 链表(或者 young 区域)里的热点数据全部都被淘汰了
,如果这些大量的数据在很长一段时间都不会被访问的话,那么整个活跃 LRU 链表(或者 young 区域)就被污染了。
缓存污染会带来什么问题?
缓存污染带来的影响就是很致命
的,当被挤出去的这些热数据又被再次访问的时候,由于缓存未命中
,就会产生大量
的磁盘 I/O,系统性能就会急剧
下降。
将只访问了一次的大量数据就加入到活跃 LRU 链表」或者 young 区域,而将频繁访问的热点数据挤出活跃 LRU 链表」或者 young 区域,显然是不合理的,也是完全不可接受的。
怎么避免缓存污染造成的影响?
前面的 LRU 算法只要数据被访问一次,就将数据加入活跃 LRU 链表(或者 young 区域),这种 LRU 算法进入活跃 LRU 链表的门槛太低了!正式因为门槛太低,才导致在发生缓存污染的时候,很容就将原本在活跃 LRU 链表里的热点数据淘汰了。
所以,只要我们提高进入到活跃 LRU 链表(或者 young 区域)的门槛,就能有效地保证活跃 LRU 链表(或者 young 区域)里的热点数据不会被轻易替换掉。
Linux 操作系统和 MySQL Innodb 存储引擎分别是这样提高门槛的:
- Linux 操作系统:在内存页被
第一次
访问你的时候是在加载到inactive list
,在被访问第二次
的时候,才将页从inactive list
升级到active list
里。 - MySQL Innodb:在内存页被
第一次
访问你的时候是在加载到old区域
,再第二次
被访问的时候,并不会马上将该页从 old 区域升级到 young 区域,因为还要进行停留在 old 区域
的时间判断:- 如果第二次的访问时间与第一次访问的时间在
1 秒内
(默认值),那么该页就不会被从 old 区域升级到 young 区域; - 如果第二次的访问时间与第一次访问的时间
超过 1 秒
,那么该页就会从 old 区域升级到 young 区域;
- 如果第二次的访问时间与第一次访问的时间在
提高了进入活跃 LRU 链表(或者 young 区域)的门槛后,就很好了避免缓存污染带来的影响。