4. I/O 调度器选择:CFQ、Deadline、NOOP、BFQ 调度器原理与适用场景
文件系统调优里,I/O 调度器是个容易被忽略的环节。很多人觉得选默认的就行,其实不然。我见过不少嵌入式设备,换个调度器,读写性能直接翻倍。今天咱们就把这几个调度器掰开揉碎讲清楚。
4.1 调度器到底在干什么?
说白了,I/O 调度器就是块设备层的“交通警察”。多个进程同时发读写请求,谁先谁后?怎么合并?怎么排队?调度器说了算。
Linux 内核里,调度器位于通用块层和块设备驱动之间。它接收上层的 bio 请求,经过排序、合并、分发,再交给驱动去处理硬件。
核心职责:
- 请求合并:把相邻的扇区请求合并成一个,减少寻道次数
- 请求排序:按扇区顺序重排,减少磁头移动
- 公平调度:保证多个进程都能分到 I/O 时间片
- 延迟控制:确保关键请求不被饿死
4.2 四大调度器逐个拆解
4.2.1 CFQ(完全公平队列)
CFQ 是 Linux 内核用了很多年的默认调度器。它的思路很朴素:给每个进程分配一个 I/O 时间片,大家轮流用。有点像操作系统的进程调度,只不过这里调度的是磁盘访问权。
工作原理:
- 每个进程有自己的 I/O 请求队列
- 按时间片轮转方式处理各队列
- 支持优先级(ionice 命令就是跟它配合的)
- 同步请求优先于异步请求
适用场景:
- 通用桌面系统、多用户环境
- 需要公平分配 I/O 带宽的场景
- 机械硬盘(HDD)上表现不错
不适用场景:
- SSD/NAND Flash(寻道时间几乎为零,排序合并意义不大)
- 高并发数据库(延迟抖动较大)
- 实时性要求高的嵌入式系统
我的经验: 在某个工业控制项目中,CFQ 导致数据采集进程偶尔出现 200ms 以上的 I/O 延迟。换成 Deadline 后,最大延迟降到了 30ms 以内。如果你的系统对 I/O 延迟敏感,CFQ 可能不是最佳选择。
4.2.2 Deadline(截止时间调度器)
Deadline 的设计目标很明确:保证每个请求都不会被饿死太久。它给每个请求设置了一个“截止时间”,默认读请求 500ms,写请求 5s。一旦超时,这个请求会被优先处理。
工作原理:
- 维护两个队列:排序队列(按扇区排序)和 FIFO 队列(按截止时间排序)
- 正常情况下按排序队列处理,提高吞吐量
- 当有请求接近截止时间时,切换到 FIFO 队列优先处理
- 读请求优先级高于写请求(读饥饿保护)
适用场景:
- 数据库服务器(对读延迟敏感)
- 嵌入式实时系统
- 混合读写负载
- SSD 和 HDD 都适用
关键参数:
| 参数 | 默认值 | 说明 |
|---|---|---|
| read_expire | 500 ms | 读请求截止时间 |
| write_expire | 5000 ms | 写请求截止时间 |
| fifo_batch | 16 | 每次批量处理的请求数 |
| writes_starved | 2 | 读优先处理次数后,才处理一次写 |
4.2.3 NOOP(电梯调度器)
NOOP 是最简单的调度器。它几乎不做排序和合并,只维护一个简单的 FIFO 队列。新请求来了直接插到队尾,或者跟相邻的请求合并一下。
你可能会问:这么简单有什么用?
嗯,对于 SSD 和 NAND Flash 这类随机访问性能很好的设备,排序和合并带来的收益微乎其微。反而调度器本身的 CPU 开销成了负担。这时候 NOOP 就是最佳选择。
适用场景:
- SSD、NVMe、eMMC、UFS 等闪存设备
- CPU 资源紧张的嵌入式系统
- 底层硬件自己做了 I/O 调度(如某些 RAID 卡、NVMe 控制器)
注意: 我曾经在一个使用 NAND Flash 的物联网设备上测试过,NOOP 相比 CFQ 能降低约 15% 的 CPU 占用。但要注意,如果设备同时有大量顺序读写和随机读写混合,NOOP 可能会导致吞吐量下降。建议先做压力测试再决定。
4.2.4 BFQ(预算公平队列)
BFQ 是 CFQ 的升级版,也是目前比较新的调度器。它把 I/O 调度做得更精细——不仅分配时间片,还分配“预算”(budget)。预算就是每次可以传输的数据量。
工作原理:
- 每个进程有独立的 I/O 预算(默认约 4MB)
- 预算用完后,切换到下一个进程
- 低延迟进程(如交互式应用)会获得更小的预算,更频繁地被调度
- 支持 cgroup 的 I/O 带宽控制
适用场景:
- 桌面系统(交互式体验好)
- 多媒体播放、音视频处理
- 需要细粒度 I/O 控制的场景
- 机械硬盘和 SSD 都适用
我的建议: BFQ 在 Android 系统上用得比较多,因为能明显提升 UI 操作的流畅度。但在纯后台服务的嵌入式设备上,BFQ 的调度开销比 Deadline 高 5%-10%,不一定划算。
4.3 如何选择和切换调度器
查看当前调度器很简单:
# 查看所有块设备的调度器
cat /sys/block/sda/queue/scheduler
# 输出示例:[mq-deadline] none
# 方括号里的是当前使用的调度器
临时切换:
echo deadline > /sys/block/sda/queue/scheduler
永久生效(以 root 身份):
# 修改内核启动参数,在 /etc/default/grub 中添加
GRUB_CMDLINE_LINUX="elevator=deadline"
# 或者使用 udev 规则
echo 'ACTION=="add|change", KERNEL=="sd*", ATTR{queue/scheduler}="deadline"' > /etc/udev/rules.d/60-iosched.rules
选择建议速查表:
| 设备类型 | 推荐调度器 | 理由 |
|---|---|---|
| 机械硬盘(HDD) | Deadline 或 BFQ | 减少寻道时间,保证延迟 |
| SATA SSD | Deadline 或 NOOP | 延迟可控,开销低 |
| NVMe | NOOP(none) | 硬件调度足够好,软件调度是累赘 |
| eMMC / NAND Flash | NOOP 或 Deadline | 减少 CPU 开销 |
| 实时系统 | Deadline | 截止时间保证 |
| 桌面/多媒体 | BFQ | 交互体验好 |
4.4 ionice 命令:进程级 I/O 优先级控制
ionice 可以给单个进程设置 I/O 优先级。它只对 CFQ 和 BFQ 调度器生效,Deadline 和 NOOP 不支持。
优先级等级:
- Idle(空闲,优先级 3): 只有磁盘空闲时才执行 I/O,适合后台备份、日志清理
- Best-effort(尽力而为,优先级 0-7): 默认等级,0 最高,7 最低
- Real-time(实时,优先级 0-7): 优先于所有 Best-effort 进程,慎用
常用用法:
# 查看当前 I/O 优先级
ionice -p 1234
# 以 Best-effort 等级 0 启动程序
ionice -c 2 -n 0 ./my_app
# 将正在运行的进程改为 Idle 等级
ionice -c 3 -p 1234
# 实时等级(谨慎使用)
ionice -c 1 -n 0 ./critical_app
避坑指南: 我曾经在一个视频监控项目中,把录像存储进程设成了 Idle 等级。结果磁盘压力大时,录像直接丢帧了。后来改成 Best-effort 等级 4,既保证了录像不丢,又不影响其他进程。记住:Idle 等级真的会被饿死。
4.5 块设备队列深度调整
队列深度(queue_depth)决定了块设备驱动一次能处理多少个未完成的 I/O 请求。这个参数对性能影响很大。
查看和调整:
# 查看当前队列深度
cat /sys/block/sda/device/queue_depth
# 临时调整(以 NVMe 为例)
echo 64 > /sys/block/nvme0n1/device/queue_depth
# 对于 SCSI 设备
echo 32 > /sys/block/sda/device/queue_depth
队列深度对性能的影响:
| 队列深度 | 随机读(IOPS) | 延迟(ms) | 适用场景 |
|---|---|---|---|
| 1-4 | 低 | 低(稳定) | 实时系统、单线程应用 |
| 8-16 | 中等 | 中等 | 通用嵌入式系统 |
| 32-64 | 高 | 较高(波动大) | 高并发服务器、数据库 |
| 128+ | 极高 | 高(可能超时) | NVMe 高性能场景 |
我的调优经验: 队列深度不是越大越好。我在一个使用 eMMC 的工控板上测试过,队列深度从 32 降到 8,随机读 IOPS 虽然下降了 20%,但 99% 延迟从 50ms 降到了 8ms。对于实时性要求高的场景,牺牲一点吞吐量换延迟稳定性,很值得。
调优步骤建议:
- 先用 fio 测试当前队列深度下的性能基线
- 逐步增大队列深度(2 倍递增),观察 IOPS 和延迟变化
- 找到 IOPS 不再明显增长的拐点
- 在拐点附近,根据延迟要求微调
- 固化到启动脚本或 udev 规则中
4.6 本章小结
调度器选择和队列深度调整,说白了就是吞吐量和延迟之间的权衡。没有银弹,只有适合你场景的方案。
我个人习惯的做法是:先确定设备类型(HDD/SSD/NVMe/Flash),再根据负载特征(随机/顺序、读/写比例、实时性要求)选调度器,最后用 fio 压测验证。队列深度从默认值开始,逐步调整到性能拐点附近。
记住一点:调优不是一次性的。系统跑起来后,用 iostat、blktrace 持续监控,发现异常及时调整。这才是嵌入式工程师该有的态度。