mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4
2169 字
6 分钟
调度器:运行队列与 Round-Robin
2021-07-15

上一章实现了基本的上下文切换,但还没有公平的调度策略。如果没有调度算法,某些任务可能永远得不到执行机会,这叫饿死(starvation)。调度器要解决的就是”谁先跑、跑多久、何时换”这三个问题。

调度问题#

在单核处理器上,同一时间只能运行一个任务。当多个任务都想运行时,操作系统必须决定执行顺序。最朴素的想法是先来先服务(FCFS):谁先创建谁先跑,跑完了再换下一个。但 FCFS 有明显的问题,如果一个任务执行时间很长,后面所有任务都要等,响应性很差。短任务可能被长任务阻塞很久,这对交互式场景(比如键盘输入响应)是不可接受的。

graph TD A[多个任务就绪] --> B{调度器决策} B --> C[选择任务A] C --> D[执行时间片] D --> E{时间片用完?} E -->|是| F[保存A的上下文] E -->|否| D F --> G[恢复B的上下文] G --> H[选择任务B] H --> D

理解了调度问题的本质,接下来看历史上提出的几种解决方案。

调度算法分类#

不同的应用场景需要不同的调度策略:

  • 桌面系统:需要快速响应用户输入,优先级调度
  • 服务器:需要高吞吐量,公平调度
  • 实时系统:需要确定性的响应时间,实时调度
┌─────────────────────────────────────┐
│ 调度策略 │
├──────────────┬──────────────────────┤
│ 非抢占式 │ 抢占式 │
│ (协作式) │ (时间片中断) │
├──────────────┴──────────────────────┤
│ FCFS │ RR │ Priority │ Multilevel │
│ (先来先服务)│(时间片轮转)│(优先级)│(多级队列)│
└─────────────────────────────────────┘

FCFS 简单但不公平;最短作业优先(SJF)理论上最优,但无法预知任务执行时间;时间片轮转(Round-Robin)给每个任务分配固定时间片,轮流执行,是最直观的公平调度方案。Round-Robin 是最适合教学操作系统的算法,它需要一个运行队列来管理就绪任务。

运行队列(Run Queue)#

运行队列是调度器的核心数据结构,它保存所有可运行(RUNNABLE)状态的任务。调度器从这个队列中选择下一个要执行的任务。

/* 运行队列结构 */
typedef struct runqueue {
struct list_node head; /* 虚拟头节点 */
uint32_t count; /* 队列中任务数量 */
} runqueue_t;

这个设计非常简洁:使用链表来存储任务,head 是虚拟头节点,不指向实际任务,count 记录队列中的任务数量,便于快速判断队列是否为空。

运行队列 (runqueue_t)
+--------+ +--------+ +--------+ +--------+
| head |---->| Task A |---->| Task B |---->| Task C |----> NULL
+--------+ +--------+ +--------+ +--------+
虚拟头节点

运行队列的操作接口:

/* 初始化运行队列 */
void runqueue_init(runqueue_t *rq);
/* 将任务加入运行队列尾部 */
void runqueue_add(runqueue_t *rq, task_t *task);
/* 从运行队列移除任务 */
void runqueue_remove(runqueue_t *rq, task_t *task);
/* 从运行队列头部取出任务(FIFO调度) */
task_t *runqueue_pop(runqueue_t *rq);
/* 检查运行队列是否为空 */
int runqueue_empty(runqueue_t *rq);

这些操作实现了典型的队列(Queue)数据结构:先进先出(FIFO)。有了运行队列,还需要决定每个任务运行多久,这就是时间片。

时间片轮转调度(Round-Robin)#

如果某个任务一直占用 CPU,其他任务就无法运行。时间片(Time Slice)机制确保每个任务只能运行固定的时间,之后必须切换到下一个任务,从而实现公平性。每个任务分配固定的时间片,时间片用完后切换到下一个任务:

时间轴:
|-- Task A (20 ticks) --|-- Task B (20 ticks) --|-- Task C (20 ticks) --|
^ ^
| |
切换 切换

在本章的实现中,MAX_TIMESLICE_TICKS = 20,意味着每个任务最多运行 20 个时钟 tick。

时间片的大小是一个权衡:时间片太小,频繁切换,上下文切换开销大;时间片太大,响应性差,看起来像非抢占式。本节选择 20 个 tick(在 100 Hz 时钟下约 200ms)是一个适中的选择。

graph LR A[时间片大小] --> B{权衡} B -->|太小| C[切换频繁<br/>开销大] B -->|太大| D[响应慢<br/>公平性差] B -->|适中| E[本节: 20 ticks<br/>~200ms]

时间片到期后,谁来触发切换?答案是时钟中断,这就是抢占式调度。

抢占式调度#

**抢占式调度(Preemptive Scheduling)**是指操作系统能够强制剥夺当前运行任务的 CPU 使用权,切换到其他任务。这与协作式调度(Cooperative Scheduling)相对,后者要求任务主动让出 CPU。

时钟中断是抢占式调度的驱动力。每当时钟中断到来,中断处理函数检查当前任务的时间片是否用完;如果用完,就触发调度,强制切换到下一个任务。

sequenceDiagram participant Timer as 定时器中断 participant Callback as timer_callback participant Scheduler as schedule participant Task as 任务A participant Next as 任务B Timer->>Callback: 发生中断(每tick) Callback->>Callback: tick++ Callback->>Callback: tick % 20 == 0? alt 时间片用完 Callback->>Scheduler: schedule_request() Callback->>Scheduler: schedule() Scheduler->>Task: 保存上下文 Scheduler->>Next: 恢复上下文 Next->>Next: 继续执行 else 时间片未完 Task->>Task: 继续执行 end

时钟中断回调的核心代码:

#define MAX_TIMESLICE_TICKS 20 /* 每个任务最多运行20个tick */
static void timer_callback(interrupt_frame_t *regs)
{
tick++;
/* 每20个tick触发一次调度 */
if (tick % MAX_TIMESLICE_TICKS == 0) {
/* 标记需要重新调度 */
schedule_request();
/* 直接调用调度器 */
schedule();
}
}

这里的逻辑很清晰:使用全局变量 tick 记录系统时钟,模运算 tick % MAX_TIMESLICE_TICKS 检测时间片是否用完,调用 schedule() 完成实际的上下文切换。

所有就绪任务都在运行队列中,但如果队列为空呢?CPU 不能停下来,它需要一个兜底任务,这就是 Idle。

Idle 任务#

当运行队列为空(所有任务都在等待或已退出)时,调度器需要选择一个任务运行。这个任务就是 Idle 任务,它执行一个无限循环,在循环中让 CPU 进入低功耗状态。

Idle 任务有三个作用:作为占位符确保调度器始终有任务可以运行;使用 hlt 指令让 CPU 进入休眠状态降低功耗;当新任务就绪时,Idle 任务可以被立即抢占。

static void idle_thread(void *arg)
{
(void)arg;
while (1) {
/* hlt指令让CPU进入低功耗状态,直到下一个中断 */
__asm__ volatile ("hlt");
}
}

hlt(Halt)指令暂停 CPU 执行,降低功耗,等待中断到来。当中断发生时,CPU 自动恢复执行。

代码实现#

文件结构#

10.kernel-sched/
├── boot/
│ ├── mbr.S
│ └── loader.S
├── kernel/
│ ├── include/
│ │ ├── scheduler.h # 调度器接口
│ │ ├── runqueue.h # 运行队列接口
│ │ ├── task.h # 任务结构
│ │ └── timer.h # 定时器接口
│ ├── task/
│ │ ├── scheduler.c # 调度器实现
│ │ ├── runqueue.c # 运行队列实现
│ │ └── task.c # 任务管理
│ ├── interrupt/
│ │ └── timer.c # 定时器中断(集成调度)
│ └── kernel.c # 测试主程序
└── Makefile

任务结构定义:

typedef struct task {
uint32_t tid; /* 任务ID */
char name[32]; /* 任务名称 */
int state; /* 任务状态:RUNNABLE/RUNNING/BLOCKED */
int priority; /* 优先级 */
uint32_t kernel_esp; /* 内核栈顶指针 */
struct list_node rq_node; /* 运行队列链表节点 */
cpu_context_t ctx; /* CPU上下文 */
/* ... 其他字段 */
} task_t;

state 决定调度器是否将任务加入运行队列,rq_node 用于将任务加入运行队列的链表节点,ctx 保存 CPU 寄存器上下文用于上下文切换。

调度器整体流程:

flowchart TD A[定时器中断] --> B[timer_callback] B --> C{tick % 20 == 0?} C -->|否| D[继续执行当前任务] C -->|是| E[schedule] E --> F[保存prev任务上下文] F --> G[prev是RUNNABLE?] G -->|是| H[将prev加入runqueue] G -->|否| I[不加入队列] H --> J[从runqueue取出next] I --> J J --> K{next为空?} K -->|是| L[next = idle_task] K -->|否| M[使用取出的任务] L --> N[恢复next上下文] M --> N

调度器初始化#

void scheduler_init(void)
{
runqueue_t *rq = get_global_runqueue();
runqueue_init(rq); /* 初始化运行队列 */
/* 创建 idle 任务,最低优先级,不加入运行队列 */
idle_task = task_create_kernel("idle", idle_thread, NULL, 0);
if (idle_task == NULL) {
vga_printf("[Scheduler] PANIC: failed to create idle task\n");
while (1);
}
/* idle 不在运行队列中,仅在队列为空时使用 */
idle_task->state = TASK_RUNNING;
current_running_task = NULL;
need_reschedule = 0;
vga_printf("[Scheduler] Initialized (idle tid=%u)\n", idle_task->tid);
}

初始化做了四件事:初始化全局运行队列,创建 idle 任务作为调度器的”备用”任务,idle 任务不加入运行队列只在队列为空时使用,初始化全局变量将当前任务设为空。

主调度函数#

void schedule(void)
{
task_t *prev = current_running_task;
task_t *next = NULL;
runqueue_t *rq = get_global_runqueue();
/* 将当前可运行任务重新加入队列(idle 不加入) */
if (prev != NULL && prev->state == TASK_RUNNABLE && prev != idle_task) {
runqueue_add(rq, prev);
}
next = runqueue_pop(rq); /* 从队列头部取出下一个任务 */
if (next == NULL) {
next = idle_task; /* 队列为空,使用 idle 任务 */
}
if (prev == next) {
need_reschedule = 0;
return; /* 没有任务切换,直接返回 */
}
current_running_task = next;
next->state = TASK_RUNNING;
need_reschedule = 0;
if (prev == NULL) {
/* 首次调度:直接跳转到 next 的入口 */
__asm__ volatile (
"movl %0, %%esp\n\t"
"jmp resume_thread"
:
: "r" (next->kernel_esp)
: "memory"
);
} else {
/* 执行上下文切换 */
context_switch(&prev->ctx, &next->ctx);
}
}

这是调度器的核心逻辑。如果 prev 任务可运行,重新加入队列尾部;从队列头部取出下一个任务(FIFO);如果队列为空,使用 idle 任务;如果 prev 和 next 相同,直接返回避免无意义切换;首次调度时直接跳转到新任务的入口,后续调度调用 context_switch() 保存/恢复寄存器。这个实现体现了时间片轮转的核心思想:任务用完时间片后回到队列尾部,等待下一轮调度。

运行队列操作#

void runqueue_add(runqueue_t *rq, task_t *task)
{
struct list_node *new_node = &task->rq_node;
if (rq->count == 0) {
/* 队列为空,直接添加 */
rq->head.next = new_node;
new_node->prev = &rq->head;
new_node->next = NULL;
} else {
/* 找到尾节点 */
struct list_node *tail = rq->head.next;
while (tail->next != NULL) {
tail = tail->next;
}
/* 将新节点添加到尾部 */
tail->next = new_node;
new_node->prev = tail;
new_node->next = NULL;
}
rq->count++;
}
task_t *runqueue_pop(runqueue_t *rq)
{
if (rq->count == 0) {
return NULL;
}
struct list_node *first = rq->head.next;
if (first == NULL) {
return NULL;
}
/* 从队列头部移除 */
rq->head.next = first->next;
if (first->next != NULL) {
first->next->prev = &rq->head;
}
first->prev = NULL;
first->next = NULL;
rq->count--;
/* 使用container_of获取task_t */
return task_from_rq_node(first);
}

runqueue_add 将任务添加到队列尾部,保证 FIFO 顺序;runqueue_pop 从队列头部取出任务,保证时间片轮转。两者配合使用 container_of 宏从链表节点获取任务结构指针。

主动让出 CPU#

void schedule_yield(void)
{
task_t *current = current_task();
if (current != NULL && current->state == TASK_RUNNING) {
current->state = TASK_RUNNABLE;
}
schedule();
}

schedule_yield() 允许任务主动让出 CPU:将当前任务状态从 TASK_RUNNING 改为 TASK_RUNNABLE,然后立即调用 schedule() 切换到下一个任务。这个机制对于协作式多任务或任务等待资源时很有用。

运行与验证#

编译运行#

cd 10.kernel-sched
make clean
make all
make run

预期输出#

=== Chapter 10: Kernel Scheduler ===
Initializing hardware...
GDT...OK
IDT...OK
Interrupts...OK
Timer (100 Hz)...OK
Boot info...OK
Physical memory...OK
Virtual memory...OK
Kernel heap...OK
--- Scheduler Test ---
Initializing task subsystem...
[Scheduler] Initialized (idle tid=1)
Creating test threads...
Threads created:
test1: tid=2, priority=5
test2: tid=3, priority=5
test3: tid=4, priority=3
--- Starting Scheduler ---
Tasks will yield CPU voluntarily in this test.
In a real system, timer interrupts would trigger scheduling.
[Task 1] Started
[Task 1] Running (iteration 0)
[Task 1] Yielding CPU...
[Task 2] Started
[Task 2] Running (iteration 0)
[Task 2] Yielding CPU...
[Task 1] Resumed
[Task 1] Running (iteration 1)
[Task 1] Yielding CPU...
[Task 2] Resumed
[Task 2] Running (iteration 1)
[Task 2] Yielding CPU...
...

观察要点#

  1. 任务交替执行:Task 1 和 Task 2 交替运行,体现了 Round-Robin 调度
  2. 上下文保存恢复:每个任务被切出后,下次切回时继续执行
  3. 主动让出:通过 schedule_yield() 主动放弃 CPU

踩坑记录#

  1. 问题:任务卡住不切换

    • 原因:时钟中断未正确触发调度
    • 解决:检查 timer_callback 是否正确注册,schedule_request() 是否被调用
  2. 问题:Idle 任务未运行

    • 原因:idle 任务未创建或队列管理错误
    • 解决:检查 idle_task 初始化,确保队列确实为空时才使用 idle
  3. 问题:任务状态不正确

    • 原因:任务状态(RUNNABLE/RUNNING)转换错误
    • 解决:在 schedule()schedule_yield() 中正确设置状态

小结#

本章在上一章的上下文切换基础上,构建了完整的调度器:运行队列用双向链表管理就绪任务,时间片轮转算法给每个任务分配固定时间片实现公平调度,时钟中断驱动抢占式调度让任务切换自动发生,Idle 任务在队列为空时兜底避免 CPU 空转。Round-Robin 虽然简单,但已经具备了实用调度器的基本要素。当多个任务共享资源时,新的问题出现了:如果两个任务互相等待对方持有的资源,就会形成死锁。下一章将引入同步原语(自旋锁、互斥锁)来保护共享资源。

参考#

支持与分享

如果这篇文章对你有帮助,欢迎支持作者或分享给更多人

调度器:运行队列与 Round-Robin
https://blog.souloss.com/posts/os/scheduler-runqueue-round-robin/
作者
Souloss
发布于
2021-07-15
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时