各位同仁,各位技术爱好者,大家好!
今天,我们将深入探讨一个在多处理器系统调度领域至关重要且极具挑战性的问题:“全局运行队列”(Global Run Queue, GRQ)的饥饿问题,以及现代操作系统如何通过在本地队列与全局队列之间巧妙地平衡负载来规避或缓解这一问题。作为一名编程专家,我将以讲座的形式,从底层逻辑、代码实现到设计哲学,为大家剖析这一复杂机制。
一、 多处理器调度引论:任务与算力的协调艺术
在单处理器时代,操作系统的调度器相对简单,它只需要决定下一个在唯一一个CPU上运行的任务是哪一个。然而,随着多核处理器、超线程技术的普及,我们的系统拥有了多个可以同时执行指令的CPU核心。这带来了巨大的并行计算能力,但也引入了新的复杂性:如何有效地将成百上千个“就绪”的任务分配给有限的、并发工作的CPU核心?这就是多处理器调度的核心问题。
任务 (Task):在操作系统语境中,一个任务通常指一个线程(thread)或一个进程(process)。它们是等待CPU时间的基本执行单元。
CPU 核心 (CPU Core):物理上可以独立执行指令的处理器单元。
调度器 (Scheduler):操作系统内核的一部分,负责管理所有就绪任务,并决定将哪个任务分配给哪个CPU核心执行,以及执行多长时间。
为了管理这些就绪任务,调度器需要一个地方来存放它们——这就是“运行队列”(Run Queue)。运行队列本质上是一个数据结构,存储了所有等待CPU执行的任务。根据其组织方式,我们可以将其大致分为两种基本模型:全局运行队列(GRQ)和本地运行队列(LRQ)。
二、 全局运行队列 (Global Run Queue) 模型及其饥饿问题
2.1 GRQ模型描述
在全局运行队列模型中,系统中所有的就绪任务都被放置在一个单一的、共享的运行队列中。任何一个空闲的CPU核心,都可以从这个唯一的GRQ中取出一个任务来执行。
// 伪代码:全局运行队列的数据结构
struct GlobalRunQueue {
Task* head; // 队列头
Task* tail; // 队列尾
Spinlock lock; // 保护队列访问的自旋锁
int num_tasks; // 队列中任务数量
// ... 其他调度相关参数
};
// 假设全局有一个GRQ实例
struct GlobalRunQueue grq;
// 任务结构
struct Task {
int id;
int priority;
enum TaskState state; // READY, RUNNING, BLOCKED, etc.
// ... 其他任务上下文信息
};
2.2 GRQ的优点
- 实现简单:逻辑清晰,所有CPU共享一个任务池。
- 天然的负载均衡:只要有CPU空闲,它就能从GRQ中获取任务。理论上,只要GRQ中有任务,就不会有CPU核心是空闲的,从而避免了局部过载和局部空闲并存的情况。
2.3 GRQ的缺点与饥饿问题
尽管GRQ看起来简单高效,但在多核甚至多插槽的现代系统中,它暴露出了严重的问题,其中最核心的就是可伸缩性瓶颈和由此引发的任务饥饿(Starvation)。
2.3.1 锁竞争 (Lock Contention)
GRQ是一个共享资源。为了确保在多CPU并发访问时数据的一致性,任何对GRQ的修改(如添加新任务、取出任务、重新插入任务)都必须通过加锁来保护。在拥有大量CPU核心的系统中,当所有核心频繁地尝试访问GRQ时,锁会成为一个高竞争热点 (Hot Spot)。
// 伪代码:CPU从GRQ中取出任务
Task* get_next_task_from_grq() {
acquire_spinlock(&grq.lock); // 获取锁
if (grq.head == NULL) {
release_spinlock(&grq.lock); // 队列为空,释放锁
return NULL;
}
Task* task = grq.head;
grq.head = grq.head->next; // 移除任务
if (grq.head == NULL) {
grq.tail = NULL;
}
grq.num_tasks--;
release_spinlock(&grq.lock); // 释放锁
return task;
}
// 伪代码:CPU将任务放回GRQ
void put_task_back_to_grq(Task* task) {
acquire_spinlock(&grq.lock); // 获取锁
task->next = NULL;
if (grq.tail == NULL) {
grq.head = task;
grq.tail = task;
} else {
grq.tail->next = task;
grq.tail = task;
}
grq.num_tasks++;
release_spinlock(&grq.lock); // 释放锁
}
当多个CPU尝试同时执行acquire_spinlock时,只有一个CPU能成功获取锁,其他CPU必须自旋等待(忙等待)。这意味着大量CPU周期被浪费在等待锁上,而不是执行实际的工作。随着核心数量的增加,这种等待时间会呈指数级增长,严重限制了系统的整体吞吐量。
2.3.2 缓存失效 (Cache Invalidation) 与缓存亲和性 (Cache Affinity) 丢失
现代CPU都有多级缓存(L1, L2, L3),用于存储最近访问的数据和指令,以提高访问速度。当一个任务在一个CPU上运行时,它的数据和指令会被加载到该CPU的缓存中。如果这个任务在执行了一小段时间后,被放回GRQ,然后又被另一个CPU取走并执行,那么:
- 缓存失效:第一个CPU缓存中关于这个任务的数据会变得“陈旧”或无效。
- 缓存亲和性丢失:第二个CPU必须重新从主内存或更远的缓存级别加载这个任务的数据,这会产生显著的性能开销。任务频繁地在不同CPU之间“跳跃”,导致每个CPU的缓存都无法有效利用,出现所谓的缓存抖动 (Cache Thrashing)。这极大地降低了任务的执行效率。
2.3.3 真正的“饥饿”问题
在GRQ模型下,真正的任务饥饿可以体现在几个层面:
- 锁等待引起的“隐性饥饿”:虽然任务最终会运行,但由于GRQ锁竞争,CPU获取任务的延迟增加,导致任务的响应时间变长。从宏观上看,任务好像被“饿着”了,因为它迟迟无法被调度。
- 调度策略不公平导致的“显性饥饿”:如果GRQ的调度策略是基于优先级或简单的FIFO(先进先出),并且系统中有源源不断的高优先级任务或新任务产生,那么低优先级任务可能永远无法得到执行机会,或者被反复推迟到队列末尾。例如,一个低优先级计算密集型任务,可能会被不断涌入的I/O密集型高优先级任务反复抢占,导致其执行时间无限期延长。
- 频繁迁移导致的“效率饥饿”:即便任务获得了CPU时间,如果它因为GRQ的负载均衡机制(或缺乏亲和性考虑)而在不同CPU之间频繁迁移,其缓存亲和性被破坏,执行效率低下。任务虽然在运行,但实际进展缓慢,如同被“饿瘦”了一般。
在多处理器系统中,我们追求的不仅仅是任务最终能运行,更是要高效、公平地运行。GRQ模型在这些方面表现出了严重的不足。
三、 本地运行队列 (Local Run Queue) 模型
为了解决GRQ的缺点,本地运行队列模型应运而生。
3.1 LRQ模型描述
在LRQ模型中,每个CPU核心都拥有一个独立的、私有的运行队列。当一个任务被创建或从阻塞状态变为就绪状态时,它通常会被放置在当前CPU(或创建它的CPU)的本地运行队列中。一个CPU核心只会从自己的本地队列中获取任务来执行。
// 伪代码:本地运行队列的数据结构
struct LocalRunQueue {
Task* head; // 队列头
Task* tail; // 队列尾
Spinlock lock; // 保护本地队列的锁
int num_tasks; // 队列中任务数量
// ... 其他调度相关参数
};
// 假设每个CPU核心都有一个LRQ实例
// 例如:LocalRunQueue cpu_queues[NUM_CPUS];
3.2 LRQ的优点
- 减少锁竞争:每个CPU主要访问自己的本地队列,这大大减少了对全局锁的竞争。CPU可以并发地从自己的队列中调度任务,显著提高了调度器的可伸缩性。
- 改善缓存亲和性:任务一旦被调度到某个CPU,它倾向于在该CPU上持续运行,直到完成或阻塞。这使得任务的数据和指令能够长时间驻留在该CPU的缓存中,从而提高执行效率。
- 更好的CPU缓存利用率:减少了缓存抖动,每个CPU的缓存都能更有效地为本地任务服务。
3.3 LRQ的缺点:负载不均衡
LRQ模型最大的缺点在于负载均衡问题。设想以下场景:
- CPU0的本地队列中有100个就绪任务,而CPU1的本地队列中只有1个任务,甚至完全空闲。
- CPU0将长时间处于高负荷状态,任务响应缓慢;而CPU1则可能长期处于低负荷或空闲状态,计算资源被浪费。
// 伪代码:LRQ模型的调度循环
void cpu_scheduler_loop(int cpu_id) {
struct LocalRunQueue* my_lq = &cpu_queues[cpu_id];
while (true) {
acquire_spinlock(&my_lq->lock);
Task* task = my_lq->head;
if (task != NULL) {
my_lq->head = my_lq->head->next;
if (my_lq->head == NULL) {
my_lq->tail = NULL;
}
my_lq->num_tasks--;
}
release_spinlock(&my_lq->lock);
if (task != NULL) {
run_task(task); // 执行任务
// 任务完成后,如果仍然就绪,可能放回my_lq
// 如果阻塞,则从队列中移除
} else {
// CPU空闲,无事可做
idle_cpu(cpu_id);
}
}
}
纯粹的LRQ模型无法自动解决这种负载倾斜问题,这将导致系统整体吞吐量下降,并可能导致某些任务的实际饥饿(因为它们被困在过载的队列中,而其他CPU却空闲)。
四、 混合模型:在本地与全局间平衡负载的艺术
为了兼顾LRQ的低竞争和高缓存亲和性,以及GRQ的负载均衡能力,现代操作系统调度器普遍采用了混合模型。这种模型的核心思想是:以本地队列为主,以跨CPU的负载均衡机制为辅。处理器P如何在本地队列与全局队列之间平衡负载,正是这种混合模型的精髓。
4.1 核心机制:工作窃取 (Work Stealing) 与工作分享 (Work Sharing)
4.1.1 工作窃取 (Work Stealing)
这是最常见的负载均衡策略。当一个CPU核心(我们称之为“窃取者”,stealer)的本地队列为空或任务不足时,它不会坐视不管,而是主动地去检查其他CPU核心(“受害者”,victim)的本地队列,并从中“窃取”一些任务到自己的本地队列来执行。
- 优点:
- 延迟驱动:只有当CPU空闲时才进行窃取,避免了不必要的开销。
- 局部性优化:任务倾向于在窃取它的CPU上运行,保持了缓存亲和性。
- 挑战:
- 窃取目标选择:如何高效地找到一个过载的“受害者”?随机选择?轮询?基于负载统计?
- 窃取策略:窃取多少个任务?窃取队列的头部(FIFO)还是尾部(LIFO)?窃取尾部可以更好地利用缓存亲和性,因为最近加入的任务可能与其他CPU的缓存数据无关,而老任务则可能已经预热了受害者的缓存。
- 同步开销:窃取者在窃取任务时,需要锁定受害者的队列。但由于窃取操作通常不那么频繁,且只发生在受害者队列较长时,所以锁竞争比GRQ低得多。
// 伪代码:LocalRunQueue 结构(支持窃取)
struct LocalRunQueue {
Task* head; // 队列头
Task* tail; // 队列尾
Spinlock lock; // 保护本地队列的锁
int num_tasks; // 队列中任务数量
// ... 其他调度相关参数
};
// 伪代码:工作窃取函数
Task* try_steal_task(int stealer_cpu_id) {
// 1. 选择一个受害者CPU
// 简单起见,这里假设轮询所有其他CPU
int victim_cpu_id = (stealer_cpu_id + 1) % NUM_CPUS;
// 复杂的调度器会根据CPU负载、拓扑结构等选择最佳受害者
struct LocalRunQueue* victim_lq = &cpu_queues[victim_cpu_id];
// 尝试获取受害者队列的锁 (非阻塞尝试或短时间重试)
if (try_acquire_spinlock(&victim_lq->lock)) {
if (victim_lq->num_tasks > 1) { // 至少保留一个任务给受害者自己
// 策略:从受害者的队列尾部窃取(LIFO),对受害者缓存亲和性影响最小
Task* stolen_task = remove_from_tail(victim_lq); // 假设有此函数
if (stolen_task != NULL) {
release_spinlock(&victim_lq->lock);
// 将窃取的任务放入自己的本地队列
add_to_local_queue(&cpu_queues[stealer_cpu_id], stolen_task);
return stolen_task;
}
}
release_spinlock(&victim_lq->lock);
}
return NULL; // 窃取失败
}
// 伪代码:CPU调度循环(包含工作窃取)
void cpu_scheduler_loop_hybrid(int cpu_id) {
struct LocalRunQueue* my_lq = &cpu_queues[cpu_id];
while (true) {
Task* task = get_next_task_from_local(my_lq); // 从本地队列获取
if (task == NULL) { // 本地队列为空
task = try_steal_task(cpu_id); // 尝试窃取任务
}
if (task != NULL) {
run_task(task);
// 任务完成后,如果仍然就绪,通常会放回自己的本地队列
// 这保持了缓存亲和性
if (task->state == READY) {
add_to_local_queue(my_lq, task);
}
} else {
// 所有尝试都失败,CPU进入空闲状态
idle_cpu(cpu_id);
}
}
}
4.1.2 工作分享 (Work Sharing) / 任务推送 (Task Pushing)
与工作窃取相反,工作分享是由过载的CPU(“生产者”,producer)主动将任务“推送”给一个空闲或轻载的CPU核心(“消费者”,consumer),或者将任务推送到一个辅助的全局队列中,供其他CPU获取。
- 优点:生产者主动均衡负载,可能更早地发现并解决负载不均。
- 挑战:
- 何时推送? 需要额外的逻辑来监控自身负载和识别需要帮助的CPU。
- 推给谁? 如何选择推送目标?
- 开销:主动推送会增加繁忙CPU的负担,可能会降低其自身任务的执行效率。
在通用操作系统调度器中,工作窃取更为常见,因为它将负载均衡的开销转移到了空闲的CPU上,避免了对繁忙CPU的进一步干扰。然而,一些专门的任务调度系统(如并行运行时库)可能会采用工作分享策略。
4.2 负载均衡的触发时机与策略
负载均衡操作(无论是窃取还是分享)不应该一直进行,那样会带来过高的开销。它需要有合适的触发时机和策略:
- 周期性检查:每个CPU核心在调度循环中,每隔一定的调度周期(例如,每隔几毫秒或几十毫秒),检查一次是否需要进行负载均衡。
- 负载阈值:只有当CPU的本地队列长度超过某个阈值,或者CPU的平均负载超过某个阈值时,才认为该CPU是“过载”的,值得被窃取或需要推送。
- 空闲触发:当一个CPU的核心本地队列完全空闲时,它会立即尝试进行工作窃取。
- NUMA拓扑感知:在非统一内存访问(NUMA)架构下,访问远程内存的开销远大于本地内存。调度器在进行负载均衡时,会尽量将任务迁移到与当前CPU在同一NUMA节点上的CPU,以保持内存亲和性。如果必须跨NUMA节点迁移,会优先考虑将数据访问量较小的任务迁移。
- 任务亲和性提示:应用程序可以通过设置CPU亲和性掩码(CPU Affinity Mask)来指定一个任务只能在某些特定的CPU上运行。调度器必须尊重这些提示,避免将任务迁移到不允许的CPU上。
- 迁移成本评估:调度器会评估迁移一个任务的成本(如缓存失效的开销)与迁移带来的收益(如提高负载均衡)之间的权衡。对于长时间运行且缓存热度高的任务,调度器会倾向于让其留在原地。
4.3 操作系统中的实现概览 (以Linux CFS为例)
Linux的Completely Fair Scheduler (CFS) 是一个基于本地运行队列和工作窃取的典型例子。
- 运行队列 (Runqueue):每个CPU都有一个
struct rq(运行队列)实例,其中包含了当前CPU所有就绪任务的红黑树(而不是简单的链表,以实现公平性)。 - 调度实体 (Scheduling Entity,
se):每个任务(或cgroup)都被抽象为一个调度实体,CFS通过调度实体来跟踪任务的虚拟运行时间(vruntime)。 - 负载跟踪 (Load Tracking):每个CPU周期性地更新其负载信息,这些信息可以被其他CPU用于负载均衡决策。
scheduler_tick():每个时钟中断都会调用此函数,进行任务切换、更新任务统计、以及触发负载均衡检查。load_balance():这是Linux内核中负责跨CPU负载均衡的核心函数。它由一个CPU发起,检查其所在调度域(scheduling domain,通常是CPU组或NUMA节点)内的其他CPU的负载情况。如果发现负载不均,它会尝试从过载的CPU窃取任务。
负载均衡的逻辑流程(简化版):
- CPU A (Stealer) 发现自己空闲或负载过轻。
- CPU A 遍历其调度域中的其他CPU (Victims)。
- 对于每个Victim CPU B:
- 获取 CPU B 的负载信息。
- 比较 CPU A 和 CPU B 的负载。 如果 CPU B 明显过载,且满足迁移条件(如任务数量、
vruntime差异等),则认为 CPU B 是一个合适的窃取目标。 - 尝试从 CPU B 的运行队列中窃取任务。
- 通常会尝试窃取具有最低
vruntime(即“最不公平”)的任务,以加速其运行。 - 或者窃取最近加入队列的任务,以减少对受害者缓存的影响。
- 窃取过程中需要持有 Victim CPU B 的运行队列锁。
- 通常会尝试窃取具有最低
- 将窃取的任务添加到 CPU A 自己的运行队列中。
- 如果成功窃取,CPU A 继续执行任务;否则,继续寻找其他 Victim,或进入空闲状态。
表格:GRQ vs. LRQ vs. Hybrid 调度模型对比
| 特性/模型 | 全局运行队列 (GRQ) | 本地运行队列 (LRQ) | 混合模型 (LRQ + 工作窃取) |
|---|---|---|---|
| 可伸缩性 | 差 (锁竞争严重) | 优 (减少锁竞争) | 优 (低竞争,按需均衡) |
| 缓存亲和性 | 差 (任务频繁迁移) | 优 (任务倾向于本地运行) | 优 (任务倾向于本地运行,按需迁移) |
| 负载均衡能力 | 优 (天然均衡) | 差 (易出现负载倾斜) | 优 (通过工作窃取/分享实现) |
| 调度器复杂性 | 低 | 中 (需管理多个队列) | 高 (需实现负载均衡逻辑) |
| 饥饿问题 | 容易发生 (锁竞争、策略不公) | 易发生 (任务困在过载队列中) | 较少发生 (通过均衡机制缓解) |
| 典型应用 | 早期单处理器/少量核系统 | 某些实时嵌入式系统 | 现代通用多核操作系统 (Linux, Windows) |
五、 混合模型对GRQ饥饿问题的缓解与新挑战
混合模型通过在本地队列和全局负载均衡之间取得平衡,极大地缓解了纯GRQ模型中的饥饿问题。
5.1 缓解GRQ饥饿问题
- 降低锁竞争:主要操作发生在本地队列,对本地队列的锁是CPU私有的,因此不存在全局竞争。跨CPU窃取虽然有锁,但频率远低于纯GRQ,且通常是空闲CPU发起,不影响繁忙CPU的正常调度。这解决了GRQ模型中因锁竞争导致的“隐性饥饿”。
- 提升缓存亲和性:任务倾向于在本地队列中运行,减少了不必要的迁移。即使发生迁移,也通常是经过权衡后的结果,避免了“效率饥饿”。
- 确保公平性:工作窃取机制确保了即便一个CPU过载,其队列中的任务最终也能被其他空闲CPU“解救”。配合调度器内部的公平性算法(如CFS的
vruntime),可以有效避免因调度策略不公导致的“显性饥饿”。任何等待时间过长的任务,其vruntime会变得很小,从而在窃取时更容易被选中。
5.2 混合模型的新挑战
尽管混合模型表现出色,但它并非完美,也面临着新的挑战:
- 负载均衡开销:负载均衡本身需要CPU时间。过度频繁或复杂的均衡操作会消耗宝贵的CPU资源,反而降低了系统整体性能。需要在均衡的收益和开销之间找到最佳平衡点。
- NUMA效应:在NUMA架构下,任务迁移不仅是CPU间的移动,还可能涉及到内存页的迁移或远程内存访问,这会带来显著的性能惩罚。调度器需要深入理解硬件拓扑,进行NUMA感知的负载均衡。
- 实时性要求:对于具有严格实时性要求的任务,负载均衡可能会引入不可预测的延迟(例如,任务被窃取到另一个CPU,或者CPU在进行负载均衡时暂停了对实时任务的调度)。
- 任务类型差异:I/O密集型任务和CPU密集型任务对负载均衡的需求不同。频繁迁移I/O密集型任务可能影响其I/O性能,而CPU密集型任务则更需要稳定的CPU时间。
- 调度器复杂性:混合模型调度器(如Linux CFS)非常复杂,需要维护大量的统计信息、复杂的算法和数据结构,调试和优化也更具挑战性。
六、 代码示例:负载均衡的抽象实现
为了更好地理解负载均衡的内部机制,我们来看一个更具体的抽象代码示例,它模拟了调度器在每个时钟周期可能执行的一些操作,包括本地调度和负载均衡。
#include <stdio.h>
#include <stdlib.h>
#include <stdbool.h>
#include <pthread.h>
#include <unistd.h> // for usleep
#include <time.h> // for nanosleep
#define NUM_CPUS 4
#define MAX_TASKS_PER_QUEUE 10
#define IDLE_THRESHOLD 2 // 队列任务数低于此值被认为是轻载/空闲
#define BALANCE_INTERVAL_TICKS 100 // 每100个tick进行一次负载均衡检查
// 任务状态
typedef enum {
TASK_READY,
TASK_RUNNING,
TASK_BLOCKED,
TASK_DONE
} TaskState;
// 任务结构
typedef struct Task {
int id;
int priority;
long vruntime; // 虚拟运行时间,用于公平性调度
TaskState state;
struct Task* next;
struct Task* prev; // 双向链表方便移除
int assigned_cpu; // 记录任务当前所在CPU
} Task;
// 简化版自旋锁
typedef struct {
volatile int flag;
} Spinlock;
void spinlock_init(Spinlock* lock) {
lock->flag = 0;
}
void spinlock_acquire(Spinlock* lock) {
while (__sync_lock_test_and_set(&lock->flag, 1)) {
// 自旋等待
}
}
void spinlock_release(Spinlock* lock) {
__sync_lock_release(&lock->flag);
}
// 本地运行队列结构
typedef struct LocalRunQueue {
int cpu_id;
Spinlock lock;
Task* head;
Task* tail;
int num_tasks;
long current_load; // 简化负载指标,可以是任务数或vruntime总和
long balance_ticks; // 记录需要多久进行一次负载均衡
} LocalRunQueue;
LocalRunQueue cpu_queues[NUM_CPUS];
pthread_t cpu_threads[NUM_CPUS];
// 全局任务ID计数器
volatile int global_task_id = 0;
Spinlock task_id_lock;
// 函数声明
void add_task_to_local_queue(LocalRunQueue* lq, Task* task);
Task* get_next_task_from_local_queue(LocalRunQueue* lq);
Task* remove_task_from_local_queue_by_id(LocalRunQueue* lq, int task_id);
Task* remove_task_from_local_queue_tail(LocalRunQueue* lq);
void balance_load(int current_cpu_id);
void* cpu_scheduler_loop(void* arg);
Task* create_new_task(int cpu_id);
// 辅助函数:将任务添加到队列
void add_task_to_local_queue(LocalRunQueue* lq, Task* task) {
spinlock_acquire(&lq->lock);
task->state = TASK_READY;
task->assigned_cpu = lq->cpu_id;
task->next = NULL;
task->prev = lq->tail; // for doubly linked list
if (lq->tail == NULL) {
lq->head = task;
lq->tail = task;
} else {
lq->tail->next = task;
lq->tail = task;
}
lq->num_tasks++;
lq->current_load += task->priority; // 简化负载计算
printf("[CPU %d] Added Task %d. Queue size: %dn", lq->cpu_id, task->id, lq->num_tasks);
spinlock_release(&lq->lock);
}
// 辅助函数:从队列中获取下一个任务 (这里简化为FIFO)
Task* get_next_task_from_local_queue(LocalRunQueue* lq) {
Task* task = NULL;
spinlock_acquire(&lq->lock);
if (lq->head != NULL) {
task = lq->head;
lq->head = lq->head->next;
if (lq->head != NULL) {
lq->head->prev = NULL;
} else {
lq->tail = NULL;
}
lq->num_tasks--;
lq->current_load -= task->priority;
}
spinlock_release(&lq->lock);
return task;
}
// 辅助函数:从队列尾部移除任务 (用于工作窃取)
Task* remove_task_from_local_queue_tail(LocalRunQueue* lq) {
Task* task = NULL;
spinlock_acquire(&lq->lock);
// 确保队列中有足够任务可以窃取 (至少保留一个给本地CPU)
if (lq->num_tasks > IDLE_THRESHOLD) {
task = lq->tail;
if (task != NULL) {
lq->tail = task->prev;
if (lq->tail != NULL) {
lq->tail->next = NULL;
} else {
lq->head = NULL;
}
lq->num_tasks--;
lq->current_load -= task->priority;
}
}
spinlock_release(&lq->lock);
return task;
}
// 创建新任务
Task* create_new_task(int cpu_id) {
spinlock_acquire(&task_id_lock);
int new_id = global_task_id++;
spinlock_release(&task_id_lock);
Task* new_task = (Task*)malloc(sizeof(Task));
new_task->id = new_id;
new_task->priority = rand() % 5 + 1; // 1-5 随机优先级
new_task->vruntime = 0; // 初始vruntime
new_task->state = TASK_READY;
new_task->next = NULL;
new_task->prev = NULL;
new_task->assigned_cpu = cpu_id;
return new_task;
}
// 核心:负载均衡逻辑 (工作窃取)
void balance_load(int current_cpu_id) {
LocalRunQueue* my_lq = &cpu_queues[current_cpu_id];
// 如果我的队列不空闲,或者任务太多,则不进行窃取
if (my_lq->num_tasks > IDLE_THRESHOLD) {
return;
}
// 遍历其他CPU,寻找过载的受害者
for (int i = 0; i < NUM_CPUS; ++i) {
if (i == current_cpu_id) continue;
LocalRunQueue* victim_lq = &cpu_queues[i];
// 尝试获取受害者队列的锁 (非阻塞尝试,简化为阻塞)
// 实际中可能用 trylock 或更复杂的无锁数据结构
spinlock_acquire(&victim_lq->lock);
// 如果受害者队列任务数远超我方,或者负载远超我方,则尝试窃取
if (victim_lq->num_tasks > my_lq->num_tasks + 1 && victim_lq->num_tasks > IDLE_THRESHOLD) {
// 窃取策略:从尾部窃取,避免影响头部任务的缓存
Task* stolen_task = remove_task_from_local_queue_tail(victim_lq);
if (stolen_task != NULL) {
spinlock_release(&victim_lq->lock); // 先释放受害者的锁
add_task_to_local_queue(my_lq, stolen_task);
printf("[CPU %d] STOLE Task %d from CPU %d. My queue: %d, Victim queue: %dn",
current_cpu_id, stolen_task->id, victim_lq->cpu_id, my_lq->num_tasks, victim_lq->num_tasks);
return; // 成功窃取一个就返回
}
}
spinlock_release(&victim_lq->lock);
}
}
// 每个CPU的调度循环
void* cpu_scheduler_loop(void* arg) {
int cpu_id = *(int*)arg;
LocalRunQueue* my_lq = &cpu_queues[cpu_id];
printf("CPU %d started.n", cpu_id);
// 初始时给每个CPU分配一些任务
for (int i = 0; i < (cpu_id == 0 ? 5 : 1); ++i) { // CPU 0 初始任务多一些
add_task_to_local_queue(my_lq, create_new_task(cpu_id));
}
long tick_count = 0;
while (true) {
tick_count++;
Task* current_task = get_next_task_from_local_queue(my_lq);
if (current_task != NULL) {
current_task->state = TASK_RUNNING;
// 模拟任务执行
// usleep(1000); // 1ms
printf("[CPU %d] Running Task %d (Prio: %d, vruntime: %ld)n",
cpu_id, current_task->id, current_task->priority, current_task->vruntime);
current_task->vruntime += 1; // 简化vruntime更新
current_task->state = TASK_READY; // 假设任务时间片用完,放回队列
add_task_to_local_queue(my_lq, current_task);
} else {
// 本地队列空,尝试进行负载均衡 (工作窃取)
printf("[CPU %d] Local queue empty. Attempting to balance load...n", cpu_id);
balance_load(cpu_id);
// 如果负载均衡后还是没有任务,则模拟CPU空闲
if (my_lq->num_tasks == 0) {
printf("[CPU %d] Idle.n", cpu_id);
struct timespec ts = {0, 10000000}; // 10ms
nanosleep(&ts, NULL); // 模拟空闲等待
}
}
// 周期性地进行全局负载均衡检查(即使不空闲也检查,防止长期不均衡)
if (tick_count % BALANCE_INTERVAL_TICKS == 0) {
balance_load(cpu_id);
}
// 模拟外部事件:随机添加新任务
if (rand() % 20 == 0) { // 约5%的几率
add_task_to_local_queue(my_lq, create_new_task(cpu_id));
}
}
return NULL;
}
int main() {
srand(time(NULL));
spinlock_init(&task_id_lock);
for (int i = 0; i < NUM_CPUS; ++i) {
cpu_queues[i].cpu_id = i;
spinlock_init(&cpu_queues[i].lock);
cpu_queues[i].head = NULL;
cpu_queues[i].tail = NULL;
cpu_queues[i].num_tasks = 0;
cpu_queues[i].current_load = 0;
cpu_queues[i].balance_ticks = 0; // Initialize for future use
}
int cpu_ids[NUM_CPUS];
for (int i = 0; i < NUM_CPUS; ++i) {
cpu_ids[i] = i;
pthread_create(&cpu_threads[i], NULL, cpu_scheduler_loop, &cpu_ids[i]);
}
for (int i = 0; i < NUM_CPUS; ++i) {
pthread_join(cpu_threads[i], NULL);
}
return 0;
}
代码解释:
Task结构:定义了任务的基本属性,包括ID、优先级、虚拟运行时间vruntime(用于调度公平性),以及状态和队列指针。Spinlock:一个简化的自旋锁,用于保护队列的并发访问。LocalRunQueue结构:每个CPU一个,包含任务链表、锁、任务计数等。add_task_to_local_queue/get_next_task_from_local_queue/remove_task_from_local_queue_tail:队列操作的辅助函数,都通过自旋锁保护。remove_task_from_local_queue_tail用于工作窃取,从队列尾部移除任务。create_new_task:模拟创建新任务。balance_load函数:这是负载均衡的核心。- 它首先检查当前CPU的本地队列是否空闲或任务很少(低于
IDLE_THRESHOLD)。 - 然后,它遍历其他CPU的队列,寻找那些任务数量显著多于当前CPU的“受害者”。
- 一旦找到合适的受害者,它会尝试从受害者的队列尾部窃取一个任务。从尾部窃取是常见的策略,因为它对受害者队列中已经缓存的任务影响最小。
- 窃取成功后,将任务添加到自己的本地队列。
- 它首先检查当前CPU的本地队列是否空闲或任务很少(低于
cpu_scheduler_loop函数:每个CPU线程的主循环。- 尝试从自己的本地队列获取任务并执行。
- 如果本地队列为空,则调用
balance_load尝试窃取任务。 - 周期性地(每
BALANCE_INTERVAL_TICKS)调用balance_load,即使不空闲也检查,以处理长期负载不均的情况。 - 模拟了任务的
vruntime更新和状态转换。 - 随机模拟新任务的创建,并添加到当前CPU的本地队列。
这个示例模拟了一个非常简化的混合调度器行为。在实际的操作系统中,如Linux CFS,vruntime的计算、任务选择、负载均衡的触发条件和受害者选择策略都更为复杂和精细,以实现更优的公平性和性能。
七、 总结与展望:动态平衡的艺术
我们今天深入探讨了多处理器调度中的一个核心难题——全局运行队列的饥饿问题,以及现代操作系统如何通过结合本地运行队列和智能的负载均衡机制(特别是工作窃取)来有效解决它。纯粹的全局队列模型因其固有的锁竞争和缓存亲和性问题,在多核时代已经不可用。本地队列模型虽然解决了这些问题,却引入了负载不均的挑战。
混合模型是这两种极端方案的优雅结合,它以本地队列为核心,确保了低竞争和高缓存亲和性,并通过按需触发的跨CPU工作窃取机制,动态地在各个核心之间平衡负载,从而避免了任务饥饿,并优化了系统整体性能。这种在局部效率和全局公平性之间寻求动态平衡的艺术,是现代操作系统调度器设计的基石,也是未来高性能计算持续优化的方向。随着异构计算、NUMA架构以及更大规模并行系统的普及,调度器设计将继续演进,以期在不断变化的硬件环境下,提供更智能、更高效的任务管理。