mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4
2345 字
6 分钟
调度器:运行队列与 Round-Robin
2024-04-10

上一章实现了基本的上下文切换,但还没有公平的调度策略。如果没有调度算法,某些任务可能永远得不到执行机会,这叫饿死(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

调度问题的本质是什么?历史上提出了几种解决方案。

调度算法分类#

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

  • 桌面系统:需要快速响应用户输入,优先级调度
  • 服务器:需要高吞吐量,公平调度
  • 实时系统:需要确定性的响应时间,实时调度

调度策略分为两大类:

类别特点算法
非抢占式(协作式)任务主动让出 CPUFCFS(先来先服务)
抢占式(时间片中断)由时钟中断强制切换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 记录队列中的任务数量,便于快速判断队列是否为空。

运行队列是一个链表结构: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(在 50 Hz 时钟下约 400ms)是一个适中的选择。

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

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

抢占式调度#

**抢占式调度(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 MIN_TIMESLICE_TICKS 10 /* 最小时间片 */
#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() 完成实际的上下文切换。

Warning

在中断处理函数中直接调用 schedule() 有一个时序要求:必须在调度前先向 PIC 发送 EOI,否则当前 IRQ 的 EOI 要等到被切出的任务恢复后才发送,期间定时器中断被 PIC 屏蔽,整个系统可能不再响应时钟中断。本章示例为突出调度逻辑省略了 EOI 调用,实际代码中 schedule() 之前应加上 pic_send_eoi(IRQ0)

所有就绪任务都在运行队列中,但如果队列为空呢?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

yieldlock.h

task

scheduler.c

runqueue.c

task.c

thread.c

sync

yieldlock.c

interrupt

timer.c

arch

x86

context_switch.S

kernel.c

Makefile

任务状态用枚举 task_state_t 表示,涵盖任务生命周期的各个阶段:

typedef enum {
TASK_RUNNING, /* 运行中 */
TASK_RUNNABLE, /* 可运行/调度 */
TASK_BLOCKED, /* 阻塞中 */
TASK_SLEEPING, /* 睡眠中 */
TASK_ZOMBIE, /* 僵尸态(已完成,但仍占用资源) */
} task_state_t;

任务结构定义:

typedef struct task {
/* identity */
tid_t tid; /* 任务ID */
pid_t tgid; /* 线程组ID(pid) */
char name[16]; /* 任务名称 */
struct process *owner; /* 所属进程(可为NULL表示内核线程) */
/* scheduling */
task_state_t state; /* 任务状态 */
int priority; /* 优先级 */
int timeslice; /* 时间片 */
/* kernel stack */
void *kstack_base; /* 内核栈基址 */
void *kstack_top; /* 内核栈顶 */
uint32_t kernel_esp; /* 当前内核栈指针 */
/* saved context for context_switch */
cpu_context_t ctx; /* CPU上下文 */
/* runqueue node */
struct list_node rq_node; /* 运行队列链表节点 */
/* bookkeeping */
int exit_code; /* 退出码 */
int refcount; /* 引用计数 */
} 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 (50 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() 中正确设置状态

当多个任务共享资源时,如果两个任务互相等待对方持有的资源,就会形成死锁。下一章将引入同步原语(自旋锁、互斥锁)来保护共享资源。

参考#

支持与分享

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

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

部分信息可能已经过时