IO栈深度剖析:VFS层原理与关键数据结构,Page Cache工作机制与回写策略,Block Layer调度算法

好,咱们今天来啃一块硬骨头——IO栈。说实话,很多做Android优化的同学,一听到IO栈就头大。层数太多,概念太杂,感觉像在翻一座山。但我想说,这座山你必须翻过去。为什么?因为IO性能瓶颈,十有八九就藏在这些层里。

我个人习惯把IO栈分成三层来看:最上面的VFS(虚拟文件系统),中间的Page Cache,最下面的Block Layer。咱们一层一层剥开。

VFS层:一切皆文件的基石

VFS,全称Virtual File System。说白了,它就是Linux内核搞的一个抽象层。你想想看,Android设备上有ext4、f2fs、sdcardfs,甚至还有各种网络文件系统。如果没有VFS,每个文件系统都得自己实现一套open、read、write接口,那应用层代码就没法写了。

VFS的核心思想就四个字:统一接口。它定义了一套标准的数据结构和操作函数,所有具体的文件系统只要实现这些接口,就能被内核统一管理。

这里有几个关键数据结构,我建议你背下来:

  • super_block:代表一个已挂载的文件系统实例。每个分区挂载后,内核都会创建一个super_block。它里面存了文件系统的元信息,比如块大小、操作函数表。
  • inode:代表一个文件或目录。注意,inode存的是文件的元数据(权限、大小、时间戳),不是文件名。文件名在dentry里。
  • dentry:目录项,负责把文件名和inode关联起来。VFS会缓存dentry,这样下次访问同一个路径时就不用重新解析了。
  • file:代表一个打开的文件描述符。它指向一个dentry,同时记录了当前读写位置。

重点提醒:VFS层最容易被忽视的性能陷阱是dentry缓存失效。我曾经在一个项目里遇到文件访问延迟飙升,查了半天,发现是dentry缓存被频繁清空导致的。每次访问都要重新解析路径,那IO性能能好才怪。

Page Cache:读写性能的加速器

好,VFS层往下走,就是Page Cache了。这个机制,说白了就是拿内存换IO速度。

你每次读文件,内核不会直接去磁盘上读,而是先看看Page Cache里有没有。如果有,直接返回内存中的数据,速度比磁盘快几个数量级。写操作也一样,数据先写到Page Cache,然后内核再异步地刷回磁盘。

Page Cache的单位是页,通常是4KB。每个页对应一个文件中的一个数据块。内核用一棵基数树(radix tree,现在叫xarray)来管理这些页,查找速度非常快。

这里有个关键问题:什么时候把脏页写回磁盘?这就是回写策略要干的事。

Linux内核的回写策略主要有几个参数:

参数 默认值 说明
dirty_background_ratio 10% 当脏页占内存比例超过此值时,后台内核线程开始回写
dirty_ratio 20% 当脏页比例超过此值时,进程自己的写操作会被阻塞,强制回写
dirty_expire_centisecs 3000(30秒) 脏页超过这个时间还没写回,就会被内核线程刷出
dirty_writeback_centisecs 500(5秒) 内核回写线程的唤醒间隔

我的经验:在Android设备上,我建议适当调低dirty_background_ratio。因为手机的内存本来就紧张,如果脏页积压太多,一旦触发dirty_ratio的硬限制,前台进程的写操作会被卡住,用户能明显感觉到卡顿。我曾经在一个低端机上把dirty_background_ratio从10%调到5%,写文件时的抖动明显减少了。

嗯,这里要注意一点:Page Cache不是越大越好。它占的是宝贵的内存资源。如果内存不足,内核会回收Page Cache,这个过程叫页面回收。回收时如果遇到脏页,还得先写回磁盘,这就产生了额外的IO。

Block Layer:IO请求的调度中枢

再往下走,就是Block Layer了。Page Cache里的数据最终要落到磁盘上,怎么落?谁来排队?谁先谁后?这就是Block Layer的活。

Block Layer的核心是IO调度器。它负责把上层的IO请求重新排序、合并,然后发给底层的块设备驱动。不同的调度算法,对性能的影响天差地别。

咱们重点看三种:

CFQ(完全公平队列)

CFQ的理念是公平。它为每个进程维护一个独立的IO队列,然后按时间片轮转。这样每个进程都能分到IO带宽,不会出现某个进程饿死的情况。

但公平的代价是什么?延迟。CFQ为了公平,会把请求打散,导致顺序读写的性能下降。在Android上,我基本不用CFQ。因为手机上的IO场景大多是随机小文件读写,公平性没那么重要,延迟才是关键。

Deadline(截止时间调度器)

Deadline的思路很直接:给每个IO请求设置一个截止时间。读请求的截止时间通常比写请求短(默认读500ms,写5s)。为什么?因为读操作往往影响前台响应,写操作可以等一等。

Deadline会优先处理即将到期的请求,同时还会做请求合并。我个人比较喜欢这个调度器,它在延迟和吞吐量之间取得了不错的平衡。

避坑指南:我曾经在一个项目里遇到写放大问题。用Deadline调度器时,如果写请求太多,读请求的截止时间会被频繁推迟,导致读延迟飙升。后来我调整了读写请求的优先级权重,才把问题解决。所以,Deadline不是万能的,你得根据实际负载来调参。

MQ-Deadline(多队列Deadline)

这是NVMe SSD时代的新宠。传统的单队列调度器,在多核CPU上会面临锁竞争问题。MQ-Deadline把IO队列拆成多个,每个CPU核心有自己的提交队列和完成队列,大大减少了锁冲突。

在Android高端机型上,UFS 3.0/4.0存储的性能已经非常接近NVMe SSD了。我建议在这些设备上优先使用MQ-Deadline。它的吞吐量比传统Deadline高出20%以上,而且延迟更稳定。

你想想看,为什么Android从某个版本开始默认调度器从CFQ换成了MQ-Deadline?就是因为多核CPU和高速存储的普及,让多队列的优势体现出来了。

最后,我总结一下这三层的关系:

  • VFS层:帮你把不同文件系统统一起来,让你用同样的API操作不同设备。
  • Page Cache:用内存缓存数据,减少磁盘访问次数。但要注意脏页回写策略,别让内存变成IO的瓶颈。
  • Block Layer:决定IO请求怎么排队、怎么合并、怎么下发。调度算法的选择,直接影响你的IO延迟和吞吐量。

这三层,每一层都有优化的空间。但说实话,大部分Android开发者连Page Cache的参数都没调过。你如果能把这层吃透,性能优化的功力至少提升一个档次。

好,今天就先聊到这儿。下一章咱们深入看看IO性能的监控工具,怎么用ftrace和perf把IO栈的每一层都看透。