linux的同步IO操作函数: sync、fsync与fdatasync
VFS(Virtual File System)的存在使得Linux可以兼容不同的文件系统,例如ext3、ext4、xfs、ntfs等等,其不仅具有为所有的文件系统实现一个通用的 外接口的作用,还具有另一个与系统性能相关的重要作用——缓存。VFS中引入了高速磁盘缓存的机制,这属于一种软件机制,允许内核将原本存在磁盘上的某些 信息保存在RAM中,以便对这些数据的进一步访问能快速进行,而不必慢速访问磁盘本身。高速磁盘缓存可大致分为以下三种:
-
目录项高速缓存——主要存放的是描述文件系统路径名的目录项对象
-
索引节点高速缓存——主要存放的是描述磁盘索引节点的索引节点对象
-
页高速缓存——主要存放的是完整的数据页对象,每个页所包含的数据一定属于某个文件,同时,所有的文件读写操作都依赖于页高速缓存。其是Linux内核所使用的主要磁盘高速缓存。
正是由于缓存的引入,所以VFS文件系统采用了文件数据延迟写的技术,因此,如果在调用系统接口写入数据时没有使用同步写模式,那么大多数据将会先保存在缓存中,待等到满足某些条件时才将数据刷入磁盘里。
内核是如何将数据刷入磁盘的呢?
何时把脏页写入磁盘
内核不断用包含块设备数据的页填充页高速缓存。只要进程修改了数据,相应的页就被标记为脏页,即把它的PG_dirty标志位置。
Unix系统允许把脏缓冲区写入块设备的操作延迟执行,因为这种策略可以显著地提高系统的性能。对高速缓存中的页的几次写操作可能只需对相应的磁盘块进行 一次缓慢的物理更新就可以满足。此外,写操作没有读操作那么紧迫,因为进程通常是不会因为延迟写而挂起,而大部分情况都因为延迟读而挂起。
一个脏页可能直到最后一刻(即直到系统关闭时)都一直逗留在主存中。然而,从延迟写策略的局限性来看,它有两个主要的缺点:
一、如果发生了硬件错误或者电源掉电的情况,那么就无法再获得RAM的内容,因此,从系统启动以来对文件进行的很多修改就丢失了。
二、页高速缓存的大小(由此存放它所需的RAM的大小)就可要很大——至少要与所访问块设备的大小不同。
因此,在下列条件下把脏页刷新(写入)到磁盘:
-
页高速缓存变得太满,但还需要更多的页,或者脏页的数量已经太多。
-
自从页变成脏页以来已过去太长时间。
-
进程请求对块设备或者特定文件任何待定的变化都进行刷新。通过调用sync()、fsync()或者fdatasync()系统调用来实现。
缓冲区页的 引入是问题更加复杂。与每个缓冲区页相关的缓冲区首部使内核能够了解每个独立块缓冲区的状态。如果至少有一个缓冲区首部的PG_Dirty标志被置位,就 应该设置相应缓冲区页的PG_dirty标志。当内核选择要刷新的缓冲区时,它扫描相应的缓冲区首部,并只把脏块的内容有效的写到磁盘。一旦内核把缓冲区 的所有脏页刷新到磁盘,就把页的PG_dirty标志清0。
谁来把脏页写入磁盘
由pdflush内核线程负责。早期版本的Linux使用bdfllush内核线程系统地扫描页高速缓存以搜索要刷新的脏页,并且使用另一个内核线程kupdate来保证所有的页不会“脏”太长时间。Linux 2.6用一组通用内核线程pdflush替代上述两个线程。当系统没有要刷新的脏页时,pdflush线程会自动处于睡眠状态,最后由pdflush_operation()函数来唤醒。
在下面几种情况下,系统会唤醒pdflush回写脏页:
1 、定时方式:
定时机制定时唤醒pdflush内核线程,周期为/proc/sys/vm/dirty_writeback_centisecs ,单位
是(1/100)秒,每次周期性唤醒的pdflush线程并不是回写所有的脏页,而是只回写变脏时间超过
/proc/sys/vm/dirty_expire_centisecs(单位也是1/100秒)。
注意:变脏的时间是以文件的inode节点变脏的时间为基准的,也就是说如果某个inode节点是10秒前变脏的,
pdflush就认为这个inode对应的所有脏页的变脏时间都是10秒前,即使可能部分页面真正变脏的时间不到10秒,
细节可以查看内核函数wb_kupdate()。
2、 内存不足的时候:
这时并不将所有的dirty页写到磁盘,而是每次写大概1024个页面,直到空闲页面满足需求为止。
3 、写操作时发现脏页超过一定比例:
当脏页占系统内存的比例超过/proc/sys/vm/dirty_background_ratio 的时候,write系统调用会唤醒
pdflush回写dirty page,直到脏页比例低于/proc/sys/vm/dirty_background_ratio,但write系统调
用不会被阻塞,立即返回。当脏页占系统内存的比例超过/proc/sys/vm/dirty_ratio的时候, write系
统调用会被被阻塞,主动回写dirty page,直到脏页比例低于/proc/sys/vm/dirty_ratio,这一点在
2.4内核中是没有的。
4 、用户调用sync系统调用:
这是系统会唤醒pdflush直到所有的脏页都已经写到磁盘为止。
linux系统在向存储设备上写数据的时候,其实,数据没有被立即写入到物理设备上,而一般处理过程是:
-
调用fwrite()将数据写入文件缓冲区(用户态进程的buffer)。
-
进程定期调用fflush()函数之后,把文件缓冲区中的文件数据写到文件系统中,此时数据还没有被真正写入到物理介质中。
-
fsync(fileno(fp))。该函数返回后,才能保证写入到了物理介质上。即先调用fileno获得文件描述符之后,再调用fsync函数返回后才将文件写入到物理介质上。
fflush和fsync的一些总结
1.提供者fflush是libc.a中提供的方法,fsync是linux系统内核提供的系统调用。
2.原形fflush接受一个参数FILE *.fflush(FILE *);fsync接受的时一个Int型的文件描述符。fsync(int fd);
3.功能fflush:是把C库中的缓冲调用write函数写到磁盘[其实是写到内核的缓冲区]。fsync:是把内核缓冲刷到磁盘上。
4.fsync 将文件相关的所有更改都发送到disk device。 这个调用是阻塞的,直到disk通知此函数传输完成。此函数也会将该文件的文件信息flush到disk。
5.fsync最终将缓冲的数据更新到文件里。
所以可以看出fflush和fsync的调用顺序应该是:
c库缓冲-----fflush---------〉内核页高速缓存--------fsync-----〉磁盘
与文件读写相关的几个重要概念
脏页:linux内核中的概念,因为硬盘的读写速度远赶不上内存的速度,系统就把读写比较频繁的数据事先放到内存中,以提高读写速度,这就叫高速缓存,linux是以页作为高速缓存的单位,当进程修改了高速缓存里的数据时,该页就被内核标记为脏页,内核将会在合适的时间把脏页的数据写到磁盘中去,以保持高速缓存中的数据和磁盘中的数据是一致的
内存映射:内存映射文件,是由一个文件到一块内存的映射。Win32提供了允许应用程序把文件映射到一个进程的函数 (CreateFileMapping)。内存映射文件与虚拟内存有些类似,通过内存映射文件可以保留一个地址空间的区域,同时将物理存储器提交给此区域,内存文件映射的物理存储器来自一个已经存在于磁盘上的文件,而且在对该文件进行操作之前必须首先对文件进行映射。使用内存映射文件处理存储于磁盘上的文件时,将不必再对文件执行I/O操作,使得内存映射文件在处理大数据量的文件时能起到相当重要的作用。
延迟写(delayed write):传统的UNIX实现在内核中设有缓冲区高速缓存或页面高速缓存,大多数磁盘I/O都通过缓冲进行。当将数据写入文件时,内核通常先将该数据复制到其中一个缓冲区中,如果该缓冲区尚未写满,则并不将其排入输出队列,而是等待其写满或者当内核需要重用该缓冲区以便存放其他磁盘块数据时,再将该缓冲排入输出队列,然后待其到达队首时,才进行实际的I/O操作。这种输出方式被称为延迟写(delayed write)(Bach [1986]第3章详细讨论了缓冲区高速缓存)。
延迟写减少了磁盘读写次数,但是却降低了文件内容的更新速度,使得欲写到文件中的数据在一段时间内并没有写到磁盘上。当系统发生故障时,这种延迟可能造成 文件更新内容的丢失。为了保证磁盘上实际文件系统与缓冲区高速缓存中内容的一致性,UNIX系统提供了sync、fsync和fdatasync三个函数。
sync函数只是将所有修改过的块缓冲区排入写队列,然后就返回,它并不等待实际写磁盘操作结束。
通常称为update的系统守护进程会周期性地(一般每隔30秒)调用sync函数。这就保证了定期冲洗内核的块缓冲区。命令sync(1)也调用sync函数。
fsync函数只对由文件描述符filedes指定的单一文件起作用,并且等待写磁盘操作结束,然后返回。fsync可用于数据库这样的应用程序,这种应用程序需要确保将修改过的块立即写到磁盘上。
fdatasync函数类似于fsync,但它只影响文件的数据部分。而除数据外,fsync还会同步更新文件的属性。
对于提供事务支持的数据库,在事务提交时,都要确保事务日志(包含该事务所有的修改操作以及一个提交记录)完全写到硬盘上,才认定事务提交成功并返回给应用层。
一个简单的问题:在*nix操作系统上,怎样保证对文件的更新内容成功持久化到硬盘?
1. write不够,需要fsync
一般情况下,对硬盘(或者其他持久存储 设备)文件的write操作,更新的只是内存中的页缓存(page cache),而脏页面不会立即更新到硬盘中,而是由操作系统统一调度,如由专门的flusher内核线程在满足一定条件时(如一定时间间隔、内存中的脏 页达到一定比例)内将脏页面同步到硬盘上(放入设备的IO请求队列)。
因为write调用不会等到硬盘IO完 成之后才返回,因此如果OS在write调用之后、硬盘同步之前崩溃,则数据可能丢失。虽然这样的时间窗口很小,但是对于需要保证事务的持久化 (durability)和一致性(consistency)的数据库程序来说,write()所提供的“松散的异步语义”是不够的,通常需要OS提供的同步IO(synchronized-IO)原语来保证:
1 #include <unistd.h>2 int fsync(int fd);
fsync的功能是确保文件fd所有已修改的内容已经正确同步到硬盘上,该调用会阻塞等待直到设备报告IO完成。
PS:如果采用内存映射文件的方式进行文件IO(使用mmap,将文件的page cache直接映射到进程的地址空间,通过写内存的方式修改文件),也有类似的系统调用来确保修改的内容完全同步到硬盘之上:
1 #incude <sys/mman.h>2 int msync(void *addr, size_t length, int flags)
msync需要指定同步的地址区间,如此细粒度的控制似乎比fsync更加高效(因为应用程序通常知道自己的脏页位置),但实际上(Linux)kernel中有着十分高效的数据结构,能够很快地找出文件的脏页,使得fsync只会同步文件的修改内容。
2. fsync的性能问题,与fdatasync
除了同步文件的修改内容(脏页),fsync还会同步文件的描述信息(metadata,包括size、访问时间st_atime & st_mtime等等),因为文件的数据和metadata通常存在硬盘的不同地方,因此fsync至少需要两次IO写操作,fsync的man page这样说:
"Unfortunately fsync() will always initialize two write operations : one for the newly written data and another one in order to update the modification time stored in the inode. If the modification time is not a part of the transaction concept fdatasync() can be used to avoid unnecessary inode disk write operations."
多余的一次IO操作,有多么昂贵呢?根据Wikipedia的数据,当前硬盘驱动的平均寻道时间(Average seek time)大约是3~15ms,7200RPM硬盘的平均旋转延迟(Average rotational latency)大约为4ms,因此一次IO操作的耗时大约为10ms左右。这个数字意味着什么?下文还会提到。
Posix同样定义了fdatasync,放宽了同步的语义以提高性能:
1 #include <unistd.h>2 int fdatasync(int fd);
fdatasync的功能与fsync类似,但是仅仅在必要的情况下才会同步metadata,因此可以减少一次IO写操作。那么,什么是“必要的情况”呢?根据man page中的解释:
"fdatasync does not flush modified metadata unless that metadata is needed in order to allow a subsequent data retrieval to be corretly handled."
举例来说,文件的尺寸(st_size)如果变化,是需要立即同步的,否则OS一旦崩溃,即使文件的数据部分已同步,由于metadata没有同步,依然读不到修改的内容。而最后访问时间(atime)/修改时间(mtime)是不需要每次都同步的,只要应用程序对这两个时间戳没有苛刻的要求,基本无伤大雅。
PS:open时的参数O_SYNC/O_DSYNC有着和fsync/fdatasync类似的语义:使每次write都会阻塞等待硬盘IO完成。(实际上,Linux对O_SYNC/O_DSYNC做了相同处理,没有满足Posix的要求,而是都实现了fdatasync的语义)相对于fsync/fdatasync,这样的设置不够灵活,应该很少使用。
3. 使用fdatasync优化日志同步
文章开头时已提到,为了满足事务要求,数据库的日志文件是常常需要同步IO的。由于需要同步等待硬盘IO完成,所以事务的提交操作常常十分耗时,成为性能的瓶颈。
在Berkeley DB下,如果开启了AUTO_COMMIT(所有独立的写操作自动具有事务语义)并使用默认的同步级别(日志完全同步到硬盘才返回),写一条记录的耗时大约为5~10ms级别,基本和一次IO操作(10ms)的耗时相同。
我们已经知道,在同步上fsync是低效的。但是如果需要使用fdatasync减少对metadata的更新,则需要确保文件的尺寸在write前后没有发生变化。日志文件天生是追加型(append-only)的,总是在不断增大,似乎很难利用好fdatasync。
且看Berkeley DB是怎样处理日志文件的:
1.每个log文件固定为10MB大小,从1开始编号,名称格式为“log.%010d"
2.每次log文件创建时,先写文件的最后1个page,将log文件扩展为10MB大小
3.向log文件中追加记录时,由于文件的尺寸不发生变化,使用fdatasync可以大大优化写log的效率
4.如果一个log文件写满了,则新建一个log文件,也只有一次同步metadata的开销
参考:http://blog.csdn.net/cywosp/article/details/8767327
http://blog.chinaunix.net/uid-1911213-id-3412851.html
http://blog.csdn.net/lwj103862095/article/details/38268647
等
转载于:https://my.oschina.net/u/1377774/blog/529847
[IO系统]11 回写机制(writeback)
在Linux-3.2新内核中,page cache和buffer cache的刷新机制发生了改变。放弃了原有的pdflush机制,改成了bdi_writeback机制。这种变化主要解决原有pdflush机制存在的一个问题:在多磁盘的系统中,pdflush管理了所有磁盘的page/buffer cache,从而导致一定程度的IO性能瓶颈。bdi_writeback机制为每个磁盘都创建一个线程,专门负责这个磁盘的pagecache或者buffer cache的数据刷新工作,从而实现了每个磁盘的数据刷新程序在线程级的分离,这种处理可以提高IO性能。
1.1 writeback机制模型
writeback机制的基本原理可以描述如下:
在Linux内核中有一个常驻内存的线程bdi_forker_thread,该线程负责为bdi_object创建writeback线程,同时检测如果writeback线程长时间处于空闲状态,bdi_forker_thread线程便会将其进行销毁。bdi_forker_thread在系统中只有一个,其会被定时唤醒,检查全局链表bdi_list队列中是否存在dirty的数据需要刷新到磁盘。如果存在dirty数据并且对应bdi的writeback线程还没有被创建,bdi_forker_thread会为该bdi创建一个writeback的线程进行写回操作。
writeback线程被创建之后会处理等待的work。writeback线程拥有一个定时器会周期性唤醒这个线程处理相应的work。当用户(page cache/buffer cache)有需要处理的inode时,将inode挂载到writeback->b_dirty链表中,然后唤醒writeback线程去处理相应的dirty_page。inode链表就是writeback线程需要处理的数据;work链表就是控制处理过程中的一些策略,不同的策略可以定义成不同的任务。
通过上述模型,对于块设备或者文件系统而言,实现dirty page的后台刷新主要做如下几个方面的工作:
1,将自己的bdi注册到系统的bdi链表中,通过bdi_forker_thread实现对bdi对象的管理,从而可以实现writeback线程的动态创建、销毁。每个块设备和文件系统都有自己的bdi对象。Ext3文件系统在创建的时候会生成superblock对象,系统会将底层块设备的backing_device关系到这个superblock对象上(在set_bdev_super函数中完成)。如果是块设备的话,在add_disk的时候直接从request_queue中得到bdi对象,然后对其进行初始化。注册bdi对象使用bdi_register_dev函数,对于ext3之类的文件系统不需要重新注册bdi对象,因为其本身就采用了底层块设备的bdi对象。
2,将需要刷新的inode节点挂载到bdi对象所属的writeback->b_dirty上,如果有特殊的work需要writeback线程完成,那么提交一个work即可;如果是通常的周期性刷新,writeback线程会自动创建相应的work。
3,操作writeback的唤醒定时器延迟唤醒writeback线程,或者直接唤醒线程,从而使得inode中radix tree上的dirty page刷新到磁盘。
1.2 bdi对象的注册
每个块设备在创建的时候会注册bdi对象(参见add_disk函数),这是Linux-3.2内核不同的地方。文件系统在mount的时候会创建superblock对象,并且通过底层块设备的request queue获取bdi对象(mount_bdev->sget->set_bdev_super)。所以,像ext3之类的文件系统都不需要重新注册bdi对象。当然,如果文件系统重新创建了一个bdi对象,那么还需要调用bdi_register_dev函数注册bdi对象。
1.3 小结
本文对linux-3.2中的writeback机制模型进行了阐述,后面还会对writeback机制中的关键函数进行分析说明。该机制是对老系统(Linux-2.6.23等)中pdflush机制的替代,其最重要的变化是每个块设备都分配了writeback线程,使得回写的IO流在各个磁盘之间独立,从而从机制上提高了IO的吞吐量。
1.4 参考文献
[博客] http://alanwu.blog.51cto.com/3652632/1109952
聊聊 Linux IO
写在前面
在开始正式的讨论前,我先抛出几个问题:
- 谈到磁盘时,常说的HDD磁盘和SSD磁盘最大的区别是什么?这些差异会影响我们的系统设计吗?
- 单线程写文件有点慢,那多开几个线程一起写是不是可以加速呢?
write(2)
函数成功返回了,数据就已经成功写入磁盘了吗?此时设备断电会有影响吗?会丢失数据吗?
write(2)
调用是原子的吗?多线程写文件是否要对文件加锁?有没有例外,比如O_APPEND
方式?- 坊间传闻,
mmap(2)
的方式读文件比传统的方式要快,因为少一次拷贝。真是这样吗?为什么少一次拷贝?
如果你觉得这些问题都很简单,都能很明确的回答上来。那么很遗憾这篇文章不是为你准备的,你可以关掉网页去做其他更有意义的事情了。如果你觉得无法明确的回答这些问题,那么就耐心地读完这篇文章,相信不会浪费你的时间。受限于个人时间和文章篇幅,部分议题如果我不能给出更好的解释或者已有专业和严谨的资料,就只会给出相关的参考文献的链接,请读者自行参阅。
言归正传,我们的讨论从存储器的层次结构开始。
存储器的金字塔结构
受限于存储介质的存取速率和成本,现代计算机的存储结构呈现为金字塔型[1]。越往塔顶,存取效率越高、但成本也越高,所以容量也就越小。得益于程序访问的局部性原理[2],这种节省成本的做法也能取得不俗的运行效率。从存储器的层次结构以及计算机对数据的处理方式来看,上层一般作为下层的Cache层来使用(广义上的Cache)。比如寄存器缓存CPU Cache的数据,CPU Cache L1~L3层视具体实现彼此缓存或直接缓存内存的数据,而内存往往缓存来自本地磁盘的数据。
本文主要讨论磁盘IO操作,故只聚焦于Local Disk的访问特性和其与DRAM之间的数据交互。
无处不在的缓存
如图,当程序调用各类文件操作函数后,用户数据(User Data)到达磁盘(Disk)的流程如图所示[3]。图中描述了Linux下文件操作函数的层级关系和内存缓存层的存在位置。中间的黑色实线是用户态和内核态的分界线。
从上往下分析这张图,首先是C语言stdio
库定义的相关文件操作函数,这些都是用户态实现的跨平台封装函数。stdio
中实现的文件操作函数有自己的stdio buffer
,这是在用户态实现的缓存。此处使用缓存的原因很简单——系统调用总是昂贵的。如果用户代码以较小的size不断的读或写文件的话,stdio
库将多次的读或者写操作通过buffer进行聚合是可以提高程序运行效率的。stdio
库同时也支持fflush(3)
函数来主动的刷新buffer,主动的调用底层的系统调用立即更新buffer里的数据。特别地,setbuf(3)
函数可以对stdio
库的用户态buffer进行设置,甚至取消buffer的使用。
系统调用的read(2)/write(2)
和真实的磁盘读写之间也存在一层buffer,这里用术语Kernel buffer cache
来指代这一层缓存。在Linux下,文件的缓存习惯性的称之为Page Cache
,而更低一级的设备的缓存称之为Buffer Cache
. 这两个概念很容易混淆,这里简单的介绍下概念上的区别:Page Cache
用于缓存文件的内容,和文件系统比较相关。文件的内容需要映射到实际的物理磁盘,这种映射关系由文件系统来完成;Buffer Cache
用于缓存存储设备块(比如磁盘扇区)的数据,而不关心是否有文件系统的存在(文件系统的元数据缓存在Buffer Cache
中)。
综上,既然讨论Linux下的IO操作,自然是跳过stdio
库的用户态这一堆东西,直接讨论系统调用层面的概念了。对stdio
库的IO层有兴趣的同学可以自行去了解。从上文的描述中也介绍了文件的内核级缓存是保存在文件系统的Page Cache
中的。所以后面的讨论基本上是讨论IO相关的系统调用和文件系统Page Cache
的一些机制。
Linux内核中的IO栈
这一小节来看Linux内核的IO栈的结构。先上一张全貌图[4]:
由图可见,从系统调用的接口再往下,Linux下的IO栈致大致有三个层次:
- 文件系统层,以
write(2)
为例,内核拷贝了write(2)
参数指定的用户态数据到文件系统Cache中,并适时向下层同步 - 块层,管理块设备的IO队列,对IO请求进行合并、排序(还记得操作系统课程学习过的IO调度算法吗?)
- 设备层,通过DMA与内存直接交互,完成数据和具体设备之间的交互
结合这个图,想想Linux系统编程里用到的Buffered IO
、mmap(2)
、Direct IO
,这些机制怎么和Linux IO栈联系起来呢?上面的图有点复杂,我画一幅简图,把这些机制所在的位置添加进去:
这下一目了然了吧?传统的Buffered IO
使用read(2)
读取文件的过程什么样的?假设要去读一个冷文件(Cache中不存在),open(2)
打开文件内核后建立了一系列的数据结构,接下来调用read(2)
,到达文件系统这一层,发现Page Cache
中不存在该位置的磁盘映射,然后创建相应的Page Cache
并和相关的扇区关联。然后请求继续到达块设备层,在IO队列里排队,接受一系列的调度后到达设备驱动层,此时一般使用DMA方式读取相应的磁盘扇区到Cache中,然后read(2)
拷贝数据到用户提供的用户态buffer中去(read(2)
的参数指出的)。
整个过程有几次拷贝?从磁盘到Page Cache
算第一次的话,从Page Cache
到用户态buffer就是第二次了。而mmap(2)
做了什么?mmap(2)
直接把Page Cache
映射到了用户态的地址空间里了,所以mmap(2)
的方式读文件是没有第二次拷贝过程的。那Direct IO
做了什么?这个机制更狠,直接让用户态和块IO层对接,直接放弃Page Cache
,从磁盘直接和用户态拷贝数据。好处是什么?写操作直接映射进程的buffer到磁盘扇区,以DMA的方式传输数据,减少了原本需要到Page Cache
层的一次拷贝,提升了写的效率。对于读而言,第一次肯定也是快于传统的方式的,但是之后的读就不如传统方式了(当然也可以在用户态自己做Cache,有些商用数据库就是这么做的)。
除了传统的Buffered IO
可以比较自由的用偏移+长度的方式读写文件之外,mmap(2)
和Direct IO
均有数据按页对齐的要求,Direct IO
还限制读写必须是底层存储设备块大小的整数倍(甚至Linux 2.4还要求是文件系统逻辑块的整数倍)。所以接口越来越底层,换来表面上的效率提升的背后,需要在应用程序这一层做更多的事情。所以想用好这些高级特性,除了深刻理解其背后的机制之外,也要在系统设计上下一番功夫。
Page Cache 的同步
广义上Cache的同步方式有两种,即Write Through(写穿)
和Write back(写回)
. 从名字上就能看出这两种方式都是从写操作的不同处理方式引出的概念(纯读的话就不存在Cache一致性了,不是么)。对应到Linux的Page Cache
上所谓Write Through
就是指write(2)
操作将数据拷贝到Page Cache
后立即和下层进行同步的写操作,完成下层的更新后才返回。而Write back
正好相反,指的是写完Page Cache
就可以返回了。Page Cache
到下层的更新操作是异步进行的。
Linux下Buffered IO
默认使用的是Write back
机制,即文件操作的写只写到Page Cache
就返回,之后Page Cache
到磁盘的更新操作是异步进行的。Page Cache
中被修改的内存页称之为脏页(Dirty Page),脏页在特定的时候被一个叫做pdflush(Page Dirty Flush)
的内核线程写入磁盘,写入的时机和条件如下:
- 当空闲内存低于一个特定的阈值时,内核必须将脏页写回磁盘,以便释放内存。
- 当脏页在内存中驻留时间超过一个特定的阈值时,内核必须将超时的脏页写回磁盘。
- 用户进程调用
sync(2)
、fsync(2)
、fdatasync(2)
系统调用时,内核会执行相应的写回操作。
刷新策略由以下几个参数决定(数值单位均为1/100秒):
1 |
|
默认是写回方式,如果想指定某个文件是写穿方式呢?即写操作的可靠性压倒效率的时候,能否做到呢?当然能,除了之前提到的fsync(2)
之类的系统调用外,在open(2)
打开文件时,传入O_SYNC
这个flag即可实现。这里给篇参考文章[5],不再赘述(更好的选择是去读TLPI相关章节)。
文件读写遭遇断电时,数据还安全吗?相信你有自己的答案了。使用O_SYNC
或者fsync(2)
刷新文件就能保证安全吗?现代磁盘一般都内置了缓存,代码层面上也只能讲数据刷新到磁盘的缓存了。当数据已经进入到磁盘的高速缓存时断电了会怎么样?这个恐怕不能一概而论了。不过可以使用hdparm -W0
命令关掉这个缓存,相应的,磁盘性能必然会降低。
文件操作与锁
当多个进程/线程对同一个文件发生写操作的时候会发生什么?如果写的是文件的同一个位置呢?这个问题讨论起来有点复杂了。首先write(2)
调用不是原子操作,不要被TLPI的中文版5.2章节的第一句话误导了(英文版也是有歧义的,作者在这里给出了勘误信息)。当多个write(2)
操作对一个文件的同一部分发起写操作的时候,情况实际上和多个线程访问共享的变量没有什么区别。按照不同的逻辑执行流,会有很多种可能的结果。也许大多数情况下符合预期,但是本质上这样的代码是不可靠的。
特别的,文件操作中有两个操作是内核保证原子的。分别是open(2)
调用的O_CREAT
和O_APPEND
这两个flag属性。前者是文件不存在就创建,后者是每次写文件时把文件游标移动到文件最后追加写(NFS等文件系统不保证这个flag)。有意思的问题来了,以O_APPEND
方式打开的文件write(2)
操作是不是原子的?文件游标的移动和调用写操作是原子的,那写操作本身会不会发生改变呢?有的开源软件比如apache写日志就是这样写的,这是可靠安全的吗?坦白讲我也不清楚,有人说Then O_APPEND is atomic and write-in-full for all reasonably-sized> writes to regular files.
但是我也没有找到很权威的说法。这里给出一个邮件列表上的讨论,可以参考下[6]。今天先放过去,后面有时间的话专门研究下这个问题。如果你能给出很明确的说法和证明,还望不吝赐教。
Linux下的文件锁有两种,分别是flock(2)
的方式和fcntl(2)
的方式,前者源于BSD,后者源于System V,各有限制和应用场景。老规矩,TLPI上讲的很清楚的这里不赘述。我个人是没有用过文件锁的,系统设计的时候一般会避免多个执行流写一个文件的情况,或者在代码逻辑上以mutex加锁,而不是直接加锁文件本身。数据库场景下这样的操作可能会多一些(这个纯属臆测),这就不是我了解的范畴了。
磁盘的性能测试
在具体的机器上跑服务程序,如果涉及大量IO的话,首先要对机器本身的磁盘性能有明确的了解,包括不限于IOPS、IO Depth等等。这些数据不仅能指导系统设计,也能帮助资源规划以及定位系统瓶颈。比如我们知道机械磁盘的连续读写性能一般不会超过120M/s,而普通的SSD磁盘随意就能超过机械盘几倍(商用SSD的连续读写速率达到2G+/s不是什么新鲜事)。另外由于磁盘的工作原理不同,机械磁盘需要旋转来寻找数据存放的磁道,所以其随机存取的效率受到了“寻道时间”的严重影响,远远小于连续存取的效率;而SSD磁盘读写任意扇区可以认为是相同的时间,随机存取的性能远远超过机械盘。所以呢,在机械磁盘作为底层存储时,如果一个线程写文件很慢的话,多个线程分别去写这个文件的各个部分能否加速呢?不见得吧?如果这个文件很大,各个部分的寻道时间带来极大的时间消耗的话,效率就很低了(先不考虑Page Cache
)。SSD呢?可以明确,设计合理的话,SSD多线程读写文件的效率会高于单线程。当前的SSD盘很多都以高并发的读取为卖点的,一个线程压根就喂不饱一块SSD盘。一般SSD的IO Depth都在32甚至更高,使用32或者64个线程才能跑满一个SSD磁盘的带宽(同步IO情况下)。
具体的SSD原理不在本文计划内,这里给出一篇详细的参考文章[7]。有时候一些文章中所谓的SATA磁盘一般说的就是机械盘(虽然SATA本身只是一个总线接口)。接口会影响存储设备的最大速率,基本上是SATA -> PCI-E -> NVMe
的发展路径,具体请自行Google了解。
具体的设备一般使用fio
工具[8]来测试相关磁盘的读写性能。fio的介绍和使用教程有很多[9],不再赘述。这里不想贴性能数据的原因是存储介质的发展实在太快了,一方面不想贴某些很快就过时的数据以免让初学者留下不恰当的第一印象,另一方面也希望读写自己实践下fio命令。
前文提到存储介质的原理会影响程序设计,我想稍微的解释下。这里说的“影响”不是说具体的读写能到某个速率,程序中就依赖这个数值,换个工作环境就性能大幅度降低(当然,为专门的机型做过优化的结果很可能有这个副作用)。而是说根据存储介质的特性,程序的设计起码要遵循某个设计套路。举个简单的例子,SATA机械盘的随机存取很慢,那系统设计时,就要尽可能的避免随机的IO出现,尽可能的转换成连续的文件存取来加速运行。比如Google的LevelDB就是转换随机的Key-Value写入为Binlog(连续文件写入)+ 内存插入MemTable(内存随机读写可以认为是O(1)的性能),之后批量dump到磁盘(连续文件写入)。这种LSM-Tree
的设计便是合理的利用了存储介质的特性,做到了最大化的性能利用(磁盘换成SSD也依旧能有很好的运行效率)。
写在最后
每天抽出不到半个小时,零零散散地写了一周,这是说是入门都有些谬赞了,只算是对Linux下的IO机制稍微深入的介绍了一点。无论如何,希望学习完Linux系统编程的同学,能继续的往下走一走,尝试理解系统调用背后隐含的机制和原理。探索的结果无所谓,重要的是探索的过程以及相关的学习经验和方法。前文提出的几个问题我并没有刻意去解答所有的,但是读到现在,不知道你自己能回答上几个了?
计算机文件读写原理
前言
在一个txt文件中,修改其中一个字,然后保存,这期间计算机内部到底发生了什么?操作系统如何将键盘输入的字符通过主板上密密麻麻的总线和芯片送到磁盘上的盘片上的? 这其中涉及的所有环节,就算用一本书也不一定能完全说明。然而我想首先从宏观上认知整个过程,再逐步的去细化。因此,本篇文章的目的是从一从一个非常抽象的层次、一个非常局限的角度来分析文件读写的原理。
为什么说局限
为什么说是局限的角度呢,因为文件IO整个生命流程涉及到的环节太多了,每个环节还有很多不同的实现模式,因此我尝试着每个环节只考虑一种情况,从而能认知整个流程。
文件操作分为读和写,写可以分为覆盖写和追加写;写操作可以使用操作系统缓存cache,也可以bypass操作系统cache;写IO可以分为Write Through(透写)和Write Back(回写);另外文件数据可以存放在本地,也可以存放在外置存储中;存放在本地也分有无raid卡;硬盘也分传统硬盘或SSD,SAS或SATA接口,甚至nvme,等等。如果这些都考虑,很难说清楚文件读写的原理,我认为首先弄明白其中一种情况下的原理,再运用联系思维考虑其他情况,更有效率。
下文我将以Linux系统为例,阐述IO覆盖写,使用Write Back模式,使用操作系统缓存cache,底层使用传统SAS磁盘情况下的写IO流程。
写IO
为了便于理解,下图我将文件写IO涉及到的重要硬件设备和软件逻辑结构表示在一张图中,大致上左边是硬件,右边是软件逻辑结构,以下以应用程序覆盖写文件A为例
应用程序需要修改文件A中的部分字段,首先应用程序将待写数据存放在其user buffer结构中,user buffer 通过MMU 映射,数据实际存放在物理内存中。现在应用程序需要将待写数据写入硬盘。
①程序进程调用内核函数write(),将待写文件标识(句柄)、待写数据相对文件首部的字节偏移量(offset xx)、待写数据长度(2KB)和待写数据的位置一并传给内核 ;
注:在程序打开文件时,内核在PageCache中创建一个虚拟的文件 A’,这个文件A’从文件系统inode结构(下文讲)中映射出来,由若干个page组成,初始情况下文件A’存在与逻辑地址空间内,不占用物理内存。文件A’的逻辑长度参考文件实际长度占用page的整数倍,这里假设page 大小为4KB。
②如图,内核根据文件字节偏移量和上文提到的虚拟文件A’计算出待写数据占用的page1;(这里面待写数据只有2KB,小于page大小,因此待写数据落入page1中)
③计算出page号后,内核尝试找到page1对应的物理地址,以进行下一步操作。此时发现page1对应的数据并没有调入内存中,产生缺页,此时需要内核将page1对应的数据完整的从磁盘调入内存;(注意:此处和内存换页没有关系,这里可以看到使用操作系统Pagecache的写IO可能会产生IO读惩罚)
注:文件系统的主要功能就是组织文件在磁盘上的分布,文件是连续的结构,但其在磁盘上却是离散分布的。文件系统将磁盘格式化成若干个块,每个块由若干个连续物理扇区组成,这个真实存在的块叫做物理块。为了提高利用率,同一个文件映射出的物理块可能在磁盘的任何位置,不一定是连续的。因此文件系统需要一个链表来记录文件对应的物理块位置,这个结构在linux中就是inode
④文件系统将page号映射到对应的块,然后根据inode可查到文件块对应的真实物理地址LBA,内核将请求封装后转给设备驱动层(此步内核需要将Page所包含的所有字节都调入内存——”Page对齐“)。
⑤设备驱动将请求翻译成若干各个SCSI指令,驱动SAS控制器通过SAS总线向磁盘发送指令:
SCSI Read() LBA0x****** Len=N N=读取字节大小/扇区大小
注:上述过程主要发生在CPU与内存之间,CPU从内存中读出指令并执行,最后CPU将指令通过PCIe总线发送给了SAS控制器,SAS控制器将指令发送到SAS总线上
⑥磁盘收到SCSI指令后,找到LBA对应的实际盘面和柱面,读出对应的扇区,发回SAS控制器;
⑦从磁盘读出的数据(这里是4KB大小)从原路返回,最后写入到page1对应的物理内存中;
⑧内核用代写2KB数据替换掉Page1对应的2KB待替换数据;
⑨此时内核向程序进程反馈:写入成功;
⑩内核在合适时机将内存中的脏页刷入磁盘。
注:⑨⑩两步表示 Write Back模式,内核在没有将数据写入磁盘时就返回写入成功,以提高效率,相当于内核“欺骗”了应用程序。实际上不光内核会这样做,底层的很多环节也会有这样的情况,比如磁盘也会“欺骗”SAS控制器。如果此时发生系统掉电,所有易失性存储中的数据全部丢失,并未写入磁盘,而应用程序认为写IO已经完成了,下次开机时就会产生数据不一致。程序可以设置Write Through 模式,此时内核会等底层层层上报写入成功后,才会反馈写入成功。
小结
为了能完整的表述整个IO过程,这其中我简化掉了很多流程。比如这里面没有提卷管理层,还有一个IO中很重要的概念——队列(queue),只要有IO瓶颈,就会有队列,操作系统中有多处队列,比如Linux中io scheduler。磁盘上也有队列,以实现NCQ等算法。

浅谈Linux内核IO体系之磁盘IO
Linux I/O体系是Linux内核的重要组成部分,主要包含网络IO、磁盘IO等。基本所有的技术栈都需要与IO打交道,分布式存储系统更是如此。本文主要简单分析一下磁盘IO,看看一个IO请求从发起到完成到底经历了哪些流程。
目录
- 名词解释
- IO体系
- VFS层
- PageCache层
- 映射层
- 通用块层
- IO调度层
- 设备驱动层
- 物理设备层
- FAQ
名词解释
Buffered I/O
:缓存IO又叫标准IO,是大多数文件系统的默认IO操作,经过PageCache。Direct I/O
:直接IO,By Pass PageCache。offset、length需对齐到block_size。Sync I/O
:同步IO,即发起IO请求后会阻塞直到完成。缓存IO和直接IO都属于同步IO。Async I/O
:异步IO,即发起IO请求后不阻塞,内核完成后回调。通常用内核提供的Libaio。Write Back
:Buffered IO时,仅仅写入PageCache便返回,不等数据落盘。Write Through
:Buffered IO时,不仅仅写入PageCache,而且同步等待数据落盘。
IO体系
我们先看一张总的Linux内核存储栈图片:
Linux IO存储栈主要有以下7层:
VFS层
我们通常使用open、read、write等函数来编写Linux下的IO程序。接下来我们看看这些函数的IO栈是怎样的。在此之前我们先简单分析一下VFS层的4个对象,有助于我们深刻的理解IO栈。
VFS层的作用是屏蔽了底层不同的文件系统的差异性,为用户程序提供一个统一的、抽象的、虚拟的文件系统,提供统一的对外API,使用户程序调用时无需感知底层的文件系统,只有在真正执行读写操作的时候才调用之前注册的文件系统的相应函数。
VFS支持的文件系统主要有三种类型:
- 基于磁盘的文件系统:Ext系列、XFS等。
- 网络文件系统:NFS、CIFS等。
- 特殊文件系统:/proc、裸设备等。
VFS主要有四个对象类型(不同的文件系统都要实现):
- superblock:整个文件系统的元信息。对应的操作结构体:
struct super_operations
。 - inode:单个文件的元信息。对应的操作结构体:
struct inode_operations
。 - dentry:目录项,一个文件目录对应一个dentry。对应的操作结构体:
struct dentry_operations
。 - file:进程打开的一个文件。对应的操作结构体:
struct file_operations
。
关于VFS相关结构体的定义都在include/linux/fs.h里面。
superblock
superblock结构体定义了整个文件系统的元信息,以及相应的操作。
https://github.com/torvalds/linux/blob/v4.16/fs/xfs/xfs_super.c#L1789
static const struct super_operations xfs_super_operations = { ...... }; static struct file_system_type xfs_fs_type = { .name = "xfs", ...... };
inode
inode结构体定义了文件的元数据,比如大小、最后修改时间、权限等,除此之外还有一系列的函数指针,指向具体文件系统对文件操作的函数,包括常见的open、read、write等,由i_fop
函数指针提供。
文件系统最核心的功能全部由inode的函数指针提供。主要是inode的i_op
、i_fop
字段。
struct inode { ...... // inode 文件元数据的函数操作 const struct inode_operations *i_op; // 文件数据的函数操作,open、write、read等 const struct file_operations *i_fop; ...... }
在设置inode的i_fop
时候,会根据不同的inode类型设置不同的i_fop
。我们以xfs为例:
https://github.com/torvalds/linux/blob/v4.16/fs/xfs/xfs_iops.c#L1266
https://github.com/torvalds/linux/blob/v4.16/fs/inode.c#L1980
如果inode类型为普通文件的话,那么设置XFS提供的xfs_file_operations
。
如果inode类型为块设备文件的话,那么设置块设备默认提供的def_blk_fops
。
void xfs_setup_iops(struct xfs_inode *ip) { struct inode *inode = &ip->i_vnode; switch (inode->i_mode & S_IFMT) { case S_IFREG: inode->i_op = &xfs_inode_operations; // 在IO栈章节会分析一下xfs_file_operations inode->i_fop = &xfs_file_operations; inode->i_mapping->a_ops = &xfs_address_space_operations; break; ...... default: inode->i_op = &xfs_inode_operations; init_special_inode(inode, inode->i_mode, inode->i_rdev); break; } } void init_special_inode(struct inode *inode, umode_t mode, dev_t rdev) { inode->i_mode = mode; ...... if (S_ISBLK(mode)) { // 块设备相应的系列函数 inode->i_fop = &def_blk_fops; inode->i_rdev = rdev; } ...... }
dentry
dentry是目录项,由于每一个文件必定存在于某个目录内,我们通过路径查找一个文件时,最终肯定找到某个目录项。在Linux中,目录和普通文件一样,都是存放在磁盘的数据块中,在查找目录的时候就读出该目录所在的数据块,然后去寻找其中的某个目录项。
struct dentry { ...... const struct dentry_operations *d_op; ...... };
在我们使用Linux的过程中,根据目录查找文件的例子无处不在,而目录项的数据又都是存储在磁盘上的,如果每一级路径都要读取磁盘,那么性能会十分低下。所以需要目录项缓存,把dentry放在缓存中加速。
VFS把所有的dentry放在dentry_hashtable哈希表里面,使用LRU淘汰算法。
file
用户程序能接触的VFS对象只有file
,由进程管理。我们常用的打开一个文件就是创建一个file对象,并返回一个文件描述符。出于隔离性的考虑,内核不会把file的地址返回,而是返回一个整形的fd。
struct file { // 操作文件的函数指针,和inode里面的i_fop一样,在open的时候赋值为i_fop。 const struct file_operations *f_op; // 指向对应inode对象 struct inode *f_inode; // 每个文件都有自己的一个偏移量 loff_t f_pos; ...... }
file对象是由内核进程直接管理的。每个进程都有当前打开的文件列表,放在files_struct结构体中。
struct files_struct { ...... struct file __rcu * fd_array[NR_OPEN_DEFAULT]; ...... };
fd_array数组存储了所有打开的file对象,用户程序拿到的文件描述符(fd)实际上是这个数组的索引。
IO栈
https://github.com/torvalds/linux/blob/v4.16/fs/read_write.c#L566
SYSCALL_DEFINE3(read, unsigned int, fd, char __user *, buf, size_t, count) { ...... ret = vfs_read(f.file, buf, count, &pos); ...... return ret; } SYSCALL_DEFINE3(write, unsigned int, fd, char __user *, buf, size_t, count) { ...... ret = vfs_write(f.file, buf, count, &pos); ...... return ret; }
由此可见,我们经常使用的read、write系统调用实际上是对vfs_read、vfs_write的一个封装。
size_t vfs_read(struct file *file, char __user *buf, size_t count, loff_t *pos) { ...... if (file->f_op->read) ret = file->f_op->read(file, buf, count, pos); else ret = do_sync_read(file, buf, count, pos); ...... } ssize_t vfs_write(struct file *file, const char __user *buf, size_t count, loff_t *pos) { ...... if (file->f_op->write) ret = file->f_op->write(file, buf, count, pos); else ret = do_sync_write(file, buf, count, pos); ...... }
我们发现,VFS会调用具体的文件系统的实现:file->f_op->read
、file->f_op->write
。
对于通用的文件系统,Linux封装了很多基本的函数,很多文件系统的核心功能都是以这些基本的函数为基础,再封装一层。接下来我们以XFS为例,简单分析一下XFS的read、write都做了什么操作。
https://github.com/torvalds/linux/blob/v4.16/fs/xfs/xfs_file.c#L1137
const struct file_operations xfs_file_operations = { ...... .llseek = xfs_file_llseek, .read = do_sync_read, .write = do_sync_write, // 异步IO,在之后的版本中名字为read_iter、write_iter。 .aio_read = xfs_file_aio_read, .aio_write = xfs_file_aio_write, .mmap = xfs_file_mmap, .open = xfs_file_open, .fsync = xfs_file_fsync, ...... };
这是XFS的f_op
函数指针表,我们可以看到read、write函数直接使用了内核提供的do_sync_read
、do_sync_write
函数。
ssize_t do_sync_read(struct file *filp, char __user *buf, size_t len, loff_t *ppos) { ...... ret = filp->f_op->aio_read(&kiocb, &iov, 1, kiocb.ki_pos); ...... } ssize_t do_sync_write(struct file *filp, const char __user *buf, size_t len, loff_t *ppos) { ...... ret = filp->f_op->aio_write(&kiocb, &iov, 1, kiocb.ki_pos); ...... }
这两个函数最终也是调用了具体文件系统的aio_read
和aio_write
函数,对应XFS的函数为xfs_file_aio_read
和xfs_file_aio_write
。
xfs_file_aio_read
和xfs_file_aio_write
虽然有很多xfs自己的实现细节,但其核心功能都是建立在内核提供的通用函数上的:xfs_file_aio_read
最终会调用generic_file_aio_read
函数,xfs_file_aio_write
最终会调用generic_perform_write
函数,这些通用函数是基本上所有文件系统的核心逻辑。
接下来便要进入PageCache层的相关逻辑了,我们先简单概括一下读写多了哪些事情。
generic_file_aio_read
:
- 根据文件偏移量计算出要读取数据在PageCache中的位置。
- 如果命中PageCache则直接返回,否则触发磁盘读取任务,会有预读的操作,减少IO次数。
- 数据读取到PageCache后,拷贝到用户态Buffer中。
generic_perform_write
:
- 根据文件偏移量计算要写入的数据再PageCache中的位置。
- 将用户态的Buffer拷贝到PageCache中。
- 检查PageCache是否占用太多,如果是则将部分PageCache的数据刷回磁盘。
使用Buffered IO时,VFS层的读写很大程度上是依赖于PageCache的,只有当Cache-Miss,Cache过满等才会涉及到磁盘的操作。
块设备文件
我们在使用Direct IO时,通常搭配Libaio使用,避免同步IO阻塞程序。而往往Direct IO + Libaio应用于裸设备的场景,尽量不要应用于文件系统中的文件,这时仍然会有文件系统的种种开销。
通常Direct IO + Libaio使用的场景有几种:
- write back journal,journal也是裸设备。
- 不怎么依赖文件系统的绝大部分功能,仅仅是读写即可,可直接操作裸设备。
上面基本都是普通文件的读写,我们通常的使用场景中还有一种特殊的文件即块设备文件(/dev/sdx),这些块设备文件仍然由VFS层管理,相当于一个特殊的文件系统。当进程访问块设备文件时,直接调用设备驱动程序提供的相应函数,默认的块设备函数列表如下:
const struct file_operations def_blk_fops = { ...... .open = blkdev_open, .llseek = block_llseek, .read = do_sync_read, .write = do_sync_write, .aio_read = blkdev_aio_read, .aio_write = blkdev_aio_write, .mmap = generic_file_mmap, .fsync = blkdev_fsync, ...... };
使用Direct IO + Libaio + 裸设备时,VFS层的函数指针会指向裸设备的def_blk_fops
。因为我们通常使用DIO+Libaio+裸设备,所以我们简单分析一下Libaio的IO流程。
Libaio提供了5个基本的方法,只能以DIO的方式打开,否则可能会进行Buffered IO。
io_setup, io_cancal, io_destroy, io_getevents, io_submit
Linux内核AIO的实现在https://github.com/torvalds/linux/blob/v4.16/fs/aio.c,我们简单分析一下io_submit的操作。
SYSCALL_DEFINE3(io_submit, aio_context_t, ctx_id, long, nr, struct iocb __user * __user *, iocbpp) { return do_io_submit(ctx_id, nr, iocbpp, 0); } long do_io_submit(aio_context_t ctx_id, long nr,struct iocb __user *__user *iocbpp, bool compat){ ... for (i=0; i<nr; i++) { ret = io_submit_one(ctx, user_iocb, &tmp, compat); } ... } static int io_submit_one(struct kioctx *ctx, struct iocb __user *user_iocb,struct iocb *iocb, bool compat){ ... ret = aio_run_iocb(req, compat); ... } static ssize_t aio_run_iocb(struct kiocb *req, bool compat){ ... case IOCB_CMD_PREADV: rw_op = file->f_op->aio_read; case IOCB_CMD_PWRITEV: rw_op = file->f_op->aio_write; ... }
可以发现,最终也是调用f_op
的aio_read
函数,对应于文件系统的文件就是xfs_file_aio_read
函数,对应于块设备文件就是blkdev_aio_read
函数,然后进入通用块层,放入IO队列,进行IO调度。由此可见Libaio的队列也就是通用块层之下的IO调度层中的队列。
PageCache层
在HDD时代,由于内核和磁盘速度的巨大差异,Linux内核引入了页高速缓存(PageCache),把磁盘抽象成一个个固定大小的连续Page,通常为4K。对于VFS来说,只需要与PageCache交互,无需关注磁盘的空间分配以及是如何读写的。
当我们使用Buffered IO的时候便会用到PageCache层,与Direct IO相比,用户程序无需offset、length对齐。是因为通用块层处理IO都必须是块大小对齐的。
Buffered IO中PageCache帮我们做了对齐的工作:如果我们修改文件的offset、length不是页大小对齐的,那么PageCache会执行RMW
的操作,先把该页对应的磁盘的数据全部读上来,再和内存中的数据做Modify,最后再把修改后的数据写回磁盘。虽然是写操作,但是非对齐的写仍然会有读操作。
Direct IO由于跳过了PageCache,直达通用块层,所以需要用户程序处理对齐的问题。
脏页刷盘
如果发生机器宕机,位于PageCache中的数据就会丢失;所以仅仅写入PageCache是不可靠的,需要有一定的策略将数据刷入磁盘。通常有几种策略:
- 手动调用fsync、fdatasync刷盘,可参考浅谈分布式存储之sync详解。
- 脏页占用比例超过了阈值,触发刷盘。
- 脏页驻留时间过长,触发刷盘。
Linux内核目前的做法是为每个磁盘都建立一个线程,负责每个磁盘的刷盘。
预读策略
从VFS层我们知道写是异步的,写完PageCache便直接返回了;但是读是同步的,如果PageCache没有命中,需要从磁盘读取,很影响性能。如果是顺序读的话PageCache便可以进行预读策略,异步读取该Page之后的Page,等到用户程序再次发起读请求,数据已经在PageCache里,大幅度减少IO的次数,不用阻塞读系统调用,提升读的性能。
映射层
映射层是在PageCache之下的一层,由多个文件系统(Ext系列、XFS等,打开文件系统的文件)以及块设备文件(直接打开裸设备文件)组成,主要完成两个工作:
- 内核确定该文件所在文件系统或者块设备的块大小,并根据文件大小计算所请求数据的长度以及所在的逻辑块号。
- 根据逻辑块号确定所请求数据的物理块号,也即在在磁盘上的真正位置。
由于通用块层以及之后的的IO都必须是块大小对齐的,我们通过DIO打开文件时,略过了PageCache,所以必须要自己将IO数据的offset、length对齐到块大小。
我们使用的DIO+Libaio直接打开裸设备时,跳过了文件系统,少了文件系统的种种开销,然后进入通用块层,继续之后的处理。
通用块层
通用块层存在的意义也和VFS一样,屏蔽底层不同设备驱动的差异性,提供统一的、抽象的通用块层API。
通用块层最核心的数据结构便是bio
,描述了从上层提交的一次IO请求。
https://github.com/torvalds/linux/blob/v4.16/include/linux/blk_types.h#L96
struct bio { ...... // 要提交到磁盘的多段数据 struct bio_vec *bi_io_vec; // 有多少段数据 unsigned short bi_vcnt; ...... } struct bio_vec { struct page *bv_page; unsigned int bv_len; unsigned int bv_offset; };
所有到通用块层的IO,都要把数据封装成bio_vec
的形式,放到bio
结构体内。
在VFS层的读请求,是以Page为单位读取的,如果改Page不在PageCache内,那么便要调用文件系统定义的read_page函数从磁盘上读取数据。
const struct address_space_operations xfs_address_space_operations = { ...... .readpage = xfs_vm_readpage, .readpages = xfs_vm_readpages, .writepage = xfs_vm_writepage, .writepages = xfs_vm_writepages, ...... };
IO调度层
Linux调度层是Linux IO体系中的一个重要组件,介于通用块层和块设备驱动层之间。IO调度层主要是为了减少磁盘IO的次数,增大磁盘整体的吞吐量,会队列中的多个bio进行排序和合并,并且提供了多种IO调度算法,适应不同的场景。
Linux内核为每一个块设备维护了一个IO队列,item是struct request
结构体,用来排队上层提交的IO请求。一个request包含了多个bio,一个IO队列queue了多个request。
struct request { ...... // total data len unsigned int __data_len; // sector cursor sector_t __sector; // first bio struct bio *bio; // last bio struct bio *biotail; ...... }
上层提交的bio有可能分配一个新的request结构体去存放,也有可能合并到现有的request中。
Linux内核目前提供了以下几种调度策略:
- Deadline:默认的调度策略,加入了超时的队列。适用于HDD。
- CFQ:完全公平调度器。
- Noop:No Operation,最简单的FIFIO队列,不排序会合并。适用于SSD、NVME。
块设备驱动层
每一类设备都有其驱动程序,负责设备的读写。IO调度层的请求也会交给相应的设备驱动程序去进行读写。大部分的磁盘驱动程序都采用DMA的方式去进行数据传输,DMA控制器自行在内存和IO设备间进行数据传送,当数据传送完成再通过中断通知CPU。
通常块设备的驱动程序都已经集成在了kernel里面,也即就算我们直接调用块设备驱动驱动层的代码还是要经过内核。
spdk实现了用户态、异步、无锁、轮询方式NVME驱动程序。块存储是延迟非常敏感的服务,使用NVME做后端存储磁盘时,便可以使用spdk提供的NVME驱动,缩短IO流程,降低IO延迟,提升IO性能。
物理设备层
物理设备层便是我们经常使用的HDD、SSD、NVME等磁盘设备了。
FAQ
1、write返回成功数据落盘了吗?
Buffered IO:write返回数据仅仅是写入了PageCache,还没有落盘。
Direct IO:write返回数据仅仅是到了通用块层放入IO队列,依旧没有落盘。
此时设备断电、宕机仍然会发生数据丢失。需要调用fsync或者fdatasync把数据刷到磁盘上,调用命令时,磁盘本身缓存(DiskCache)的内容也会持久化到磁盘上。
2、write系统调用是原子的吗?
write系统调用不是原子的,如果有多线程同时调用,数据可能会发生错乱。可以使用O_APPEND
标志打开文件,只能追加写,这样多线程写入就不会发生数据错乱。
3、mmap相比read、write快在了哪里?
mmap直接把PageCache映射到用户态,少了一次系统调用,也少了一次数据在用户态和内核态的拷贝。
mmap通常和read搭配使用:写入使用write+sync,读取使用mmap。
4、为什么Direct IO需要数据对齐?
DIO跳过了PageCache,直接到通用块层,而通用块层的IO都必须是块大小对齐的,所以需要用户程序自行对齐offset、length。
5、Libaio的IO栈?
write()--->sys_write()--->vfs_write()--->通用块层--->IO调度层--->块设备驱动层--->块设备
6、为什么需要 by pass pagecache?
当应用程序不满Linux内核的Cache策略,有更适合自己的Cache策略时可以使用Direct IO跳过PageCache。例如Mysql。
7、为什么需要 by pass kernel?
当应用程序对延迟极度敏感时,由于Linux内核IO栈有7层,IO路径比较长,为了缩短IO路径,降低IO延迟,可以by pass kernel,直接使用用户态的块设备驱动程序。例如spdk的nvme,阿里云的ESSD。
8、为什么需要直接操作裸设备?
当应用程序仅仅使用了基本的read、write,用不到文件系统的大而全的功能,此时文件系统的开销对于应用程序来说是一种累赘,此时需要跳过文件系统,接管裸设备,自己实现磁盘分配、缓存等功能,通常使用DIO+Libaio+裸设备。例如Ceph FileStore的Journal、Ceph BlueStore。