第四章 文件读写过程以及性能
3.1 read 文件一个字节会发生多大的磁盘 IO
大家好,我是飞哥!
在日常开发中一些看似司空见惯的问题上,我觉得可能大多数人其实并没有真正理解,或者理解的不够透彻。不信我们来看以下一段简单的读取文件的代码:

上图中的代码仅仅只是对某个文件读取了一个字节,基于这个代码片段我们来思考:
- 读取文件 1 个字节是否会导致磁盘 IO?
- 如果发生了磁盘 IO,那发生的是多大的 IO 呢?
大家平时用的各种语言 C++、PHP、Java、Go 啥的封装层次都比较高,把很多细节都给屏蔽的比较彻底。如果把上面的问题搞清楚,需要剖开 Linux 的内部来看 Linux 的 IO 栈。
3.1.1 大话 Linux IO 栈
废话不多说,我画了一个 Linux IO 栈的简化版本(官方的 IO 栈参考这个 Linux.IO.stack_v1.0.pdf):

通过 IO 栈可以看到,我们在应用层简单的一次 read 而已,内核就需要 IO 引擎、VFS、PageCache、通用块管理层、IO 调度层等许多个组件来进行复杂配合才能完成。
那这些组件都是干啥的呢?我们挨个简单过一遍。
IO 引擎
开发同学想要读写文件的话,在 lib 库层有很多套函数可以选择,比如 read & write,pread & pwrite。这事实上就是在选择 Linux 提供的 IO 引擎。
常见的 IO 引擎种类如下:

我们开篇中代码片用的 read 函数就属于 sync 引擎。IO 引擎仍然处于上层,它需要内核层提供的系统调用、VFS、通用块层等更底层组件的支持才能实现。
接着让我们继续深入到内核,来介绍各个内核组件。
系统调用
当进入到系统调用以后,也就进入到了内核层。
系统调用将内核中其它组件的功能进行封装,然后通过接口的形式暴露给用户进程来访问。
对于我们的读取文件的需求,系统调用需要依赖 VFS 内核组件。
VFS 虚拟文件系统
VFS 的思想就是在 Linux 上抽象一个通用的文件系统模型,对我们开发人员或者是用户提供一组通用的接口,让我们不用 care 具体文件系统的实现。VFS 提供的核心数据结构有四个,它们定义在内核源代码的 include/linux/fs.h 和 include/linux/dcache.h 中。
- superblock:Linux 用来标注具体已安装的文件系统的有关信息。
- inode:Linux 中的每一个文件/目录都有一个 inode,记录其权限、修改时间等信息。
- dentry:目录项,是路径中的一部分,所有的目录项对象串起来就是一棵 Linux 下的目录树。
- file:文件对象,用来和打开它的进程进行交互。
围绕这这四个核心数据结构,VFS 也都定义了一系列的操作方法。比如,inode 的操作方法定义 inode_operations,在它的里面定义了我们非常熟悉的 mkdir 和 rename 等。对于 file 对象,定义了对应的操作方法 file_operations,如下:
// include/linux/fs.h
struct file {
......
const struct file_operations *f_op
}
struct file_operations {
......
ssize_t (*read) (struct file *, char __user *, size_t, loff_t *);
ssize_t (*write) (struct file *, const char __user *, size_t, loff_t *);
......
int (*mmap) (struct file *, struct vm_area_struct *);
int (*open) (struct inode *, struct file *);
int (*flush) (struct file *, fl_owner_t id);
}注意 VFS 是抽象的,所以它的 file_operations 里定义的 read、write 都只是函数指针,实际中需要具体的文件系统来实现,例如 ext4 等等。
Page Cache
Page Cache。它的中文译名叫页面高速缓存。它是 Linux 内核使用的主要磁盘高速缓存,是一个纯内存的工作组件。Linux 内核使用搜索树来高效管理大量的页面。
有了它,Linux 就可以把一些磁盘上的文件数据保留在内存中,然后来给访问相对比较慢的磁盘来进行访问加速。
当用户要访问的文件的时候,如果要访问的文件 block 正好存在于 Page Cache 内,那么 Page Cache 组件直接把数据从内核态拷贝到用户进程的内存中就可以了。如果不存在,那么会申请一个新页,发出缺页中断,然后用磁盘读取到的 block 内容来填充它,下次直接使用。
看到这里,开篇的问题可能你就明白一半了,如果你要访问的文件近期访问过,那么 Linux 大概率就是从 Page cache 内存中的拷贝给你就完事,并不会有实际的磁盘 IO 发生。
不过有一种情况下,Pagecache 不会生效,那就是你设置了 DIRECT_IO 标志。
文件系统
Linux 下支持的文件系统有很多,常用的有 ext2/3/4、XFS、ZFS 等。
要用哪种文件系统是在格式化的时候指定的。因为每一个分区都可以单独进行格式化,所以一台 Linux 机器下可以同时使用多个不同的文件系统。
文件系统里提供对 VFS 的具体实现。除了数据结构,每个文件系统还会定义自己的实际操作函数。例如在 ext4 中定义的 ext4_file_operations。在其中包含的 VFS 中定义的 read 函数的具体实现:do_sync_read 和 do_sync_write。
const struct file_operations ext4_file_operations = {
.llseek = ext4_llseek,
.read = do_sync_read,
.write = do_sync_write,
.aio_read = generic_file_aio_read,
.aio_write = ext4_file_write,
......
}和 VFS 不同的是,这里的函数就是实实在在的实现了。
通用块层
文件系统还要依赖更下层的通用块层。
对上层的文件系统,通用块层提供一个统一的接口让供文件系统实现者使用,而不用关心不同设备驱动程序的差异,这样实现出来的文件系统就能用于任何的块设备。通过对设备进行抽象后,不管是磁盘还是机械硬盘,对于文件系统都可以使用相同的接口对逻辑数据块进行读写操作。
对下层。I/O 请求添加到设备的 I/O 请求队列。它定义了一个叫 bio 的数据结构来表示一次 IO 操作请求(include/linux/bio.h)。
IO 调度层
当通用块层把 IO 请求实际发出以后,并不一定会立即被执行。因为调度层会从全局出发,尽量让整体磁盘 IO 性能最大化。
对于机械硬盘来说,调度层会尽量让磁头类似电梯那样工作,先往一个方向走,到头再回来,这样整体效率会比较高一些。具体的算法有 deadline 和 cfq,算法细节就不展开了,感兴趣同学可以自行搜索。
对于固态硬盘来说,随机 IO 的问题已经被很大程度地解决了,所以可以直接使用最简单的 noop 调度器。
在你的机器上,通过 dmesg | grep -i scheduler 来查看你的 Linux 支持的调度算法。
通用块层和 IO 调度层一起为上层文件系统屏蔽了底层各种不同的硬盘、U盘的设备差异。
3.1.2 读文件过程
我们已经把 Linux IO 栈里的各个内核组件都简单介绍一遍了。现在我们再从头整体过一下读取文件的过程(图中源代码基于 Linux 3.10):

这一张长图把整个 Linux 读取文件的过程都串了一遍。
回顾开篇问题
回到开篇的第一个问题:读取文件 1 个字节是否会导致磁盘 IO?
从上述流程中可以看到,如果 Page Cache 命中的话,根本就没有磁盘 IO 产生。
INFO
所以,大家不要觉得代码里出现几个读写文件的逻辑就觉得性能会慢的不行。操作系统已经替你优化了很多很多,内存级别的访问延迟大约是 ns 级别的,比机械磁盘 IO 快了好几个数量级。如果你的内存足够大,或者你的文件被访问的足够频繁,其实这时候的 read 操作极少有真正的磁盘 IO 发生。
假如 Page Cache 没有命中,那么一定会有传动到机械轴上进行磁盘 IO 吗?
其实也不一定,为什么,因为现在的磁盘本身就会带一块缓存。另外现在的服务器都会组建磁盘阵列,在磁盘阵列里的核心硬件 Raid 卡里也会集成 RAM 作为缓存。只有所有的缓存都不命中的时候,机械轴带着磁头才会真正工作。
再看开篇的第二个问题:如果发生了磁盘 IO,那发生的是多大的 IO 呢?
如果所有的 Cache 都没有兜住 IO 读请求,那么我们来看看实际 Linux 会读取多大。真的按我们的需求来,只去读一个字节吗?
整个 IO 过程中涉及到了好几个内核组件。而每个组件之间都是采用不同长度的块来管理磁盘数据的。

- Page Cache 是以页为单位的,Linux 页大小一般是 4KB
- 文件系统是以块(block)为单位来管理的。使用
dumpe2fs可以查看,一般一个块默认是 4KB - 通用块层是以段为单位来处理磁盘 IO 请求的,一个段为一个页或者是页的一部分
- IO 调度程序通过 DMA 方式传输 N 个扇区到内存,扇区一般为 512 字节
- 硬盘也是采用“扇区”的管理和传输数据的
可以看到,虽然我们从用户角度确实是只读了 1 个字节(开篇的代码中我们只给这次磁盘IO留了一个字节的缓冲区)。但是在整个内核工作流中,最小的工作单位是磁盘的扇区,为512字节,比1个字节要大的多。
另外 block、page cache 等高层组件工作单位更大。其中 Page Cache 的大小是一个内存页 4KB。所以一般一次磁盘读取是多个扇区(512字节)一起进行的。假设通用块层 IO 的段就是一个内存页的话,一次磁盘 IO 就是 4 KB(8 个 512 字节的扇区)一起进行读取。
另外我们没有讲到的是还有一套复杂的预读取的策略。所以,在实践中,可能比 8 更多的扇区一起来被传输到内存中。
最后,啰嗦几句
操作系统的本意是做到让你简单可依赖,让你尽量把它当成一个黑盒。你想要一个字节,它就给你一个字节,但是自己默默干了许许多多的活儿。
我们虽然国内绝大多数开发都不是搞底层的,但如果你十分关注你的应用程序的性能,你应该明白操作系统的什么时候悄悄提高了你的性能,是怎么来提高的。以便在将来某一个时候你的线上服务器扛不住快要挂掉的时候,你能迅速找出问题所在。
3.2 Write 文件一个字节后何时发起写磁盘 IO
在前文《read文件一个字节实际会发生多大的磁盘IO?》写完之后,本来想着偷个懒,只通过读操作来让大家了解下Linux IO栈的各个模块就行了。但很多同学表示再让我写一篇关于写操作的。既然不少人都有这个需求,那我就写一下吧。
Linux内核真的是太复杂了,源代码的行数已经从1.0版本时的几万行,到现在已经是千万行的一个庞然大物了。直接钻进去的话,很容易在各种眼花缭乱的各种调用中迷失了自己,再也钻不出来了。我分享给大家一个我在琢磨内核的方法。一般我自己先想一个自己很想搞清楚的问题。不管在代码里咋跳来跳去,时刻都要记得自己的问题,无关的部分尽量少去发散,只要把自己的问题搞清楚了就行了。
现在我想搞明白的问题是,在最常用的方式下,不开 O_DIRECT、不开 O_SYNC(写文件的方法有很多,有sync模式、direct模式、mmap内存映射模式),write是怎么写的。c的代码示例如下:
#include <fcntl.h>
int main()
{
char c = 'a';
int out;
out = open("out.txt", O_WRONLY | O_CREAT | O_TRUNC);
write(out,&c,1);
...
}进一步细化我的问题,我们对打开的问题写入一个字节后:
- write函数在内核里是怎么执行的?
- 数据在什么时机真正能写入到磁盘上?
我们在讨论的过程中不可避免地要涉及到内核代码,我使用的内核版本是3.10.1。如果有需要,你可以到这里来下载:https://mirrors.edge.kernel.org/pub/linux/kernel/v3.x/。
write函数实现剖析
我花了不短的时间跟踪write写到ext4文件系统时的各种调用和返回,大致理出来了一个交互图。当然为了突出重点,我抛弃了不少细节,比如DIRECT IO、ext4日志记录啥的都没有体现出来,只抽取出来了一些我认为关键的调用。

在上面的流程图里,所有的写操作最终到哪儿了呢?在最后面的 __block_commit_write 中,只是 make dirty。然后大部分情况下你的函数调用就返回了(稍后再说 balance_dirty_pages_ratelimited)。数据现在还在内存中的 PageCache 里,并没有真正写到硬盘。
为什么要这样实现,不直接写硬盘呢?原因就在于硬盘尤其是机械硬盘,性能是在是太慢了。一块服务器级别的万转盘,最坏随机访问平均延迟都是毫秒级别的,换算成 IOPS 只有100多不到200。设想一下,假如你的后端接口里每个用户来访问都需要一次随机磁盘 IO,不管你多牛的服务器,每秒200的qps都将直接打爆你的硬盘,相信作为为百万/千万/过亿用户提供接口的你,这个是你绝对不能忍的。
Linux这么搞也是有副作用的,如果接下来服务器发生掉电,内存里东西全丢。所以Linux还有另外一个“补丁”——延迟写,帮我们缓解这个问题。注意下,我说的是缓解,并没有彻底解决。
再说下 balance_dirty_pages_ratelimited,虽然绝大部分情况下,都是直接写到 Page Cache 里就返回了。但在一种情况下,用户进程必须得等待写入完成才可以返回,那就是对 balance_dirty_pages_ratelimited 的判断如果超出限制了。该函数判断当前脏页是否已经超过脏页上限 dirty_bytes、dirty_ratio,超过了就必须得等待。这两个参数只有一个会生效,另外一个是0。拿 dirty_ratio 来说,如果设置的是30,就说明如果脏页比例超过内存的30%,则 write 函数调用就必须等待写入完成才能返回。可以在你的机器下的 /proc/sys/vm/ 目录来查看这两个配置。
# cat /proc/sys/vm/dirty_bytes
0
# cat /proc/sys/vm/dirty_ratio
30内核延迟写
内核是什么时候真正把数据写到硬盘中呢?为了快速摸清楚全貌,我想到的办法是用 systemtap 工具,找到内核写 IO 过程中的一个关键函数,然后在其中把函数调用堆栈打出来。查了半天资料以后,我决定用 do_writepages 这个函数。
systemtap 跟踪以后,打印信息如下:
--------------------------------------------------------
0xffffffff8118efe0 : do_writepages+0x0/0x40 [kernel]
0xffffffff8122d7d0 : __writeback_single_inode+0x40/0x220 [kernel]
0xffffffff8122e414 : writeback_sb_inodes+0x1c4/0x490 [kernel]
0xffffffff8122e77f : __writeback_inodes_wb+0x9f/0xd0 [kernel]
0xffffffff8122efb3 : wb_writeback+0x263/0x2f0 [kernel]
0xffffffff8122f35c : bdi_writeback_workfn+0x1cc/0x460 [kernel]
0xffffffff810a881a : process_one_work+0x17a/0x440 [kernel]
0xffffffff810a94e6 : worker_thread+0x126/0x3c0 [kernel]
0xffffffff810b098f : kthread+0xcf/0xe0 [kernel]
0xffffffff816b4f18 : ret_from_fork+0x58/0x90 [kernel]
--------------------------------------------------------从上面的输出我们可以看出,真正的写文件过程操作是由 worker 内核线程发出来的(和我们自己的应用程序进程没有半毛钱关系,此时我们的应用程序的 write 函数调用早就返回了)。这个 worker 线程写回是周期性执行的,它的周期取决于内核参数 dirty_writeback_centisecs 的设置,根据参数名也大概能看出来,它的单位是百分之一秒。
# cat /proc/sys/vm/dirty_writeback_centisecs
500我查看到我的配置是500,就是说每5秒会周期性地来执行一遍。回顾我们的问题,我们最关心的问题的啥时候写入的,围绕这个思路不过多发散。于是沿着这个调用栈不断地跟踪,跳转,终于找到了下面的代码。如下代码里我们看到,如果是 for_background 模式,且 over_bground_thresh 判断成功,就会开始回写了。
static long wb_writeback(struct bdi_writeback *wb,
struct wb_writeback_work *work)
{
work->older_than_this = &oldest_jif;
...
if (work->for_background && !over_bground_thresh(wb->bdi))
break;
...
if (work->for_kupdate) {
oldest_jif = jiffies -
msecs_to_jiffies(dirty_expire_interval * 10);
} else ...
}
static long wb_check_background_flush(struct bdi_writeback *wb)
```c
static long wb_check_background_flush(struct bdi_writeback *wb)
{
if (over_bground_thresh(wb->bdi)) {
...
return wb_writeback(wb, &work);
}
}那么 over_bground_thresh 函数判断的是啥呢?其实就是判断当前的脏页是不是超过内核参数里 dirty_background_ratio 或 dirty_background_bytes 的配置,没超过的话就不写了(代码位于 fs/fs-writeback.c:1440,限于篇幅我就不贴了).这两个参数只有一个会真正生效,其中 dirty_background_ratio 配置的是比例、dirty_background_bytes 配置的是字节.
在我的机器上的这两个参数配置如下,表示脏页比例超过10%就开始回写.
# cat /proc/sys/vm/dirty_background_bytes
0
# cat /proc/sys/vm/dirty_background_ratio
10那如果脏页一直都不超过这个比例怎么办呢,就不写了吗?不是的.在上面的 wb_writeback 函数中我们看到了,如果是 for_kupdate 模式,会记录一个过期标记到 work->older_than_this,再往后面的代码中把符合这个条件的页面也写回了.dirty_expire_interval 这个变量是从哪儿来的呢?在 kernel/sysctl.c 里,我们发现了蛛丝马迹.哦,原来它是来自 /proc/sys/vm/dirty_expire_centisecs 这个配置.
1158 {
1159 .procname = "dirty_expire_centisecs",
1160 .data = &dirty_expire_interval,
1161 .maxlen = sizeof(dirty_expire_interval),
1162 .mode = 0644,
1163 .proc_handler = proc_dointvec_minmax,
1164 .extra1 = &zero,
1165 },# cat /proc/sys/vm/dirty_expire_centisecs
3000在我的机器上,它的值是3000。单位是百分之一秒,所以就是脏页过了30秒就会被内核线程认为需要写回到磁盘了。
结论
我们demo代码中的写入,其实绝大部分情况都是写入到 PageCache 中就返回了,这时并没有真正写入磁盘。我们的数据会在如下三个时机下被真正发起写磁盘IO请求:
- 第一种情况:如果 write 系统调用时,如果发现 PageCache 中脏页占比太多,超过了
dirty_ratio或dirty_bytes,write 就必须等待了。 - 第二种情况:write 写到 PageCache 就已经返回了。worker 内核线程异步运行的时候,再次判断脏页占比,如果超过了
dirty_background_ratio或dirty_background_bytes,也发起写回请求。 - 第三种情况:这时同样 write 调用已经返回了。worker 内核线程异步运行的时候,虽然系统内脏页一直没有超过
dirty_background_ratio或dirty_background_bytes,但是脏页在内存中呆的时间超过dirty_expire_centisecs了,也会发起回写。
NOTE
如果对以上配置不满意,你可以自己通过修改
/etc/sysctl.conf来调整,修改完了别忘了执行sysctl -p。
最后我们要认识到,这套 write pagecache+回写的机制第一目标是性能,不是保证不丢失我们写入的数据的。如果这时候掉电,脏页时间未超过 dirty_expire_centisecs 的就真的丢了。如果你做的是和钱相关非常重要的业务,必须保证落盘完成才能返回,那么你就可能需要考虑使用 fsync。
Github:https://github.com/yanfeizhang/coder-kung-fu
欢迎关注个人公众号「开发内功修炼」,了解你的每一比特,用好你的每一纳秒。
有想继续加入知识星球的同学微信扫描下面的二维码即可加入。另外在公众号后台发送「星球优惠券」可以获取开发内功修炼读者的专属优惠券。

