11.3 文件系统与 IO 模型 (阻塞 / 非阻塞 / 异步)
一句话
文件系统把 “路径” 翻译成 “块”,中间隔着一层 page cache;IO 模型解决的是 “等待数据时进程在干什么”——阻塞、非阻塞、多路复用、信号驱动、异步,五种选择决定了你能撑多少并发。
从 write 到磁盘:四层
| 层 | 做什么 | 关键点 |
|---|---|---|
| VFS | 统一接口(inode/dentry/file) | 路径解析有缓存(dcache) |
| 具体文件系统 | ext4/xfs/btrfs:块分配、日志 | journal 影响写放大 |
| page cache | 缓存文件页 | write 默认只写缓存 |
| 块层 + 设备 | 调度、合并、下发 | SSD/云盘决定真实延迟 |
所以 write() 返回成功 ≠ 落盘;要落盘必须 fsync()(或 O_SYNC)。
五种 IO 模型
| 模型 | 等待数据 | 等待就绪 | 典型调用 | 适合 |
|---|---|---|---|---|
| 阻塞 | 进程睡 | 进程睡 | read | 简单、低并发 |
| 非阻塞 | 轮询 | 轮询 | read + O_NONBLOCK | 极少单独使用 |
| 多路复用 | 睡在 select/poll/epoll | 睡 | epoll_wait | 高并发网络(见 11.4 epoll 与 IO 多路复用) |
| 信号驱动 | 睡 | 信号回调 | SIGIO | 罕见 |
| 异步 | 完全不阻塞 | 内核完成后通知 | io_uring / POSIX AIO | 高吞吐存储、减少 syscall |
关键区别:前四种在 “数据从内核拷到用户态” 这一步仍是同步的;只有真异步(io_uring)把这一步也交给内核。
缓冲 vs 直接 IO
| 方式 | 路径 | 优点 | 缺点 |
|---|---|---|---|
| 缓冲 IO(默认) | 用户缓冲 ↔ page cache ↔ 设备 | 快、可合并、预读 | 双份内存;掉电丢缓存 |
O_DIRECT | 用户缓冲 ↔ 设备 | 绕过 cache,可预测 | 对齐要求、失去预读、通常更慢 |
mmap | 用户地址空间映射 page cache | 零拷贝读、随机访问方便 | 缺页开销、文件截断即 SIGBUS |
落到数字:五种模型的阻塞点
| 模型 | 等待数据 | 拷到用户态 | 单线程可管连接 |
|---|---|---|---|
| 阻塞 IO | 进程睡 | 同步 | 1 |
| 非阻塞 | 轮询(浪费 CPU) | 同步 | 1 |
| epoll | 内核事件表 | 同步 | 万级 |
| 信号驱动 | 信号 | 同步 | 少用 |
io_uring | 内核队列 | 内核完成 | 万级 + 低 syscall |
strace -c -p <pid> # 按 syscall 统计耗时占比:先看 read/write 还是 futex
iostat -x 1 3 # 设备侧:await 高说明排队,%util 高说明饱和
反模式
| 反模式 | 后果 |
|---|---|
每条记录 fsync | IOPS 被打满(每次都是设备级等待) |
以为 write 成功就落盘 | 掉电丢数据 |
用 O_DIRECT “优化” 小随机读 | 失去预读与合并,通常更慢 |
| 单线程阻塞 IO 做高并发 | 一个慢请求拖住所有连接 |
忽略 io_uring 的内核版本要求 | 老内核上回退到同步,性能不达预期 |
三条铁律
🔑 write 进缓存,fsync 才落盘;可靠性要求决定 fsync 频率。
🔑 并发靠 “等待方式”:能睡在 epoll 上,就不要每连接一个线程。
🔑
O_DIRECT是特殊工具:数据库/自缓存系统用,普通服务别用。
自检
Q1 服务 “写日志很慢”,可能是什么原因?
▸ 答案:大概率是每条日志都 fsync(或用了 O_SYNC):每次写都要等设备确认,机械盘/网络盘上直接变成 ms 级。改成 “写内存缓冲 + 批量刷盘”(如每 100ms 或每 N 条 fsync 一次),吞吐能提升一到两个数量级。
Q2 为什么 write() 返回成功,进程崩溃后数据却丢了?
▸ 答案:因为默认是缓冲写:数据只进了 page cache,尚未落盘;进程崩溃不影响 page cache(数据还在),但机器掉电就会丢。要保证 “崩溃后可恢复”,必须在关键点 fsync(或依赖 WAL 的 fsync 策略)。
Q3 高并发网络服务为什么用 epoll 而不是 “每连接一线程”?
▸ 答案:因为线程是昂贵资源:每线程有栈、上下文切换、TLB/cache 失效。epoll 让一个线程管理上万连接,只在就绪时处理,把 “等待” 的成本从线程换成内核事件表(见 11.4 epoll 与 IO 多路复用)。
Q4 io_uring 相比 epoll 解决了什么?
▸ 答案:epoll 解决 “等就绪”,但数据拷贝与提交仍是同步 syscall;io_uring 用共享内存环形队列提交/回收请求,支持真正的异步读写(含文件 IO),大幅减少 syscall 与上下文切换。代价是内核版本要求(5.1+,生产建议 5.10+)与编程复杂度。
Q5 mmap 读大文件一定比 read 快吗?
▸ 答案:不一定。mmap 省掉了用户态拷贝,随机访问友好,但会引入缺页开销;顺序读用 read + 预读往往更快。mmap 还有个坑:文件被截断后访问映射区会触发 SIGBUS,需要处理信号或避免共享可变文件。
Q6 iostat 的 %util 接近 100% 一定说明磁盘是瓶颈吗?
▸ 答案:不一定。%util 表示 “设备有未完成请求的时间占比”,在支持并行的 NVMe 上,100% 也可能还有余量;真正的判据是 await(排队延迟)与应用的 P99 是否同步恶化。机械盘上 %util 100% 通常确实饱和。