3、全局调度算法:G-EDF、G-RM与负载均衡
好,咱们进入多核调度里最核心的一块——全局调度算法。说实话,我刚接触多核系统时,第一反应是:把单核的调度算法直接搬过来不就行了?后来被现实狠狠教育了一顿。单核上跑得好好的任务,到了多核上,各种优先级反转、缓存颠簸、核间迁移开销……问题一个接一个。
今天咱们重点聊三种全局调度策略:G-EDF、G-RM,以及负载均衡。我会结合项目里的实际踩坑经历来讲,希望能帮你少走弯路。
3.1 全局最早截止时间优先(G-EDF)
G-EDF,说白了就是把所有任务的截止时间放在一个全局队列里排序。谁最急,谁先跑。哪个核空闲了,就从队列头部取一个任务执行。
听起来很简单对吧?但坑就在细节里。
核心思想:所有任务共享一个就绪队列,按截止时间排序。任何空闲核都从队列头部取任务。
我在一个车载仪表盘项目里用过G-EDF。当时有6个任务:3个图像渲染(周期20ms),2个传感器采集(周期10ms),1个CAN通信(周期5ms)。用G-EDF跑起来,大部分时间表现不错。但有一次,我发现某个图像帧偶尔会卡顿——明明截止时间还早,却被频繁抢占。
查了半天,问题出在全局队列的锁竞争上。4个核同时访问队列,自旋锁的开销在任务切换频繁时急剧上升。我当时的解决方案是:把队列改成无锁链表,配合CAS操作。效果立竿见影,调度延迟从平均12μs降到了3μs。
我的建议:G-EDF适合任务数量不多(<20个)、周期差异不大的场景。如果任务太多,队列操作会成为瓶颈。
G-EDF还有一个著名的理论问题:Dhall效应。简单说,当某个任务的利用率特别低时,G-EDF可能无法保证所有任务的可调度性。嗯,这听起来反直觉——低利用率反而出问题?
举个例子:两个核,三个任务。任务A利用率0.6,任务B利用率0.6,任务C利用率0.1。理论上总利用率1.3 < 2,应该能调度。但G-EDF在某些情况下会让C频繁抢占A和B,导致A和B错过截止时间。我在项目中遇到过类似情况,最后不得不给关键任务加了带宽隔离机制。
3.2 全局速率单调(G-RM)
G-RM是另一种思路:周期越短的任务,优先级越高。所有任务按周期排序,短周期任务优先执行。
你可能会问:这和单核的RM有啥区别?区别大了。单核RM有精确的可调度性判定(Liu-Layland定理),但多核G-RM……嗯,没有这么完美的理论支撑。
注意:G-RM在多核上没有简单的可调度性充要条件。实际项目中,我通常用资源利用率上限来估算:对于m个核,G-RM的利用率上限大约是m * (2^(1/m) - 1)。当m=4时,上限约0.76m,也就是76%的核利用率。
我在一个工业控制器项目里用过G-RM。任务都是周期性的,周期从1ms到100ms不等。G-RM的优势很明显:实现简单,不需要动态计算截止时间,只需要一个固定优先级表。
但有个坑:优先级反转在多核环境下更隐蔽。单核上,优先级反转通常发生在共享资源上。多核呢?除了共享资源,还有核间依赖——比如任务A在核0上等任务B在核1上释放的信号量。如果B被低优先级任务抢占,A就被无辜牵连。
我当时怎么解决的?用了优先级继承协议的变种——多核优先级继承(MPIP)。每个核维护一个本地优先级天花板,当任务阻塞时,把阻塞者的优先级临时提升到被阻塞任务的级别。代码实现大概是这样:
// 多核优先级继承伪代码
void mpip_lock(mutex_t *m, task_t *current) {
spin_lock(&m->lock);
if (m->owner == NULL) {
m->owner = current;
spin_unlock(&m->lock);
return;
}
// 检测优先级反转
if (current->priority > m->owner->priority) {
// 提升持有者的优先级
m->owner->boosted_priority = current->priority;
// 触发核间中断,让持有者所在核重新调度
ipi_send(m->owner->cpu_id, IPI_RESCHED);
}
// 当前任务阻塞
current->state = BLOCKED;
enqueue(&m->wait_queue, current);
spin_unlock(&m->lock);
schedule();
}
这个方案在4核系统上测试,最坏情况下的阻塞时间从原来的500μs降到了80μs。当然,代价是增加了核间中断的开销——每次优先级提升都要发IPI。我个人觉得,在实时性要求高的场景下,这个代价是值得的。
3.3 负载均衡策略
聊完具体的调度算法,咱们说说负载均衡。你想想看,就算调度算法再好,如果任务分配不均匀,也是白搭。
负载均衡分两种:静态和动态。
| 类型 | 特点 | 适用场景 | 我踩过的坑 |
|---|---|---|---|
| 静态负载均衡 | 编译时分配任务到核,运行时不变 | 任务集固定、周期已知 | 任务实际执行时间波动大时,静态分配容易失衡 |
| 动态负载均衡 | 运行时根据核负载迁移任务 | 任务动态创建、执行时间不确定 | 迁移开销可能抵消均衡收益 |
我在一个ADAS(高级驾驶辅助)项目里做过动态负载均衡。当时有8个核,任务包括摄像头数据采集、目标检测、路径规划等。一开始我用的是最简单的轮询迁移:每10ms检查一次各核的负载,如果某个核负载超过80%,就把它的一个任务迁移到负载最低的核。
结果呢?系统频繁抖动。任务迁移导致缓存失效,迁移后的任务第一次执行特别慢,然后又触发新的迁移……恶性循环。
我的改进方案:引入迁移代价评估。每次迁移前,估算缓存刷新开销 + 新核上的执行时间,如果总收益小于阈值(比如5%的核利用率),就不迁移。同时,对关键任务设置亲和性,禁止迁移。
具体实现上,我用了每核运行队列 + 全局负载监控器的结构:
// 负载均衡决策逻辑(简化版)
void load_balancer_tick() {
for each core c {
load[c] = get_core_utilization(c);
}
max_load = max(load);
min_load = min(load);
if (max_load - min_load < THRESHOLD) return; // 负载均衡,不动
core_t *busy = get_core_with_max_load();
core_t *idle = get_core_with_min_load();
task_t *candidate = select_migratable_task(busy);
if (candidate == NULL) return; // 没有可迁移的任务
// 评估迁移代价
int cost = estimate_migration_cost(candidate, busy, idle);
int benefit = load[busy->id] - load[idle->id];
if (benefit > cost * 2) { // 收益至少是代价的两倍
migrate_task(candidate, idle);
}
}
这个方案在8核系统上测试,负载不均衡度从原来的平均35%降到了8%以内。而且没有出现抖动——因为迁移阈值设得比较保守,只有负载差超过20%时才触发。
最后说一句:没有银弹。G-EDF、G-RM、负载均衡,各有各的适用场景。我个人的习惯是:
- 任务周期固定、数量少 → G-RM + 静态负载均衡
- 任务有动态截止时间、数量中等 → G-EDF + 动态负载均衡
- 混合场景 → 分区调度(后面章节会详细讲)
嗯,今天就到这儿。下一章咱们聊分区调度——另一种完全不同的思路,把多核问题拆成多个单核问题。到时候见。