4. 调度算法(下):EDF 原理、LLF 原理与混合调度策略

上一章我们聊了 RM 和 DM 这些固定优先级算法。说实话,它们虽然简单可靠,但有个硬伤——CPU 利用率上不去。我早年做一款工业相机的时候,RM 调度死活只能跑到 78% 的利用率,再高就丢帧。后来换成 EDF,直接干到 92%,还稳稳的。

这一章,我们就来啃两块硬骨头:EDF(最早截止时间优先)和LLF(最小空闲时间优先)。它们都是动态优先级算法,能榨干 CPU 的最后一点性能。但用不好,也会翻车。

4.1 EDF:最早截止时间优先

EDF 的思路很直白:谁的截止时间最近,谁先跑。每次调度时,系统检查所有就绪任务,挑出截止时间最小的那个执行。

你想想看,这就像你手头有多个 deadline 不同的活儿。EDF 就是那个不停提醒你「先做最急的」的监工。

4.1.1 工作原理

每个任务 τ_i 在释放时,系统会计算它的绝对截止时间:

d_i = r_i + D_i

其中 r_i 是释放时间,D_i 是相对截止时间。调度器维护一个就绪队列,按 d_i 升序排列。每次调度,取队首任务执行。

我画了一张图,帮你理解 EDF 的调度过程:

EDF 调度示例:两个周期任务 任务 A (T=5, C=2) D=5 D=10 D=15 D=20 D=25 D=30 D=35 任务 B (T=8, C=3) D=8 D=16 D=24 D=32 CPU 执行序列(EDF) A B A B A B A A B A A B A A B A 0 5 10 15 20 25 30 35 关键观察:EDF 在 t=5 时,任务 A 截止时间(5)早于任务 B(8),所以 A 先执行。 t=10 时,任务 A 截止时间(10)仍早于 B(16),继续优先执行 A。 EDF 总是选择「当前最紧急」的任务,而不是固定优先级。

4.1.2 可调度性分析

EDF 有一个非常漂亮的理论结论:对于一组周期任务,如果总利用率 U ≤ 1,那么 EDF 一定能调度。注意,这里没有 RM 那个 69.3% 的上限。

EDF 可调度条件:

U = Σ (C_i / T_i) ≤ 1

其中 C_i 是任务 i 的最坏执行时间,T_i 是周期。

我当年第一次看到这个结论时,觉得太完美了。但实际用起来,有两个坑:

  • 过载时的多米诺效应:一旦 U > 1,EDF 会瞬间崩溃,大量任务同时错过截止时间。不像 RM,过载时至少高优先级任务还能保住。
  • 实现开销:每次调度都要比较截止时间,队列维护比固定优先级复杂。

我曾经踩过的坑:在一个视频处理项目中,我用 EDF 调度三个任务,测试时利用率 95%,跑得稳稳的。结果现场环境温度升高,CPU 降频,一个任务的执行时间从 8ms 涨到 11ms。利用率瞬间变成 105%,整个系统像多米诺骨牌一样,所有任务都错过了截止时间。从那以后,我养成了一个习惯——EDF 系统必须留至少 10% 的余量。

4.1.3 EDF 的变体

实际工程中,纯 EDF 用得不多。更常见的是它的变体:

变体名称 核心思想 适用场景
抢占式 EDF 新任务释放时,如果它的截止时间更早,立即抢占当前任务 硬实时系统,截止时间严格
非抢占式 EDF 任务开始执行后不允许被抢占,直到主动挂起 临界区保护、不可抢占资源
服务器 EDF 将非周期任务封装成「服务器任务」,用 EDF 调度 混合任务集(周期+非周期)

4.2 LLF:最小空闲时间优先

LLF 是另一个动态优先级算法。它的思路是:谁的空闲时间最少,谁先跑。

空闲时间(Laxity)定义为:

L_i = d_i - t - C_rem_i

其中 d_i 是绝对截止时间,t 是当前时间,C_rem_i 是剩余执行时间。

说白了,空闲时间就是「在不耽误截止时间的前提下,还能摸鱼多久」。空闲时间越少,说明这个任务越危险,必须优先执行。

4.2.1 LLF 与 EDF 的对比

这两个算法经常被放在一起比较。我整理了一张表:

对比维度 EDF LLF
调度依据 截止时间 d_i 空闲时间 L_i
抢占频率 较低(只在任务释放时检查) 较高(每次时钟中断都可能变化)
实现复杂度 中等 较高(需要跟踪剩余执行时间)
过载行为 多米诺效应 更早检测到危险任务
适用场景 周期任务为主 执行时间变化大的任务

我个人习惯:能用 EDF 就不用 LLF。为什么?LLF 的抢占太频繁了。你想想看,每次时钟滴答,所有就绪任务的空闲时间都在变,调度器得重新算一遍。这本身就会吃掉不少 CPU 时间。

一个小技巧:如果你非要用 LLF,可以设置一个「空闲时间阈值」。只有当任务的 L_i 小于某个值(比如 2ms)时,才触发重新调度。这样可以大幅减少不必要的上下文切换。

4.2.2 LLF 的致命弱点

LLF 有一个著名的毛病——颠簸(Thrashing)。当多个任务的空闲时间接近时,调度器会在它们之间频繁切换,导致大量上下文切换开销。

举个例子:两个任务 A 和 B,空闲时间都是 5ms。LLF 会让 A 跑一会儿,然后发现 B 的空闲时间更小了,切到 B;跑一会儿又发现 A 更小了,再切回来。结果两个任务都没怎么干活,全在切来切去。

我在一个雷达信号处理项目中遇到过这个问题。当时用了 LLF,结果上下文切换开销占了 CPU 的 30%。后来换成 EDF,一切正常。

4.3 混合调度策略

现实世界不是非黑即白的。很多时候,我们需要把多种调度策略揉在一起用。这就是混合调度。

4.3.1 为什么要混合?

单一调度算法总有短板:

  • RM/ DM:利用率上不去,但实现简单、行为可预测
  • EDF:利用率高,但过载时崩溃
  • LLF:能处理执行时间变化,但开销大

混合调度的思路是:取长补短。把任务分成不同的「类」,每类用不同的调度策略。

4.3.2 常见的混合模式

我总结了几种工程上常用的混合模式:

模式名称 结构 典型应用
两层调度 上层用 EDF 分配时间片,下层用 RM 执行 多媒体系统(视频+音频+控制)
固定+动态 关键任务用 RM,非关键任务用 EDF 汽车电子(安全任务+信息娱乐)
服务器机制 用固定优先级调度服务器,服务器内部用 EDF 航空电子(ARINC 653 分区调度)
优先级继承+EDF 资源访问用优先级继承,任务调度用 EDF 机器人操作系统(ROS 2)

4.3.3 实战案例:一个混合调度器的设计

假设我们要设计一个无人机飞控系统,任务集如下:

  • 姿态控制:周期 1ms,执行时间 0.2ms,硬实时
  • GPS 定位:周期 10ms,执行时间 1ms,软实时
  • 图像处理:周期 33ms,执行时间 5ms,非实时
  • 日志记录:非周期,执行时间 0.5ms,尽力而为

我的设计方案是这样的:

// 混合调度器伪代码
void scheduler() {
    // 第一层:固定优先级(RM)
    // 姿态控制优先级最高
    if (attitude_task_ready()) {
        execute_attitude();
        return;
    }
    
    // 第二层:EDF 调度
    // GPS 和图像处理按截止时间排序
    task_t *next = edf_select(gps_task, image_task);
    if (next) {
        execute(next);
        return;
    }
    
    // 第三层:后台执行
    // 日志记录等非实时任务
    execute_background();
}

这个设计的好处是:

  • 姿态控制永远不被阻塞,满足硬实时要求
  • GPS 和图像处理在剩余时间里按 EDF 调度,利用率高
  • 日志记录只在空闲时执行,不影响关键任务

混合调度的核心原则:

1. 关键任务用固定优先级,保证确定性

2. 非关键任务用动态优先级,提高利用率

3. 非实时任务用后台调度,不占用实时带宽

4.3.4 混合调度的调试要点

混合调度调试起来比单一算法麻烦。我分享几个经验:

  • 先测边界:分别测试每种调度策略的边界条件,再测混合后的交互
  • 加日志点:在每个调度决策点打印「谁调度了谁」,方便事后分析
  • 监控切换频率:如果发现不同调度层之间频繁切换,说明设计有问题
  • 留余量:混合调度本身有额外开销,建议留 15%-20% 的 CPU 余量

我曾经踩过的坑:在一个混合调度系统中,我把一个软实时任务误标成了硬实时,结果它占用了大量 CPU 时间,导致真正的硬实时任务被饿死。调试了两天才发现是优先级配置错了。所以,一定要在代码里显式标注每个任务的实时等级,并且做代码审查。

4.4 本章小结

这一章我们聊了三个核心内容:

  • EDF:利用率高,可调度条件简单(U ≤ 1),但过载时容易崩溃
  • LLF:能感知任务紧急程度,但开销大,容易颠簸
  • 混合调度:取长补短,关键任务用固定优先级,非关键任务用动态优先级

说实话,没有银弹。每种调度算法都有自己的脾气。我的建议是:先搞清楚你的任务集特点,再选算法。如果任务周期稳定、执行时间可预测,EDF 是首选。如果任务执行时间变化大,考虑 LLF 或混合调度。如果系统安全等级高,老老实实用 RM。