Skip to content

第10章:CPU 调度——谁先上 CPU


元数据卡

属性
叙事密度12%
主语言C(模拟描述)
配角语言Java (Thread scheduler)
前置第9章(线程与同步)
难度

核心问题:地心调度台上有 10 个任务同时请求执行,你的引擎只有 4 个汽缸(核)。操作系统如何决定:"你先上,你等着"?


你在哪

"现在轮到你来决定谁先上 CPU了。多个进程/线程在等待队列里排队,调度器需要选择下一个。这里没有完美的策略——只有针对不同场景的权衡。"

上一章你学会了怎么创建线程、用锁保护共享数据。但有个问题没问:当同时有 100 个线程就绪时,到底哪个先跑上 CPU?

这不只是操作系统内核才能碰到的问题。写多线程应用时,你也会面临同样的决定:工作线程的优先级怎么设?后台任务要不要降低频率?

这一章讲调度——不是具体的调度器怎么实现,而是调度的策略与权衡

你的任务

本章分层

  • 必读:调度三大指标的权衡;FCFS / SJF / RR 三种经典算法
  • 选读:优先级调度与优先级反转;多级反馈队列(MLFQ)如何自动分类进程
  • 深水区:Linux CFS 的 vruntime 与红黑树实现;nice 值与 root 权限的关系 本章不会要求你掌握
  • CFS 源码中的红黑树操作细节
  • CPU 亲和性(CPU Affinity)的配置
  • 组调度(Group Scheduling)的实现

你的程序需要同时处理三种任务:

  • 交互式任务:用户按键盘后 10ms 内必须有响应,否则用户觉得"卡"
  • 批处理任务:视频转码,可以慢但希望尽快完成
  • 后台任务:定期同步文件,不紧急但最好别完全饿死

如果你来设计调度器,你会怎么做?


遭遇战:谁先执行?

假设三个进程同时到达:

进程需要运行时间类型
A10s批处理
B1s交互式(紧急)
C2s后台同步

不同的调度策略→截然不同的结果。


获得技能:调度的三个指标

调度策略的优劣,核心看三个指标。每个指标对应一类用户感受。

1⃣ 周转时间(Turnaround Time)

$$T_{turnaround} = T_{completion} - T_{arrival}$$

从进程到达直到结束的总时间。用户送了一个大文件去压缩,什么时候能拿回结果——这就是周转时间。

批处理场景下最关键

2⃣ 响应时间(Response Time)

$$T_{response} = T_{first_run} - T_{arrival}$$

从进程到达直到第一次被调度执行的时间。你在编辑器中打字,按下 'A' 键到屏幕上显示 'A' —— 响应时间越短,系统感觉越"流畅"。

交互式场景下最关键

3⃣ 公平性(Fairness)

每个进程获得的 CPU 时间是否合理。一个进程如果永远得不到 CPU 时间,就会饿死(starvation)

关键洞察:三个指标不可能同时最优化。追求最小周转时间可能饿死短进程;追求最小响应时间可能频繁切换带来巨大开销;追求绝对公平可能降低吞吐量。调度就是一场永无止境的权衡。


获得技能:先来先服务(FCFS / FIFO)

最简单的策略:谁先来谁先上,非抢占式(non-preemptive),进程主动释放 CPU(比如 I/O 等待)之前不会被打断。

c
// 模拟 FCFS 调度
// 假设三个进程在 t=0 同时到达
Process p[] = {
    {.name="A", .burst=10},  // 批处理大任务
    {.name="B", .burst=1},   // 交互式小任务
    {.name="C", .burst=2},   // 后台同步
};
// FCFS: A → B → C
// A: 完成时间 10, 周转 10, 响应 0
// B: 完成时间 11, 周转 11, 响应 10
// C: 完成时间 13, 周转 13, 响应 12
// 平均周转: (10+11+13)/3 = 11.3s
// B 的响应时间: 10s → 交互式体验灾难!

问题:如果 B 是编辑器按键响应,用户按了键之后要等 10 秒才看到字母?这个调度器会被丢出窗外。

这就是 ** convoy effect(护航效应)**:一个长进程拖慢后面所有短进程。


获得技能:最短作业优先(SJF)

谁最短谁先上。也分抢占式和非抢占式。

非抢占式 SJF

c
// SJF (non-preemptive): 谁最短谁先上
// B(1s) → C(2s) → A(10s)
// B: 完成 1, 周转 1, 响应 0
// C: 完成 3, 周转 3, 响应 1
// A: 完成 13, 周转 13, 响应 3
// 平均周转: (1+3+13)/3 = 5.7s ← 比 FCFS 好一倍

抢占式 SJF(也叫最短剩余时间优先 SRTF):

c
// SRTF: A 执行了 2ms 后,新到达的 B(1ms) → 抢占 A
// A 才跑 2s, 还剩 8s, B 最短 → B 先上
// B 完成后, C(2s) < A 剩余 8s → C 上
// C 完成后 → A 上
// 平均周转更短

理论上,SJF 在平均周转时间上是最优的(这叫 SJF Optimality 定理)。

但现实远不完美

  1. 不知道执行时间:你怎么知道一个进程需要跑多久?只能靠历史预测(Sheriff-like 算法:$T_{next} = \alpha T_{observed} + (1-\alpha) T_{prev_guess}$)
  2. 长进程可能饿死:如果一直有比它短的进程加入,长进程永远得不到 CPU

获得技能:时间片轮转(Round Robin, RR)

这是占领交互式场景的杀手锏。每个进程运行一个固定时间片(time quantum / time slice),时间到了就被抢占,轮到下一个就绪进程。

c
// RR, time quantum = 1s
// A(10s), B(1s), C(2s) 同时到达
//
// t=0: A   (还剩9s)
// t=1: B   (还剩0s, 完成!)
// t=2: C   (还剩1s)
// t=3: A   (还剩8s)
// t=4: C   (还剩0s, 完成!)
// t=5~13: A 独占直到完成
//
// 响应时间:
// A: 0, B: 1, C: 2 → 平均 1s  ← 比 FCFS(平均 7.3s) 和 SJF(平均 1.3s) 都好
// 但周转时间变差了:
// A: 13, B: 1, C: 4 → 平均 6s  ← 比 FCFS(11.3) 好,比 SJF(5.7) 差

关键洞察:响应时间 vs 周转时间的经典权衡。用更多上下文切换的代价换来了更低的响应时间。

时间片选多大?

这是 RR 中最微妙的问题:

时间片响应时间上下文切换开销效果
太短(1μs)极好极高(90% CPU 花在切换上)系统变慢
适中(10-100ms)可接受(~1%开销)主流选择
太长(1s)退化到 FCFS

现代操作系统:Linux 调度的时间片通常在 1ms~20ms 之间。每次上下文切换(从寄存器保存到 TLB 刷新)大约需要 1-10μs。100ms 时间片意味着切换开销约 0.01%,可以忽略。


获得技能:优先级调度

有些任务天生比其他任务重要。操作系统中断处理比用户程序重要,前台应用比后台服务重要。

c
// 优先级调度示例
Process processes[] = {
    {.name="Audio",  .burst=5,  .priority=1},  // 高优先级,音频处理
    {.name="Editor", .burst=8,  .priority=3},  // 中优先级,文本编辑器
    {.name="Backup", .burst=20, .priority=5},  // 低优先级,定时备份
};
// 按优先级执行:Audio → Editor → Backup

致命问题——优先级反转(Priority Inversion)

高优先级进程 H 需要锁 L
中优先级进程 M 正在运行
低优先级进程 L 持有锁 L
→ H 等 L 释放锁,但 L 被 M 抢占
→ H 被 M 间接阻塞!

这不是理论问题。1997 年火星探路者号在火星上频繁系统重启,最后发现就是因为优先级反转导致看门狗超时。修复方式就是优先级继承(priority inheritance)——当 L 持有 H 需要的锁时,L 临时继承 H 的优先级,确保 L 能快速运行并释放锁。

python
# Python 中设置线程优先级(实际支持有限)
import threading
import os

# 大多数桌面系统:调度策略由内核管理,用户态设置优先级的API效果有限
# 真要做实时调度,需要 Linux 的 SCHED_FIFO / SCHED_RR 策略和 root 权限

常见陷阱:多级反馈队列(MLFQ)

前面几种策略各有优缺点。现实中没有一个调度器只用一种算法。MLFQ 就是将多种策略组合起来的实用方案。

核心思想

  1. 多个队列,每个队列对应不同优先级
  2. 高优先级队列 RR 时间片短,低优先级队列 RR 时间片长
  3. 新进程进最高优先级队列
  4. 进程用完了时间片→降级到下一级队列
  5. 进程在时间片内主动让出 CPU(如 I/O 等待)→ 保持或提升优先级
c
// MLFQ 抽象模型(简化版)
#define N_QUEUES 3
#define TIME_SLICES {10ms, 50ms, 200ms}

// Q0: 时间片 10ms  → 新进程 / 交互式进程
// Q1: 时间片 50ms  → 半交互式
// Q2: 时间片 200ms → CPU 密集型(批处理)

这个设计妙在哪里?

  • 交互进程:每次只跑一点点 CPU 就做 I/O 了(键盘输入→休眠等用户按下一个键),一直留在 Q0,响应极快
  • CPU 密集进程:一直用满时间片,迅速降到 Q2,虽然时间片长但频率低,不影响交互进程
  • 无需事先知识:不像 SJF 需要预知运行时间,MLFQ 通过行为观察自动分类

MLFQ 发明者 Corbato 因此获得 1990 年图灵奖。颁奖词说 MLFQ 是"最被广泛使用的调度算法之一"——这种谦虚的说法掩盖了它几乎是所有通用操作系统调度器的基础。


Linux 实验(面向系统编程/运维方向的读者):CFS 的详细实现是个 12000 行的内核调度器。以下只讲 CFS 的核心思想,不作为通关要求。

获得技能:Linux CFS(完全公平调度)

从 Linux 2.6.23 开始,Linux 默认调度器换成了 CFS(Completely Fair Scheduler),由 Con Kolivas 的首创理念演化而来,Ingo Molnar 实现。

CFS 放弃了传统的"时间片 + 优先级队列"模式。

核心思想:一个 理想的多任务处理器 应该在任意时刻让所有可运行进程以完全相等的速度推进。但现实中只有一个 CPU——所以 CFS 记录每个进程已获得的 虚拟运行时间(vruntime),每次选择 vruntime 最小 的进程运行。

c
// CFS 伪逻辑
// 每次调度时:
struct task *pick_next_task(struct rq *rq) {
    // 红黑树中最左节点 = vruntime 最小的进程
    return rb_entry(rb_first_cached(&rq->cfs_tasks),
                    struct task, run_node);
}

// 运行期间更新 vruntime
void update_curr(struct rq *rq) {
    struct task *curr = rq->curr;
    u64 delta_exec = now - curr->exec_start;
    curr->vruntime += delta_exec;  // 等比例积累
}

优先级的影响:高优先级进程的 vruntime 增长更慢,所以它被选中的概率更高。CFS 通过 权重(weight) 控制:

$$vruntime_delta = delta_exec \times \frac{NICE_0_LOAD}{weight}$$

NICE_0_LOAD 是普通优先级(nice=0)的权重值。高优先级(nice=-20)的权重更大,所以同样的实际运行时间贡献更少的 vruntime,意味着更频繁地被选中。

数据结构:CFS 将每个可运行进程放在一个**红黑树(Red-Black Tree)**中,key 是 vruntime。选择"下一个谁上 CPU"就是 O(log n) 的树查找——对几千个线程依然很快。

Linux 实验nice 值调整需要 root 权限才能升高优先级(设置为负值)。以下 Python 示例展示的是 Linux nice 值的用户态接口。

Linux 实验nice 值调整需要 root 权限才能升高优先级(设置为负值)。以下 Python 示例展示的是 Linux nice 值的用ce(10) # 降低当前进程优先级

CFS 视角: 这个进程的 vruntime 增长更快 → 更少被调度


### CFS vs 传统时间片调度

| 方面 | 传统(RR/MLFQ) | CFS |
|------|----------------|-----|
| 时间片 | 固定或按队列 | 动态,由目标延迟和进程数决定 |
| 数据结构 | 链表/多级队列 | 红黑树 |
| 公平性 | 各队列内部轮转 | 全系统按 vruntime 精确公平 |
| 休眠进程 | 唤醒时可能被惩罚 | 休眠时 vruntime 不变,唤醒时获得"补偿"→ 有好的交互性 |
| 切换频率 | 固定周期 | 自适应 |

---

##  通关挑战

**问题**:实现一个简化版 CFS 调度器模拟(非抢占式模拟),使用最小堆选择下一个进程。

```c
// sched_sim.c - 用最小堆模拟 CFS
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

typedef struct {
    char name[16];
    int vruntime;     // 虚拟运行时间(模拟)
    int remaining;    // 剩余运行时间
} Process;

// 最小堆
typedef struct {
    Process **data;
    int size;
    int cap;
} MinHeap;

void heap_push(MinHeap *h, Process *p) {
    // 在堆中按 vruntime 排序
    int i = h->size++;
    h->data[i] = p;
    while (i > 0 && h->data[i]->vruntime < h->data[(i-1)/2]->vruntime) {
        Process *tmp = h->data[i];
        h->data[i] = h->data[(i-1)/2];
        h->data[(i-1)/2] = tmp;
        i = (i-1)/2;
    }
}

Process *heap_pop(MinHeap *h) {
    // 取出 vruntime 最小的进程
    if (h->size == 0) return NULL;
    Process *min = h->data[0];
    h->data[0] = h->data[--h->size];
    // 下沉调整...
    return min;
}

// 模拟运行一个时间单位
void run(Process *p) {
    printf("Running %s (vruntime=%d, remaining=%d)\n",
           p->name, p->vruntime, p->remaining);
    p->vruntime += 1;   // 实际运行时间等同 vruntime 增长
    p->remaining--;
}

思考题:如果时间片耗尽前进程调用了 sleep()wait(),CFS 中 vruntime 会如何处理?为什么这样设计有利于交互式进程?

(答案见下方提示)

提示:休眠进程在休眠期间 vruntime 不增长;唤醒后以"较低的 vruntime"回到红黑树 → 立即被选中 → 交互式进程获得低响应时间。这与 MLFQ 中"I/O型进程留高优先级队列"的效果等价,但实现更优雅。


验收标准

通关本章后,你应该能:

  1. 解释 FCFS 的 convoy effect
  2. 说出 SJF 为什么只能理论最优
  3. 计算 RR 在不同时间片下的平均响应/周转时间
  4. 描述 MLFQ 如何自动分类交互进程和 CPU 密集型进程
  5. 理解 CFS 的 vruntime 机制和红黑树的作用
  6. 解释上下文切换的开销来源(寄存器保存、TLB 刷新、缓存污染)
  7. 复述优先级反转的历史案例(火星探路者号)

常见卡点

卡点表现原因
混淆响应时间和周转时间分析 RR 时算错响应 = 第一次上 CPU,周转 = 完全结束
忘记非抢占式认为 FCFS 可以打断非抢占式=运行到阻塞或主动让出
过度追求公平切换过于频繁,有效计算很少上下文切换不是免费的
以为 CFS 是最优的觉得 CFS 解决了一切实时系统用 SCHED_FIFO,CFS 不适合硬实时

现在不需要理解

  • 完全公平调度器的具体源码—— Linux 内核 kernel/sched/fair.c 有 12000 行。你不需要读源码才知道它怎么工作
  • CPU 亲和性(CPU Affinity)—— 把某个进程绑定到特定核心上运行。优化缓存性能用的,不是调度器核心逻辑
  • 组调度(Group Scheduling)—— CFS 的扩展:对用户组而不是进程做公平分配
  • BFS / MuQSS—— Con Kolivas 的桌面优化调度器,为了低延迟交互而放弃 CFS 的全系统公平性。不是主线内核

旅人笔记

调度器是操作系统中最像"政治"的部分:有多个利益方(进程),资源有限(CPU 核心),没有完美方案(每个指标都在 trade-off)。

从 FCFS 到 SJF 再到 RR,每一步都是对新场景的回应。MLFQ 是工程上的集大成者——不需要预知就能自适应。而 CFS 从另一个角度(虚拟时间公平)解决了同样的问题,用红黑树这个数据结构的优雅让实现变得简洁。

但调度器的故事还没结束。当 CPU 频率不再大幅增长、多核成为常态,调度算法的重点开始从"谁用 CPU"转向"数据离 CPU 有多远"——NUMA(非一致性内存访问)让这个问题变得复杂。但那已经是另一章的课题了。

回到现实:写你那百万并发 Web 服务器时,留意线程优先级、注意不要忘记 yield()、记得调 nice——但是真正决定谁先上 CPU 的,依然是你操作系统内核里那个名叫 schedule() 的函数。


🚪 下一站预告

第11章:锁实现与动态存储——锁是怎么工作的?malloc 背后发生了什么?

调度器选好了下一个线程,但多线程之间需要互斥。锁在硬件层面怎么实现?spinlock 和 mutex 的底层区别?每个 malloc/free 调用背后,内存分配器在做什么?下一章,从调度走向同步和存储。

Built with VitePress | Software Systems Atlas