磁盘
磁盘结构与数据存储方式, 数据是如何存储的,又通过怎样的方式被访问?
机械硬盘
机械硬盘主要由磁盘盘片、磁头、主轴与传动轴等组成;数据就存放在磁盘盘片中
现代硬盘寻道都是采用CHS(Cylinder Head Sector)的方式,硬盘读取数据时,读写磁头沿径向移动,移到要读取的扇区所在磁道的上方,这段时间称为寻道时间(seek time)。因读写磁头的起始位置与目标位置之间的距离不同,寻道时间也不同。磁头到达指定磁道后,然后通过盘片的旋转,使得要读取的扇区转到读写磁头的下方,这段时间称为旋转延迟时间(rotational latencytime)。然后再读写数据,读写数据也需要时间,这段时间称为传输时间(transfer time)。
- 机械磁盘的寻址方式导致机械磁盘的随机读写性能较差, 顺序读写性能远强于随机读写性能
固态硬盘
固态硬盘主要由主控芯片、闪存颗粒与缓存组成;数据就存放在闪存芯片中
通过主控芯片进行寻址, 因为是电信号方式, 没有任何物理结构, 所以寻址速度非常快且与数据存储位置无关
- 固态硬盘寻址时间非常短,随机读写性能远强于机械硬盘
- 由于缓存的存在, 固态硬盘大量读写后会出现掉速的情况
- 由于闪存芯片的物理特性, 擦写次数过多后会出现掉速甚至损坏的情况,通常固态硬盘使用寿命相较于机械硬盘更短,且在使用后期性能会出现较明显的下降
IO状态监控
如何查看系统IO状态
-
iotop
prio: io优先级, 可以通过ionice调整, 在cfq下起作用 -
iostat
cpu iowait%; -x 参数
iowait 表示在一个采样周期内有百分之几的时间属于以下情况:CPU空闲、并且有仍未完成的I/O请求; 注意,iowait可以推测系统的状态,但是不能说明此时系统一定遇到了IO瓶颈 [1] [2]
avgrq-sz 平均请求扇区的大小
avgqu-sz 是平均请求队列的长度
await 每一个IO请求的处理的平均时间(ms);这里可以理解为IO的响应时间
svctm 表示平均每次设备I/O操作的服务时间(ms); 如果svctm的值与await很接近,表示几乎没有I/O等待,磁盘性能很好,如果await的值远高于svctm的值,则表示I/O队列等待太长 (不准确,最新版本的iostat中已经删除,[1] [2]
%util 在统计时间内所有处理IO时间,除以总共统计时间;可以用来参考当前设备的负载,但是并不完全准确(参考官方的解释 [1])
-
sar
-d 参数; -
strace
查看进程系统调用, -t 参数, 耗时分析 -
lsof
FD (内存, 目录, fd号) ;-p [pid]; -i port;
通过 lsof | grep delete 方式查看被删除文件的占用情况,有时有些文件虽然被删除了但是依然有进程在使用, 导致无法释放空间
查看磁盘空间
-
df -h
;df -i
du -d[n] -h
IO、缓存和系统调用
调用open
, fwrite
时到底发生了什么?
在一个IO过程中,以下5个API/系统调用是必不可少的
Create
函数用来打开一个文件,如果该文件不存在,那么需要在磁盘上创建该文件
Open
函数用于打开一个指定的文件。如果在Open
函数中指定O_CREATE
标记,那么Open
函数同样可以实现Create
函数的功能
Clos
e函数用于释放文件句柄
Write
和Read
函数用于实现文件的读写过程
open()
几种IO方式 / 缓存对于IO效率的影响
O_SYNC
(先写缓存, 但是需要实际落盘之后才返回, 如果接下来有读请求, 可以从内存读), write-through
O_DSYNC
(D=data, 类似O_SYNC, 但是只同步数据, 不同步元数据)
O_DIRECT
(直接写盘, 不经过缓存)
O_ASYNC
(异步IO, 使用信号机制实现, 不推荐, 直接用aio_xxx)
O_NOATIME
(读取的时候不更新文件 atime(access time))
缓存相关的系统调用
sync()
全局缓存写回磁盘
fsync()
特定fd的sync()
fdatasync()
只刷数据, 不同步元数据
mount
noatime(全局不记录atime), re方式(只读), sync(同步方式)
一个IO从创建到消亡的历程
一个IO的传奇一生 这里有一篇非常好的资料,讲述了整个IO过程;
下面简单记录下自己的理解的一次常见的Linux IO过程, 想了解更详细及相关源码,非常推荐阅读上面的原文
- 进程通过各语言封装的API创建文件,实际上调用Linux系统调用
Open
,陷入到内核态 - Open系统调用陆续完成文件名解析、inode对象查找、创建file对象,最后执行特定文件对应的
file->open
函数 - [可选] 如果找不到对应的inode对象, 会尝试创建一个,并把相应的元数据写入到磁盘中
- 在内存中初始化一个描述被打开文件的对象(FD);dentry,inode之类的信息在磁盘上是永久存储的,file对象是在内存中是临时存在的,它会随着文件的创建而生成,随着文件的关闭而消亡
在Linux系统中文件类型是多种多样的,一个USB设备也是一个文件,一个普通的Word文档也是一个文件,一个RAID设备也是一个文件。
虽然他们在系统中都是文件,但是,他们的操作方式是截然不同的。USB设备可能需要采用字符设备的方式和设备驱动交互;RAID设备可能需要采用块设备的方式和设备驱动进行交互;普通Word文件需要通过cache机制进行性能优化。
所以,虽然都是文件,但是,文件表面下的这些设备是不相同的,需要采用的操作方法显然是截然不同的。作为一个通用的文件系统,如何封装不同的底层设备是需要考虑的问题。
在Linux中,为了达到这个目的,推出了VFS概念。在VFS层次对用户接口进行了统一封装,并且实现了通用的文件操作功能
- 进程调用语言API进行读写,实际上通过VFS的系统调用陷入内核态,进行内存拷贝和内存读写
正常情况下(存在IO缓存),一个IO过程会不可避免的产生拷贝的开销
- 基于调用OPEN()时的不同参数,针对文件的读写会使用/绕过PageCache
使用Cache时,如果系统崩溃,存储在内存中的IO数据可能来不及写回到磁盘前就被破坏
- 对EXT3文件写操作主要考虑两种情况,一种情况是DIRECT IO方式;另一种情况是page cache的写方式
- 7.1 [EXT3, Direct IO] Direct IO方式会直接绕过缓存处理机制,直接进入块设备层
- 7.2 [EXT3, Cache] Page Cache缓存方式是应用中经常采用的方式,性能会比Direct IO高出不少
对于每一个EXT3文件,都会在内存中维护一棵Radix tree,这棵radix tree就是用来管理来page cache页的
当IO想往磁盘上写入的时候,EXT3会查找其对应的radix tree,看是否已经存在与写入地址相匹配的page页,如果存在那么直接将数据合并到这个page 页中,并且结束一次IO过程,直接结束应用层请求,此时应用层的write
函数返回
如果被访问的地址还没有对应的page页,那么需要为访问的地址空间分配page页,并且从磁盘上加载数据到page页内存,最后将这个page页加入到radix tree中
- 通过radix tree找到内存中缓存的page页,如果page页不存在,重新分配一个
- 通过
ext3_prepare_write
处理EXT3 日志,并且如果page是一个新页的话,那么需要从磁盘读入数据,装载进page页- 将用户数据拷贝至指定的page页。最后一步将操作的Page页设置成dirty。便于writeback机制将dirty页同步到磁盘
Page cache会占用Linux的大量内存,当内存紧张的时候,需要从page cache中回收一些内存页,另外,dirty page在内存中聚合一段时间之后,需要被同步到磁盘
应该在3.0内核之前,Linux采用pdflush机制将dirty page同步到磁盘,在之后的版本中,Linux采用了writeback机制进行dirty page的刷新工作。有关于writeback机制的一些源码分析可以参考《 writeback机制源码分析》
总的来说,如果用户需要写EXT3文件时,默认采用的是writeback的cache算法,数据会首先被缓存到page页,这些page页会采用radix tree的方式管理起来,便于查找。一旦数据被写入page之后,会将该页标识成dirty。Writeback内核线程或者pdflush线程会周期性的将dirty page刷新到磁盘。如果,系统出现内存不足的情况,那么page回收线程会采用cache算法考虑回收文件系统的这些page缓存页。
- 脏页回写,每个块设备都会有一个writeback内核线程处理page cache/buffer的回写任务。当该线程被调度后,会检索对应inode的radix tree,获取所有的脏页,然后调用块设备接口将脏数据回写到磁盘
与ext3类似,块设备同样有一个Cache机制,实现逻辑与ext3也没什么不同
- 块设备处理, IO到达了块设备层之后遇到了两类块设备处理方法。如果遇到无queue块设备类型,bio马上被转发到其他底层设备;如果遇到了有queue块设备类型,bio会被压入请求队列,进行合并处理,等待unplug机制的调度处理
- 块设备的IO调度算法, 通过不同的调度算法[
noop
,deadline
,cfq
],会按照不同的调度逻辑处理收到的IO请求,详见下面块调度章节 - 经过快设备的调度之后以此处理,数据抵达SCSI协议层的,数据在那里准备打包并最终实际记录到磁盘中
- [机械硬盘] 硬件驱动改变对应柱面->磁道->扇区的磁性,将数据保存下来
- [固态硬盘] 硬件驱动通过主控芯片改变对应闪存芯片对应位置的电位高低,将数据保存下来
IO体系结构
Linux IO体系结构
[站外图片上传中...(image-38a7b-1644137945193)]
应用程序 -> API
-> 系统调用 -> VFS
-> FS(ext3, ntfs) -> (page cache)
-> Block IO (块驱动, 通用块层)
-> IO 调度
-> 块设备驱动 ->... -> 物理块设备
- VFS层:虚拟文件系统层。由于内核要跟多种文件系统打交道,而每一种文件系统所实现的数据结构和相关方法都可能不尽相同,所以,内核抽象了这一层,专门用来适配各种文件系统,并对外提供统一操作接口
- 文件系统层:不同的文件系统实现自己的操作过程,提供自己特有的特征,具体不多说了,大家愿意的话自己去看代码即可
- 页缓存层:负责针对page的缓存
- 通用块层:由于绝大多数情况的io操作是跟块设备打交道,所以Linux在此提供了一个类似vfs层的块设备操作抽象层。下层对接各种不同属性的块设备,对上提供统一的Block IO请求标准
- IO调度层:因为绝大多数的块设备都是类似磁盘这样的设备,所以有必要根据这类设备的特点以及应用的不同特点来设置一些不同的调度算法和队列。以便在不同的应用环境下有针对性的提高磁盘的读写效率,这里就是大名鼎鼎的Linux电梯所起作用的地方。针对机械硬盘的各种调度方法就是在这实现的
- 块设备驱动层:驱动层对外提供相对比较高级的设备操作接口,往往是C语言的,而下层对接设备本身的操作方法和规范
- 块设备层:这层就是具体的物理设备了,定义了各种针对设备操作方法和规范
EXT3 文件系统
Superblock 超级描述了整个文件系统的信息。为了保证可靠性,可以在每个块组中对superblock进行备份。为了避免superblock冗余过多,可以采用稀疏存储的方式,即在若干个块组中对superblock进行保存,而不需要在所有的块组中都进行备份
GDT 组描述符表 组描述符表对整个组内的数据布局进行了描述。例如,数据块位图的起始地址是多少?inode位图的起始地址是多少?inode表的起始地址是多少?块组中还有多少空闲块资源等。组描述符表在superblock的后面
数据块位图 数据块位图描述了块组内数据块的使用情况。如果该数据块已经被某个文件使用,那么位图中的对应位会被置1,否则该位为0
Inode位图 Inode位图描述了块组内inode资源使用情况。如果一个inode资源已经使用,那么对应位会被置1
Inode表(即inode资源)和数据块。这两块占据了块组内的绝大部分空间,特别是数据块资源
一个文件是由inode进行描述的。一个文件占用的数据块block是通过inode管理起来的。在inode结构中保存了直接块指针、一级间接块指针、二级间接块指针和三级间接块指针。对于一个小文件,直接可以采用直接块指针实现对文件块的访问;对于一个大文件,需要采用间接块指针实现对文件块的访问
块调度算法
/sys/class/block/${dev_name}/queue/scheduler
1. noop(无调度)
最简单的调度器。它本质上就是一个链表实现的fifo队列,并对请求进行简单的合并处理。
调度器本身并没有提供任何可以配置的参数
2. deadline(最终期限调度)
读写请求被分成了两个队列, 一个用访问地址作为索引,一个用进入时间作为索引,并且采用两种方式将这些request管理起来;
在请求处理的过程中,deadline算法会优先处理那些访问地址临近的请求,这样可以最大程度的减少磁盘抖动的可能性。
只有在有些request即将被饿死的时候,或者没有办法进行磁盘顺序化操作的时候,deadline才会放弃地址优先策略,转而处理那些即将被饿死的request
deadline算法可调整参数
read_expire: 读请求的超时时间设置(ms)。当一个读请求入队deadline的时候,其过期时间将被设置为当前时间+read_expire,并放倒fifo_list中进行排序
write_expire:写请求的超时时间设置(ms)
fifo_batch:在顺序(sort_list)请求进行处理的时候,deadline将以batch为单位进行处理。每一个batch处理的请求个数为这个参数所限制的个数。在一个batch处理的过程中,不会产生是否超时的检查,也就不会产生额外的磁盘寻道时间。这个参数可以用来平衡顺序处理和饥饿时间的矛盾,当饥饿时间需要尽可能的符合预期的时候,我们可以调小这个值,以便尽可能多的检查是否有饥饿产生并及时处理。增大这个值当然也会增大吞吐量,但是会导致处理饥饿请求的延时变长
writes_starved:这个值是在上述deadline出队处理第一步时做检查用的。用来判断当读队列不为空时,写队列的饥饿程度是否足够高,以时deadline放弃读请求的处理而处理写请求。当检查存在有写请求的时候,deadline并不会立即对写请求进行处理,而是给相关数据结构中的starved进行累计,如果这是第一次检查到有写请求进行处理,那么这个计数就为1。如果此时writes_starved值为2,则我们认为此时饥饿程度还不足够高,所以继续处理读请求。只有当starved >= writes_starved的时候,deadline才回去处理写请求。可以认为这个值是用来平衡deadline对读写请求处理优先级状态的,这个值越大,则写请求越被滞后处理,越小,写请求就越可以获得趋近于读请求的优先级
front_merges:当一个新请求进入队列的时候,如果其请求的扇区距离当前扇区很近,那么它就是可以被合并处理的。而这个合并可能有两种情况,一个是向当前位置后合并,另一种是向前合并。在某些场景下,向前合并是不必要的,那么我们就可以通过这个参数关闭向前合并。默认deadline支持向前合并,设置为0关闭
3. cfq(完全公平队列)
cfq试图给所有进程分配等同的块设备使用的时间片,进程在时间片内,可以将产生的IO请求提交给块设备进行处理,时间片结束,进程的请求将排进它自己的队列,等待下次调度的时候进行处理。这就是cfq的基本原理。
除了针对时间片进行公平队列调度外,cfq还提供了优先级支持。每个进程都可以设置一个IO优先级,cfq会根据这个优先级的设置情况作为调度时的重要参考因素。优先级首先分成三大类:实时(Real Time)、最佳效果(Best Try)、闲置(Idle),RT和BE类别中,分别又再划分了8个子优先级实现更细节的QOS需求,而IDLE只有一个子优先级
在调度一个request时,首先需要选择一个一个合适的cfq_group。Cfq调度器会为每个cfq_group分配一个时间片,当这个时间片耗尽之后,会选择下一个cfq_group。每个cfq_group都会分配一个vdisktime,并且通过该值采用红黑树对cfq_group进行排序。在调度的过程中,每次都会选择一个vdisktime最小的cfq_group进行处理。
一个cfq_group管理了7棵service tree,每棵service tree管理了需要调度处理的对象cfq_queue。因此,一旦cfq_group被选定之后,需要选择一棵service tree进行处理。这7棵service tree被分成了三大类,分别为RT、BE和IDLE。这三大类service tree的调度是按照优先级展开的
通过优先级可以很容易的选定一类Service tree。当一类service tree被选定之后,采用service time的方式选定一个合适的cfq_queue。每个Service tree是一棵红黑树,这些红黑树是按照service time进行检索的,每个cfq_queue都会维护自己的service time。分析到这里,我们知道,cfq算法通过每个cfq_group的vdisktime值来选定一个cfq_group进行服务,在处理cfq_group的过程通过优先级选择一个最需要服务的service tree。通过该Service tree得到最需要服务的cfq_queue。该过程在cfq_select_queue
函数中实现
一个cfq_queue被选定之后,后面的过程和deadline算法有点类似。在选择request的时候需要考虑每个request的延迟等待时间,选择那种等待时间最长的request进行处理。但是,考虑到磁盘抖动的问题,cfq在处理的时候也会进行顺序批量处理,即将那些在磁盘上连续的request批量处理掉
cfq调度算法的参数
back_seek_max:磁头可以向后寻址的最大范围,默认值为16M
back_seek_penalty:向后寻址的惩罚系数。这个值是跟向前寻址进行比较的
以上两个是为了防止磁头寻道发生抖动而导致寻址过慢而设置的。基本思路是这样,一个io请求到来的时候,cfq会根据其寻址位置预估一下其磁头寻道成本。首先设置一个最大值back_seek_max,对于请求所访问的扇区号在磁头后方的请求,只要寻址范围没有超过这个值,cfq会像向前寻址的请求一样处理它。然后再设置一个评估成本的系数back_seek_penalty,相对于磁头向前寻址,向后寻址的距离为1/2(1/back_seek_penalty)时,cfq认为这两个请求寻址的代价是相同。这两个参数实际上是cfq判断请求合并处理的条件限制,凡事复合这个条件的请求,都会尽量在本次请求处理的时候一起合并处理。
fifo_expire_async:设置异步请求的超时时间。同步请求和异步请求是区分不同队列处理的,cfq在调度的时候一般情况都会优先处理同步请求,之后再处理异步请求,除非异步请求符合上述合并处理的条件限制范围内。当本进程的队列被调度时,cfq会优先检查是否有异步请求超时,就是超过fifo_expire_async参数的限制。如果有,则优先发送一个超时的请求,其余请求仍然按照优先级以及扇区编号大小来处理
fifo_expire_sync:这个参数跟上面的类似,区别是用来设置同步请求的超时时间
slice_idle:参数设置了一个等待时间。这让cfq在切换cfq_queue或service tree的时候等待一段时间,目的是提高机械硬盘的吞吐量。一般情况下,来自同一个cfq_queue或者service tree的IO请求的寻址局部性更好,所以这样可以减少磁盘的寻址次数。这个值在机械硬盘上默认为非零。当然在固态硬盘或者硬RAID设备上设置这个值为非零会降低存储的效率,因为固态硬盘没有磁头寻址这个概念,所以在这样的设备上应该设置为0,关闭此功能
group_idle:这个参数也跟上一个参数类似,区别是当cfq要切换cfq_group的时候会等待一段时间。在cgroup的场景下,如果我们沿用slice_idle的方式,那么空转等待可能会在cgroup组内每个进程的cfq_queue切换时发生。这样会如果这个进程一直有请求要处理的话,那么直到这个cgroup的配额被耗尽,同组中的其它进程也可能无法被调度到。这样会导致同组中的其它进程饿死而产生IO性能瓶颈。在这种情况下,我们可以将slice_idle = 0而group_idle = 8。这样空转等待就是以cgroup为单位进行的,而不是以cfq_queue的进程为单位进行,以防止上述问题产生
low_latency:这个是用来开启或关闭cfq的低延时(low latency)模式的开关。当这个开关打开时,cfq将会根据target_latency的参数设置来对每一个进程的分片时间(slice time)进行重新计算。这将有利于对吞吐量的公平(默认是对时间片分配的公平)。关闭这个参数(设置为0)将忽略target_latency的值。这将使系统中的进程完全按照时间片方式进行IO资源分配。这个开关默认是打开的
我们已经知道cfq设计上有“空转”(idling)这个概念,目的是为了可以让连续的读写操作尽可能多的合并处理,减少磁头的寻址操作以便增大吞吐量。如果有进程总是很快的进行顺序读写,那么它将因为cfq的空转等待命中率很高而导致其它需要处理IO的进程响应速度下降,如果另一个需要调度的进程不会发出大量顺序IO行为的话,系统中不同进程IO吞吐量的表现就会很不均衡。就比如,系统内存的cache中有很多脏页要写回时,桌面又要打开一个浏览器进行操作,这时脏页写回的后台行为就很可能会大量命中空转时间,而导致浏览器的小量IO一直等待,让用户感觉浏览器运行响应速度变慢。这个low_latency主要是对这种情况进行优化的选项,当其打开时,系统会根据target_latency的配置对因为命中空转而大量占用IO吞吐量的进程进行限制,以达到不同进程IO占用的吞吐量的相对均衡。这个开关比较合适在类似桌面应用的场景下打开。
target_latency:当low_latency的值为开启状态时,cfq将根据这个值重新计算每个进程分配的IO时间片长度
quantum:这个参数用来设置每次从cfq_queue中处理多少个IO请求。在一个队列处理事件周期中,超过这个数字的IO请求将不会被处理。这个参数只对同步的请求有效
slice_sync:当一个cfq_queue队列被调度处理时,它可以被分配的处理总时间是通过这个值来作为一个计算参数指定的。公式为:time_slice = slice_sync + (slice_sync/5 * (4 - prio))
这个参数对同步请求有效
slice_async:这个值跟上一个类似,区别是对异步请求有效
slice_async_rq:这个参数用来限制在一个slice的时间范围内,一个队列最多可以处理的异步请求个数。请求被处理的最大个数还跟相关进程被设置的io优先级有关
根据以上几种io调度算法的分析,我们应该能对各种调度算法的使用场景有一些大致的思路了
从原理上看,cfq是一种比较通用的调度算法,它是一种以进程为出发点考虑的调度算法,保证大家尽量公平
deadline是一种以提高机械硬盘吞吐量为思考出发点的调度算法,尽量保证在有io请求达到最终期限的时候进行调度,非常适合业务比较单一并且IO压力比较重的业务,比如数据库。
而noop呢?其实如果我们把我们的思考对象拓展到固态硬盘,那么你就会发现,无论cfq还是deadline,都是针对机械硬盘的结构进行的队列算法调整,而这种调整对于固态硬盘来说,完全没有意义
对于固态硬盘来说,IO调度算法越复杂,额外要处理的逻辑就越多,效率就越低。所以,固态硬盘这种场景下使用noop是最好的,deadline次之,而cfq由于复杂度的原因,无疑效率最低
异步IO Linux AIO
通常在Linux上使用的IO接口是同步方式的,进程调用write
/read
之后会阻塞陷入到内核态,直到本次IO过程完成之后,才能继续执行,下面介绍的异步IO则没有这种限制,但是当前Linux异步IO尚未成熟
目前Linux aio还处于较不成熟的阶段,只能在O_DIRECT
方式下才能使用(glibc_aio),也就是无法使用默认的Page Cache机制
正常情况下,使用aio族接口的简要方式如下:
- 调用
io_setup
创建一个 I/O context 用于提交和接受 I/O 请求。 - 创建 1~n 和 I/O 请求,调用
io_submit
提交请求。 - I/O 请求执行完成,通过 DMA 直接将数据传输到 user buffer。
- 调用
io_getevents
处理已完成的 I/O。 - 重新执行第 2 步,或者确认不需要继续执行 AIO,调用
io_destroy
销毁 I/O context
当前的aio的实现库较多,glic(基于pthread),内核,libev作者(事件方式,类似libev)均有不同的实现,总体来看不算成熟
全新的异步IO io_uring
io_uring 是 2019 年 5 月发布的 Linux 5.1 加入的一个重大特性 —— Linux 下的全新的异步 I/O 支持,希望能彻底解决长期以来 Linux AIO 的各种不足
io_uring 实现异步 I/O 的方式其实是一个生产者-消费者模型:
- 用户进程生产 I/O 请求,放入提交队列(Submission Queue,后续简称 SQ)。
- 内核消费 SQ 中的 I/O 请求,完成后将结果放入完成队列(Completion Queue,后续简称 CQ)。
- 用户进程从 CQ 中收割 I/O 结果。
io_uring
具有如下的有点,但是更加不成熟
用户态和内核态共享提交队列(submission queue)和完成队列(completion queue)
IO 提交和收割可以 offload 给 Kernel,且提交和完成不需要经过系统调用
支持 Block 层的 Polling 模式
通过提前注册用户态内存地址,减少地址映射的开销
LVM & RAID
逻辑卷管理
RAID0
RAID1
RAID5(纠错)
条带化
参考资料
Linux系统性能调整:IO过程
Linux的IO调度
一个IO的传奇一生
理解inode
Linux 文件系统是怎么工作的?
Linux中Buffer cache性能问题一探究竟
Asynchronous I/O and event notification on linux
AIO 的新归宿:io_uring
Linux 文件 I/O 进化史(四):io_uring —— 全新的异步 I/O