2. 早期调度器回顾:Linux 0.11 的调度算法与 O(n) 调度器
在理解现代 CFS 调度器之前,有必要回溯 Linux 调度器的起点。早期调度器设计简单、直观,但随着硬件复杂度和负载规模的提升,其缺陷逐渐暴露,直接催生了 O(1) 调度器的诞生。
2.1 Linux 0.11 调度算法:时间片 + 优先级循环
Linux 0.11(1991 年)的调度器是教科书式的时间片轮转(Round-Robin)变体,核心思想是:每个进程拥有一个时间片,时间片用完后必须让出 CPU,等待下一次调度。调度器通过 counter 字段实现时间片计数,通过 priority 字段决定初始时间片长度。
核心数据结构:
task_struct 中的 counter 和 priority 字段。
调度流程(简化)
- 遍历所有进程:从
task[0]到task[NR_TASKS-1],检查每个进程的状态。 - 选择 counter 最大的就绪进程:
counter值越大,表示剩余时间片越多,优先级越高。 - 运行选中的进程:每次时钟中断(10ms)递减
counter。 - counter 归零时触发调度:进程被挂起,调度器重新选择下一个进程。
- 所有进程 counter 都为零时:调度器为每个进程重新分配时间片:
counter = counter/2 + priority。
关键观察: 这种“counter 归零后重新计算”的机制,实际上实现了动态优先级——睡眠进程的 counter 在睡眠期间不会被时钟中断递减,因此重新计算后其 counter 值相对较高,从而获得更高的调度优先级。这是早期调度器对 I/O 密集型进程的隐式优待。
SVG 图:Linux 0.11 调度器核心循环
2.2 O(n) 调度器的诞生背景
Linux 1.2 到 2.4 内核使用的调度器被称为 O(n) 调度器。其名称来源于调度算法的时间复杂度:每次调度都需要遍历所有进程(n 个进程),时间复杂度为 O(n)。随着系统中进程数量的增加,调度开销线性增长。
诞生背景
- 多任务需求增长:90 年代末,服务器和桌面系统同时运行的进程数从几十个增长到数百个。
- SMP(对称多处理)初步支持:Linux 2.0 开始支持 SMP,但调度器仍使用单一全局运行队列,所有 CPU 共享一个锁。
- 交互式响应要求:桌面用户对 GUI 响应速度的要求提高,调度器需要更好地区分 I/O 密集型和 CPU 密集型进程。
核心缺陷预警: O(n) 调度器在进程数超过 100 时,调度延迟开始显著影响系统性能。更严重的是,全局运行队列的锁竞争在 SMP 系统上会导致严重的扩展性问题。
2.3 O(n) 调度器的核心缺陷
O(n) 调度器的缺陷可以归纳为以下四个方面,这些缺陷直接推动了 O(1) 调度器的设计。
| 缺陷类别 | 具体表现 | 影响 |
|---|---|---|
| 时间复杂度 O(n) | 每次调度需要遍历所有进程,计算并比较优先级 | 进程数增多时调度延迟线性增长,系统吞吐量下降 |
| 全局运行队列锁 | 所有 CPU 共享一个运行队列,操作队列时需要加锁 | SMP 系统上锁竞争激烈,CPU 扩展性差 |
| 交互式进程识别粗糙 | 通过平均睡眠时间判断交互性,算法复杂且不准确 | 桌面响应不稳定,部分交互进程被误判 |
| 时间片分配不灵活 | 时间片固定为 10ms-200ms,无法动态调整 | CPU 密集型进程长时间占用,I/O 密集型进程响应慢 |
SVG 图:O(n) 调度器架构与缺陷示意
2.4 代码片段:O(n) 调度器的核心逻辑
以下代码展示了 O(n) 调度器的核心调度函数 schedule() 的简化逻辑。注意 for 循环遍历所有进程,以及 goodness() 函数计算每个进程的“调度权重”。
// Linux 2.4 调度器核心 (简化版)
asmlinkage void schedule(void) {
struct task_struct *prev, *next, *p;
int this_cpu, c;
prev = current;
this_cpu = smp_processor_id();
// 检查是否需要重新调度
if (prev->state == TASK_RUNNING)
goto still_running;
// 遍历所有进程 (O(n) 核心)
repeat_schedule:
next = idle_task(this_cpu);
c = -1000;
for_each_task(p) { // 遍历 task[] 数组
if (p->state != TASK_RUNNING)
continue;
int weight = goodness(p, this_cpu); // 计算调度权重
if (weight > c) {
c = weight;
next = p;
}
}
// 如果没有可运行进程,使用 idle 进程
if (!next)
next = idle_task(this_cpu);
// 上下文切换
switch_to(prev, next);
return;
still_running:
c = goodness(prev, this_cpu);
next = prev;
goto repeat_schedule;
}
// 计算进程的调度权重
static inline int goodness(struct task_struct *p, int cpu) {
int weight;
weight = p->counter; // 剩余时间片
if (weight) {
weight += 20 - p->nice; // nice 值调整
// 交互式进程加分 (基于平均睡眠时间)
if (p->avg_sleep > 0)
weight += p->avg_sleep >> 4;
}
return weight;
}
代码解读:
for_each_task(p) 宏展开后是一个遍历 task[NR_TASKS] 数组的循环。每次调度都需要扫描所有进程,即使系统中只有少数进程处于可运行状态。当 NR_TASKS 设置为 512 或 1024 时,每次调度的循环次数固定为最大值,造成严重的 CPU 缓存污染和调度延迟。
2.5 从 O(n) 到 O(1) 的驱动力
O(n) 调度器的缺陷在 2.4 内核后期变得不可接受,尤其是 SMP 服务器场景。以下表格总结了 O(n) 与后续 O(1) 调度器的关键差异,为下一章的学习建立对比基础。
| 对比维度 | O(n) 调度器 (Linux 2.4) | O(1) 调度器 (Linux 2.6 早期) |
|---|---|---|
| 时间复杂度 | O(n) — 遍历所有进程 | O(1) — 常数时间选择 |
| 运行队列 | 单一全局运行队列 | 每个 CPU 独立运行队列 |
| 锁粒度 | 全局锁 | 每 CPU 锁 (无竞争) |
| 交互式识别 | 基于平均睡眠时间 (启发式) | 基于动态优先级 + 时间片粒度 |
| 调度延迟 | 随进程数增加而增加 | 恒定 (约 1ms 以内) |
| SMP 扩展性 | 差 (锁竞争) | 优秀 (无锁竞争) |
历史教训: O(n) 调度器的设计假设是“系统中进程数量有限且稳定”,但现实世界的负载(Web 服务器、数据库、桌面环境)打破了这一假设。调度器设计必须考虑最坏情况下的性能边界,而不仅仅是平均情况。