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 核心逻辑流程图

下面这张图,我把三种算法的核心逻辑画出来了。你可以对照着看,理解它们之间的区别和联系。

调度算法核心逻辑对比 优先级调度 1. 任务分配静态优先级 2. 就绪队列按优先级排序 3. 每次选最高优先级任务 4. 可能发生优先级反转 → 需配合优先级继承协议 多级反馈队列 1. 多个队列,优先级递减 2. 新任务进入最高优先级 3. 用完时间片则降级 4. 主动让出CPU则保留 → 定期提升所有任务优先级 完全公平调度 1. 每个任务维护vruntime 2. 红黑树按vruntime排序 3. 选vruntime最小的运行 4. 权重影响vruntime增速 → O(log n) 调度复杂度 核心区别 优先级调度 • 静态优先级,不自动调整 • 适合实时任务 • 实现简单但不够灵活 • 需要人工干预优先级 多级反馈队列 • 动态调整优先级 • 自适应任务行为 • 参数调优复杂 • 可能有饥饿问题 完全公平调度 • 虚拟时间保证公平 • 红黑树高效查找 • 权重灵活配置 • 实时性稍弱 选择建议:实时系统→优先级调度 | 通用系统→CFS | 混合负载→MLFQ

嗯,这一节的内容就到这里。三种算法各有千秋,没有银弹。关键是要理解它们的核心思想——优先级调度是“静态规则”,MLFQ 是“动态适应”,CFS 是“虚拟公平”。理解了这些,你在实际项目中就能根据场景灵活选择了。

想要更多资料,联系可可