4. 常见调度算法(下):优先级调度、多级反馈队列(MLFQ)、完全公平调度(CFS)
好,咱们接着聊调度算法。上一节我们讲了先来先服务和短作业优先,这些算法在教科书里很经典,但放到真实的生产环境里,往往不够用。为什么?因为现实世界的任务,脾气秉性各不相同——有的急,有的不急;有的跑几毫秒就结束了,有的要霸占CPU好几分钟。
所以这一节,我们来看看更“聪明”的调度策略。我个人习惯把优先级调度、多级反馈队列和完全公平调度称为“调度三剑客”。它们解决的核心问题就一个:如何在多任务、多优先级、多时间尺度的场景下,让每个任务都觉得自己被公平对待了。
4.1 优先级调度:谁急谁先上
优先级调度,说白了就是给每个任务贴个标签。标签数字越小,优先级越高,CPU就先伺候谁。这听起来很合理吧?我在项目中遇到过这样一个场景:一个实时监控系统里,网络数据包的接收线程必须立刻响应,而日志写入线程可以稍微等等。这时候,优先级调度就是救命稻草。
但这里有个坑——优先级反转。嗯,这词听着有点绕,我举个例子你就明白了。
优先级反转场景:
假设有三个任务:T1(高优先级)、T2(中优先级)、T3(低优先级)。T3先拿到了一个共享锁,T1来了想抢锁,但锁在T3手里,T1只能等。这时候T2开始运行,它不需要锁,所以CPU一直给T2。T3被T2抢占了,锁一直释放不了。结果呢?T1这个最高优先级的任务,反而被T2这个中优先级的任务给“饿死”了。
怎么解决?我建议用优先级继承协议。简单说,就是T3持有锁期间,临时把优先级提升到T1的水平,这样T2就抢不过T3了,T3赶紧跑完释放锁,T1就能拿到锁继续执行。
代码实现上,Linux内核里就有现成的机制:
// 伪代码示意:优先级继承
void lock_acquire(lock_t *lock) {
if (lock->holder != NULL && lock->holder->priority < current->priority) {
// 提升持有者的优先级
lock->holder->priority = current->priority;
}
// 然后当前任务等待锁释放
}
避坑指南:我曾经在一个嵌入式项目里没处理好优先级反转,结果系统每隔几分钟就卡死一次。排查了两天才发现是锁的问题。从那以后,我只要看到共享资源加锁,第一反应就是检查有没有优先级反转的风险。
4.2 多级反馈队列(MLFQ):动态调整,灵活应变
MLFQ 是我个人非常喜欢的一个算法。它不像优先级调度那样死板——优先级是静态的,一旦设好就不变了。MLFQ 的思路是:让任务自己“证明”自己该不该有高优先级。
怎么证明?看行为。一个任务如果每次都用完时间片,说明它是个CPU密集型的“大胃王”,那就把它降级到低优先级队列。如果一个任务经常主动让出CPU(比如等待I/O),说明它是个交互型的“小精灵”,那就让它留在高优先级队列。
你想想看,这多合理?
MLFQ 的核心结构是这样的:
| 队列编号 | 优先级 | 时间片长度 | 调度策略 |
|---|---|---|---|
| Q0 | 最高 | 8ms | RR(轮转) |
| Q1 | 中 | 16ms | RR |
| Q2 | 低 | 32ms | FCFS |
新任务进来,先扔到 Q0。如果在 Q0 里用完了 8ms 还没结束,就降到 Q1。在 Q1 里又用完了 16ms,再降到 Q2。Q2 里就用 FCFS 慢慢跑吧。
但这里有个问题:如果所有任务都降到了 Q2,那新来的交互型任务怎么办? 嗯,这就是 MLFQ 的另一个关键机制——优先级提升。每隔一段时间(比如 100ms),把所有任务重新提升到最高优先级队列。这样,那些被降级的交互型任务就能“翻身”了。
注意:MLFQ 的参数调优是个技术活。时间片设得太短,上下文切换开销大;设得太长,交互型任务响应慢。我曾经在一个数据库中间件项目里调 MLFQ 参数,前后试了十几种组合才找到最优解。建议你在实际项目中,先用默认参数跑起来,然后通过监控工具观察任务的响应时间,再逐步调整。
4.3 完全公平调度(CFS):让每个任务都“感觉”公平
CFS 是 Linux 内核从 2.6.23 版本开始使用的默认调度器。它的设计哲学很有意思——不追求绝对的公平,而是追求“虚拟时间”上的公平。
什么叫虚拟时间?CFS 维护一个红黑树,每个任务有一个 vruntime(虚拟运行时间)。调度器每次选 vruntime 最小的任务来运行。任务运行得越多,vruntime 增长得越快,自然就会被其他任务赶超。
说白了,CFS 就像一个“轮流坐庄”的游戏。每个人坐庄的时间长短,由他的权重决定。权重高的任务(比如前台交互进程),vruntime 增长得慢,所以能多坐一会儿。权重低的任务(比如后台编译任务),vruntime 增长得快,坐一会儿就得让位。
CFS 的调度周期是这样计算的:
// 简化后的 CFS 调度周期计算
sched_period = max(min_granularity * nr_running, sysctl_sched_latency);
// 每个任务的时间片
task_slice = sched_period * (task_weight / total_weight);
// 虚拟时间增量
vruntime_delta = task_slice * (NICE_0_LOAD / task_weight);
你看,vruntime_delta 和 task_weight 成反比。权重越大,vruntime 增长越慢,在红黑树里就越靠左,越容易被选中。
CFS 的核心优势:
- O(log n) 的调度复杂度——红黑树查找效率高,任务再多也不怕
- 天然的公平性——每个任务都能得到与权重成比例的 CPU 时间
- 良好的交互体验——交互型任务权重高,响应快
我记得有一次,我在一个 128 核的服务器上跑混合负载——既有 Web 服务,又有离线数据分析任务。用 CFS 调度,Web 服务的响应时间一直很稳定,离线任务也能充分利用空闲 CPU。换回旧版的 O(1) 调度器,Web 服务时不时就卡一下。这就是 CFS 的魅力。
4.4 三种算法的对比与选择
说了这么多,我们来个总结。这三种算法各有适用场景:
| 算法 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
| 优先级调度 | 实时系统、嵌入式系统 | 实现简单,响应可预测 | 优先级反转、静态配置不灵活 |
| MLFQ | 通用操作系统、交互式系统 | 自适应,兼顾交互和吞吐 | 参数调优复杂,可能有饥饿问题 |
| CFS | 通用操作系统(Linux 默认) | 公平性好,复杂度低,可扩展性强 | 对实时任务支持不够 |
我个人建议:如果你在做嵌入式实时系统,优先考虑优先级调度,配合优先级继承协议。如果你在做通用计算平台,CFS 是首选,Linux 内核已经帮你优化得很好了。如果你需要兼顾交互体验和吞吐量,MLFQ 是个不错的选择,但要做好参数调优的心理准备。
一个小技巧:在实际项目中,不要只依赖一种调度算法。你可以把任务分类——实时任务用优先级调度,普通任务用 CFS,批处理任务用 MLFQ 的变体。这种“混合调度”策略,我在多个高并发系统中都用过,效果很不错。
4.5 核心逻辑流程图
下面这张图,我把三种算法的核心逻辑画出来了。你可以对照着看,理解它们之间的区别和联系。
嗯,这一节的内容就到这里。三种算法各有千秋,没有银弹。关键是要理解它们的核心思想——优先级调度是“静态规则”,MLFQ 是“动态适应”,CFS 是“虚拟公平”。理解了这些,你在实际项目中就能根据场景灵活选择了。
想要更多资料,联系可可