操作系统学习笔记 · 第 11 课 · 实时调度与多核调度
上一课我们默认"只有一个 CPU 核"。但现实是:你的手机有 8 核,服务器有 64 核;而有些任务——比如无人机飞控、汽车刹车——晚一毫秒都可能出人命。这一课,我们进入调度的两个进阶战场:实时调度 和 多核调度。
从"晚 10ms 会怎样"说起
想象你在开一辆自动驾驶的车。前方突然出现行人,刹车系统必须在 10ms 内响应,晚一点点就撞上了。
这时候,"平均表现不错"够用吗?完全不够。 哪怕 99% 的情况都响应及时,只要那 1% 晚了 10ms,就是一场事故。
这就是实时调度要解决的命题:不是"平均好",而是"每个任务都必须在截止时间前完成"。
11.1 实时调度解决什么问题
一句话理解:普通调度追求"平均表现好",实时调度追求"每个任务必须按时完成"——错过截止时间就是失败。
两者的目标根本不同:
- 普通调度(上一课讲的 FCFS/SJF/RR/MLFQ):追求平均周转快、响应快、吞吐高——偶尔慢一点没关系;
- 实时调度:追求确定性——每个任务都有个"截止时间(deadline)",必须在此之前完成,错过就是失败。
典型场景:汽车刹车控制、无人机飞控、工业机器人、医疗设备。这些系统里,"按时"不是加分项,而是生死线。
11.2 RMS 与 EDF:两个经典直觉
实时调度领域,有两个最经典的算法,先掌握它们的直觉:
RMS(速率单调):周期越短(越频繁)的任务,优先级越高。静态优先级,实现简单。
EDF(最早截止优先):谁离截止时间最近谁先跑。动态优先级,理论最优但实现复杂。
- RMS(Rate-Monotonic Scheduling):看任务的周期。哪个任务来得越频繁(周期越短),就给越高的优先级。因为"频繁来"的任务,一旦错过就更容易堆积。它的优先级是固定的(静态),实现简单。
- EDF(Earliest Deadline First):看任务的截止时间。谁的 deadline 离现在最近,谁先跑。优先级动态变化(每次截止时间都在变)。
一个生活类比:EDF 就像赶作业——哪门课明天就要交,就先写哪门,而不是看哪门作业"量大"或"频率高"。EDF 理论上是最优的(能调度的任务集最多),但每次都要动态计算优先级,实现复杂、开销大。
一句话记住差异:RMS 看"来得勤不勤",EDF 看"催得急不急"。
11.3 CPU 亲和性(affinity)
进入多核世界,第一个概念是CPU 亲和性。
一句话理解:让某个线程尽量固定跑在某个 CPU 核上,好处是缓存是"热的"(数据还在那核的缓存里),切换开销小。
为什么"固定核"能更快?因为每个 CPU 核有自己的缓存。一个线程在核 0 上跑了一段时间,它要用的数据都已经被加载进核 0 的缓存里了("缓存是热的")。
如果调度器把它搬到核 1,核 1 的缓存里没有这些数据,就得重新从内存加载——冷启动,慢。
所以让线程"恋旧"、固定在一个核上,能减少缓存失效。Linux 里可以用 taskset 命令把进程绑到指定核,或用 sched_setaffinity 编程设置。
11.4 多核负载均衡
既然固定核好,那是不是每个线程都钉死一个核就完了?不行——因为还有另一个问题:负载均衡。
一句话理解:多核下要避免"一个核忙死、其他核闲死",调度器会把任务从忙的核"搬"到闲的核。但搬太勤又会破坏缓存亲和,是个权衡。
想象 4 个核,结果 100 个线程全挤在核 0 上排队,核 1、2、3 在睡觉——这就浪费了 3/4 的算力。所以调度器必须"搬":把核 0 上堆积的任务,搬到闲着的核上。
但注意,这里藏着一对矛盾:
- 负载均衡要求你勤搬(别让谁闲着、谁累死);
- 缓存亲和要求你少搬(搬了缓存就凉了)。
所以现代调度器是"该搬才搬、搬完就尽量别动"。现代 Linux 按 NUMA 架构、按"调度域"分层做负载均衡,避免盲目迁移——不追求每个核"绝对平均",而是"大致均衡"即可。
动手实验:C 语言扩展成"多核 + 优先级"模拟器
上一课我们写了单核 RR 调度器,这一课扩展成"多核 + 优先级 + 负载均衡":
// lesson11.c —— 多核 + 优先级调度模拟
#include <stdio.h>
#define PROCS 6
#define CORES 2
typedef struct {
char name[8];
int remain;
int prio; // 优先级:数字越小越优先
} Process;
int main(void) {
Process procs[PROCS] = {
{"P1", 6, 1}, {"P2", 4, 3}, {"P3", 5, 1},
{"P4", 3, 2}, {"P5", 7, 3}, {"P6", 2, 1}
};
int cores[CORES] = {0, 0}; // 每个核当前累加的工作量(简化)
// 简化模型:按优先级排序后,轮流分给最闲的核(负载均衡)
for (int i = 0; i < PROCS; i++)
for (int j = i + 1; j < PROCS; j++)
if (procs[j].prio < procs[i].prio) {
Process t = procs[i]; procs[i] = procs[j]; procs[j] = t;
}
printf("按优先级分派到 %d 个核(负载均衡):\n", CORES);
for (int i = 0; i < PROCS; i++) {
int least = 0;
for (int c = 1; c < CORES; c++)
if (cores[c] < cores[least]) least = c; // 找最闲的核
cores[least] += procs[i].remain;
printf("%s(优先级%d,需%d)→ 核 %d,核%d累计=%d\n",
procs[i].name, procs[i].prio, procs[i].remain, least, least, cores[least]);
}
return 0;
}编译运行:
gcc lesson11.c -o lesson11 && ./lesson11观察两点:优先级高的先派(排序后 P1/P3/P6 这种优先级 1 的先出来),以及任务被均衡地分到两个核(每次都找"最闲的核"派)。
这个模拟器是"简化模型"——真实调度器远比这复杂,但它已经抓住了两个核心直觉:优先级决定先后,负载均衡决定去哪个核。
深入点:两个前沿话题
① NUMA(非统一内存访问)
在多路服务器上,CPU 访问内存不是"一碗水端平"的:访问自己附近的内存快,访问对面(另一个 CPU 插座上)的内存慢。这种架构叫 NUMA。
调度器因此要多操一份心:不光要平衡 CPU 负载,还要尽量让线程"就近访问内存",别把线程搬到远离它内存的地方。
② 实时 Linux(PREEMPT_RT)
标准 Linux 内核是"通用"的,响应延迟对硬实时来说不够。于是有了 PREEMPT_RT 实时补丁:让内核尽可能"可抢占",把不可抢占的代码段压缩到最小,从而满足硬实时需求。很多工业控制、机器人系统跑的就是打了 RT 补丁的 Linux。
小结与思考题
这一课,我们把调度从"单核"推进到了"实时 + 多核":
- 实时调度追求"每个任务按时完成",而非"平均好";RMS 看周期、EDF 看截止时间;
- CPU 亲和性:让线程固定核,缓存热、开销小;
- 多核负载均衡:避免"忙死与闲死",但与缓存亲和是一对矛盾,需权衡;
- 扩展模拟器,用"优先级 + 最闲核"体现了这两个直觉。
留三个问题:
- 实时调度和普通调度追求的目标有何不同?
- 为什么"CPU 亲和性"能提升性能?
- 多核负载均衡和缓存亲和为什么是一对矛盾?
调度讲完了——"谁先跑"的问题告一段落。但真正的麻烦才刚刚开始:当多个线程/进程真的"同时"跑起来,还共享同一块数据时,会发生什么? 下一课,我们要亲手制造一个"并发翻车现场"——竞态条件。它会把我们前面学的线程、共享内存、调度,搅成一锅危险的粥。