4. IO栈与内核机制

在 Android 系统中,一次看似简单的文件读写,往往要穿过从应用到硬件的一整条 IO 栈。理解这条栈的层次与各层职责,是定位 IO 性能瓶颈、判断优化方向的基础。本节从 VFS 到块设备,逐层拆解一次 IO 请求的完整旅程,并重点对比直接 IO 与缓冲 IO 的差异。

一、IO 栈的整体分层结构

Android 基于 Linux 内核,其 IO 路径自上而下大致分为五层:应用层、VFS 层、文件系统层、页缓存、块层与 IO 调度器,最终到达物理存储设备。每一层都有明确的抽象职责,上层无需关心下层的实现细节。

Android IO 栈分层与数据流 应用层 Java/Kotlin File API · NDK read/write · SQLite · SharedPreferences 用户态 系统调用边界 (syscall) VFS 虚拟文件系统 统一抽象:inode · dentry · file · super_block · file_operations 内核态 具体文件系统 ext4 · f2fs · erofs · FAT/exFAT · 日志与元数据管理 页缓存 Page Cache 以 4KB 页为单位缓存文件数据 · 回写 writeback · 脏页管理 块层 Block Layer bio 请求 · 合并 merge · 拆分 split · 请求队列 request_queue IO 调度器 none · mq-deadline · BFQ · kyber · 合并与排序策略 物理存储设备 (UFS / eMMC / NVMe) 直接 IO 旁路页缓存

二、VFS:统一抽象的入口

VFS(Virtual File System)是内核中承上启下的关键层。它定义了一组通用对象模型:super_block 描述已挂载的文件系统,inode 描述文件元数据,dentry 描述目录项,file 描述进程打开的文件实例。无论底层是 ext4、f2fs 还是 erofs,应用调用 read() 时都通过 VFS 的 file_operations 函数指针分发到具体文件系统实现。

VFS 的价值在于解耦:应用只面向统一的 POSIX 接口,文件系统只需实现自己的操作集。Android 中常见的 /data 使用 f2fs,/system 使用 erofs,/sdcard 可能使用 FAT/exFAT,但应用代码无需区分。

三、页缓存:性能的关键缓冲

页缓存是内核用空闲内存缓存文件数据页的机制,默认页大小为 4KB。当应用读取文件时,内核先查页缓存:命中则直接拷贝到用户缓冲区,无需访问磁盘;未命中则触发缺页,从块设备读取并填充缓存。

写入时默认采用回写(writeback)策略:数据先写入页缓存并标记为脏页,由内核回写线程在合适时机批量刷盘。这带来两个重要影响:一是写操作延迟极低,二是数据在掉电时可能丢失。Android 中可通过 fsync() 强制刷盘,但频繁调用会显著增加 IO 延迟。

# 查看页缓存与脏页情况
cat /proc/meminfo | grep -E "Cached|Dirty|Writeback"

# 手动触发回写
sync

# 查看某进程的 IO 统计
cat /proc/<pid>/io

四、块层与 IO 调度器

文件系统将 IO 请求转化为 bio 结构,块层负责将其合并、排序并放入请求队列。IO 调度器决定这些请求以何种顺序下发给设备,直接影响随机 IO 的吞吐与延迟。

调度器 适用场景 特点
none NVMe / UFS 等高速设备 不做排序,直接下发,依赖设备自身并行能力
mq-deadline 通用场景 保证请求在截止时间内完成,兼顾吞吐与延迟
BFQ 桌面/交互敏感场景 按权重分配带宽,交互流畅但吞吐略低
kyber 低延迟设备 基于延迟目标控制队列深度

Android 设备通常根据存储类型选择调度器。UFS 设备多使用 none 或 mq-deadline,以充分发挥硬件并行度。

五、直接 IO 与缓冲 IO 的对比

缓冲 IO 是默认路径,数据经过页缓存,适合大多数场景,尤其是重复读取和小块写入。直接 IO 通过 O_DIRECT 标志打开文件,绕过页缓存,数据在用户空间与设备之间直接传输。

核心差异:缓冲 IO 以内存换延迟,适合读多写少、访问局部性强的场景;直接 IO 以延迟换可控性,适合数据库、大文件顺序读写等需要精确控制 IO 的场景。
// 缓冲 IO:默认路径,经过页缓存
int fd = open("/data/test.dat", O_RDWR);
read(fd, buf, 4096);

// 直接 IO:绕过页缓存,需对齐
int fd = open("/data/test.dat", O_RDWR | O_DIRECT);
posix_memalign(&buf, 4096, 4096);  // 缓冲区需按页对齐
read(fd, buf, 4096);

直接 IO 有严格的对齐要求:缓冲区地址、文件偏移、读写长度通常都需按块大小(512B 或 4KB)对齐,否则会返回 EINVAL。Android 中 SQLite 在 WAL 模式下会谨慎使用直接 IO,以平衡一致性与性能。

六、典型场景与误区复盘

场景:某应用启动时读取大量配置文件,首次启动耗时 800ms,二次启动仅 120ms。原因正是页缓存命中:首次读取需从存储加载,二次读取直接从内存返回。

误区一:认为 write() 返回即数据已落盘。实际上数据仅进入页缓存,掉电可能丢失。需要持久化时应调用 fsync()。

误区二:盲目使用直接 IO 追求“高性能”。直接 IO 绕过了缓存合并与预读,小块随机读写性能反而更差,且对齐要求增加开发复杂度。

排障提示:当出现 IO 延迟抖动时,优先检查 /proc/meminfo 中的 Dirty 页数量。脏页过多会触发集中回写,造成 IO 毛刺。可通过调整 vm.dirty_ratio 和 vm.dirty_background_ratio 平滑回写压力。