Linux 内核中的调度模型:从磁盘 IO 调度算法到系统级资源瓶颈分析
引言
Linux 的调度并不只有 CPU 调度。很多线上问题表面上看是“CPU 慢了”,实际根因却是磁盘 IO 排队、文件系统提交、页缓存回收,或者块设备队列被打满。真正有价值的性能分析,不是盯着一个指标看,而是把“谁在排队、为什么排队、排在哪一层”讲清楚。
本文从磁盘 IO 调度器讲起,向上连接到系统级资源瓶颈分析,最后落到可执行的诊断方法。重点不是背算法名,而是理解不同调度策略背后的取舍,以及这些取舍会在生产环境里表现成什么现象。
一、Linux 调度模型的整体视角
Linux 内核里的“调度”可以分成两层看:
- CPU 调度:决定哪个任务先拿到 CPU
- IO 调度:决定哪个 IO 请求先进入设备队列
这两层彼此影响。一个进程如果因为磁盘 IO 阻塞,就会睡眠并让出 CPU;反过来,CPU 忙不过来时,IO 完成中断和提交路径也会被拖慢。很多系统瓶颈并不是单点故障,而是 CPU、内存、文件系统和块设备之间的连锁反应。
1.1 CPU 调度只解决“谁跑”,IO 调度解决“谁先写盘”
CPU 调度关注的是 runnable task,也就是“已经准备好运行”的任务。IO 调度关注的是 block layer 上等待下发给设备的请求。二者属于不同队列,优化手段也不同。
一个常见误区是:看到 iowait 高,就以为只要换更快的磁盘就行。实际上,iowait 高可能是:
- 真正的存储慢
- 队列太长,延迟被放大
- 文件系统同步写太多
- 内存不足,频繁回写和回收
- 上层应用一次性发了太多并发请求
二、进程调度:CFS 为什么适合通用负载
Linux 默认的进程调度器是 CFS,核心目标不是简单轮转,而是尽量让任务获得“公平”的 CPU 时间。
2.1 CFS 的核心思想
CFS 维护的是虚拟运行时间vruntime。任务真正执行得越多,vruntime增长越快;权重更高的任务增长得更慢。调度器总是优先挑选vruntime最小的任务运行。
这样设计的结果有两个:
- CPU 资源更均衡
- 权重大的任务可以获得更长的有效运行时间
struct sched_entity { struct load_weight load; u64 vruntime; u64 sum_exec_runtime; struct rb_node run_node; };2.2 CFS 的实际意义
在生产系统里,CFS 的价值不是“看起来公平”,而是让不同类型任务能在同一台机器上共存。比如:
- 后台批处理不会轻易饿死前台请求
- 高优先级业务可以通过权重获得更稳定的响应
- 任务切换的整体开销比较可控
但要注意,CPU 调度再公平,也无法解决磁盘慢的问题。一个进程可能是因为 IO 阻塞而“看起来不占 CPU”,这时真正的瓶颈在别处。
三、磁盘 IO 调度:不是越复杂越好
IO 调度器的任务,是在块设备发出请求之前,对请求进行排序、合并和节流。它的目标通常有三个:
- 减少寻道和访问抖动
- 控制延迟,避免某类请求长期排队
- 在吞吐量和公平性之间做平衡
3.1 机械盘和 SSD 的差异决定了调度策略
传统机械硬盘有明显的物理寻道成本,所以“顺序访问”非常重要。把相近位置的请求合并在一起,可以显著减少磁头移动。
SSD 没有机械寻道,但仍然存在:
- 设备内部并行度限制
- 写放大
- 队列拥塞
- 读写延迟不对称
所以,SSD 时代并不是“不需要调度”,而是“调度目标变了”。重点从减少寻道,逐渐转向控制延迟、保护关键请求和减少队列抖动。
3.2 常见调度思路
noop / none
最简单的策略,尽量少做事情,更多把排序交给下层设备或硬件队列。
适合场景:
- 存储设备自己有较强的内部调度能力
- 虚拟化环境中,底层已经做了充分整形
- 对 CPU 开销敏感,且不希望内核层再做额外排序
deadline
核心思想是给每个请求设置截止时间,避免某类请求长期饥饿。它通常会维护读写队列,并优先处理快要超时的请求。
适合场景:
- 读延迟敏感
- 需要抑制写请求长期占队列
- 业务对尾延迟比较敏感
BFQ
更强调按进程或 cgroup 维度的公平性,适合交互型负载较多的系统。
适合场景:
- 桌面环境
- 多租户公平性要求高
- 希望限制某些任务过度抢占 IO
mq-deadline / kyber / none
现代 Linux 的多队列块层更强调并行提交和设备特性匹配。实际选型时,往往不是追求“最强调度”,而是选一个与底层存储最匹配、最少额外开销的策略。
3.3 典型对比
| 策略 | 核心目标 | 优点 | 代价 | 适用场景 |
|---|---|---|---|---|
| noop / none | 尽量少干预 | CPU 开销低 | 不主动优化请求顺序 | SSD、虚拟化、底层已整形 |
| deadline | 控制延迟和饥饿 | 尾延迟更稳 | 有额外排序和超时管理成本 | 数据库、读延迟敏感业务 |
| BFQ | 公平分配 | 多任务体验好 | 调度复杂度更高 | 桌面、多租户公平性 |
四、IO 调度为什么会成为系统瓶颈
IO 调度本来是为了优化系统,但在某些负载下,它本身也会变成瓶颈。原因通常不是算法名字,而是请求规模、竞争强度和设备能力不匹配。
4.1 典型开销来源
- 请求排序和插入成本
- 队列锁竞争
- 合并请求的判断成本
- 超时管理和定时检查
- 上层请求过密导致队列持续满载
当 IO 请求量很低时,这些成本几乎可以忽略;当系统进入高并发写入、日志刷盘、批量导入、数据库 checkpoint 之类的场景时,调度器和块层开销会被明显放大。
4.2 瓶颈并不总是在磁盘
下面这些症状经常会误导排查方向:
iostat里%util接近 100%,但实际设备并没有到物理极限- 应用延迟高,但磁盘吞吐量并不高
await很大,svctm却不一定能说明问题- CPU 看起来不满,但进程却响应很慢
这类问题往往说明“队列深了”,而不是“盘完全不够快”。也就是说,请求在进入设备之前就已经堆积起来了。
4.3 一个更完整的链路
flowchart LR A[应用线程发起读写] --> B[页缓存/文件系统] B --> C[块层请求合并] C --> D[IO 调度器排序] D --> E[设备队列] E --> F[存储设备执行] F --> G[完成中断/回调] G --> A瓶颈可能出现在任一层:
- 应用层:请求模式不合理
- 文件系统层:同步写、元数据更新频繁
- 块层:队列过深、排序开销过高
- 设备层:真实吞吐打满
五、如何判断系统到底卡在哪里
最有用的排查方式不是先猜,而是沿着“现象 - 队列 - 设备 - 进程”逐层缩小范围。
5.1 先看系统级症状
先确认是 CPU 问题、内存问题,还是 IO 问题。
常用观察项:
| 工具 | 关注点 | 典型含义 |
|---|---|---|
top/htop | %wa、负载、进程状态 | 是否大量任务在等 IO |
vmstat | r、b、si、so | 是否存在阻塞和换页 |
iostat | await、aqu-sz、util | 队列是否过深 |
pidstat -d | 进程级 IO | 谁在制造 IO |
sar -d | 设备历史统计 | 延迟是否持续升高 |
5.2 再看进程级行为
如果某个服务延迟高,要回答三个问题:
- 是谁在读写磁盘
- 是读多还是写多
- 是同步 IO 还是后台刷盘
比如:
- 数据库延迟抖动,可能是 checkpoint 或 flush 太集中
- 日志服务卡顿,可能是写入太频繁且同步提交太重
- 搜索服务慢,可能是随机读过多导致队列被打散
5.3 最后看设备层指标
如果设备层已经接近瓶颈,常见特征是:
- 队列深度长期较高
- 读写延迟明显上升
- 吞吐没有继续增长,但等待时间持续变长
这时继续加并发,通常不会让系统更快,只会让排队更长。
六、实战诊断流程
下面是一套更适合线上排障的顺序。
6.1 第一步:确认是不是 IO 瓶颈
看三个信号:
- 应用响应时间是否升高
%wa是否显著增加iostat的await和aqu-sz是否同时上升
如果这三个信号同时成立,基本可以先按 IO 问题排查。
6.2 第二步:确认是读慢还是写慢
- 读慢:通常更影响在线请求的尾延迟
- 写慢:通常更影响刷新、提交、落盘和后台任务
如果读写混在一起,要区分同步路径和后台路径。很多“写慢”其实是应用在等落盘确认,而不是设备写不进去。
6.3 第三步:确认是队列问题还是设备问题
判断思路很简单:
- 如果队列深、延迟高,但吞吐没到硬件极限,优先看调度和并发模型
- 如果吞吐已经接近设备上限,优先看是否需要扩容或换介质
6.4 第四步:检查内存是否在拖后腿
很多 IO 慢并不是盘慢,而是内存不足导致:
- 页缓存命中率下降
- 频繁回收
- 脏页回写集中
- 抖动加重
所以在看磁盘前,也要看内存是否把系统拖进了“边读边回收”的状态。
七、调优思路:不是盲调参数,而是先定目标
IO 调优的第一原则是先明确你想优化什么:
- 降低尾延迟
- 提升平均吞吐
- 减少 CPU 开销
- 提升多租户公平性
不同目标,答案不同。
7.1 什么时候应该尽量减少调度干预
如果底层是 SSD、NVMe 或云盘,并且设备本身已经有较强的内部调度能力,那么内核层过度排序未必有收益。此时更重要的是:
- 让请求路径尽量短
- 减少不必要的合并和锁竞争
- 控制上层并发,避免队列过深
7.2 什么时候需要更强的调度策略
如果业务对尾延迟很敏感,或者存在明显的读写竞争,那么带延迟控制能力的策略往往更合适。尤其是数据库、日志系统、事务型应用,常常需要优先保护读请求和关键写请求。
7.3 参数调优要围绕负载特征
不要直接照搬别人的参数。应该先看:
- 设备类型:机械盘、SSD、NVMe、云盘
- 负载类型:随机读、顺序写、混合读写
- IO 深度:单线程还是高并发
- 同步比例:同步写多不多
- 是否有缓存:页缓存和应用缓存是否覆盖了大部分请求
八、示例:从系统视角理解调度效果
sequenceDiagram participant App as 应用 participant FS as 文件系统 participant Scheduler as IO调度器 participant Disk as 存储设备 App->>FS: 发起读写请求 FS->>Scheduler: 生成块 IO 请求 Scheduler->>Scheduler: 排序/合并/限流 Scheduler->>Disk: 下发请求 Disk-->>Scheduler: 完成返回 Scheduler-->>FS: 通知完成 FS-->>App: 唤醒等待线程这个链路里,任何一层变慢都会放大到应用延迟:
- 文件系统层慢,应用线程先卡住
- 调度器层慢,设备还没忙起来,队列先堆满
- 设备层慢,所有上层都要跟着等待
所以排查时不能只看“磁盘忙不忙”,还要看“请求是不是已经在前面堵住了”。
九、创业团队为什么要关心这件事
对创业团队来说,IO 调度不是纯底层知识,它会直接影响成本和交付稳定性。
- 同样的机器,能跑更多请求,意味着单位成本更低
- 尾延迟更稳,意味着用户体验更可控
- 能快速定位瓶颈,意味着事故恢复更快
真正有价值的不是把每个调度器名字背下来,而是建立一套判断方法:
- 看到慢,先判断在哪一层慢
- 看到高延迟,先判断是排队还是执行慢
- 看到抖动,先判断是并发问题还是介质问题
总结
Linux 的调度模型不是单一算法,而是一整套围绕 CPU、内存、文件系统和块设备的资源协调机制。CPU 调度解决“谁运行”,IO 调度解决“谁先进设备”,系统级瓶颈分析则要回答“为什么会排队、排在哪一层、怎么降低排队成本”。
对于线上系统来说,最实用的能力不是记住某个调度器的名字,而是能迅速把问题分成三类:设备真的慢、队列排太长、上层请求模式不合理。只要这条判断链清楚,IO 优化就不再是拍脑袋调参数,而是可以被验证、复现和持续迭代的工程工作。