上一章实现了基本的上下文切换,但还没有公平的调度策略。如果没有调度算法,某些任务可能永远得不到执行机会,这叫饿死(starvation)。调度器要解决的就是”谁先跑、跑多久、何时换”这三个问题。
调度问题
在单核处理器上,同一时间只能运行一个任务。当多个任务都想运行时,操作系统必须决定执行顺序。最朴素的想法是先来先服务(FCFS):谁先创建谁先跑,跑完了再换下一个。但 FCFS 有明显的问题,如果一个任务执行时间很长,后面所有任务都要等,响应性很差。短任务可能被长任务阻塞很久,这对交互式场景(比如键盘输入响应)是不可接受的。
理解了调度问题的本质,接下来看历史上提出的几种解决方案。
调度算法分类
不同的应用场景需要不同的调度策略:
- 桌面系统:需要快速响应用户输入,优先级调度
- 服务器:需要高吞吐量,公平调度
- 实时系统:需要确定性的响应时间,实时调度
┌─────────────────────────────────────┐│ 调度策略 │├──────────────┬──────────────────────┤│ 非抢占式 │ 抢占式 ││ (协作式) │ (时间片中断) │├──────────────┴──────────────────────┤│ 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)是一个适中的选择。
时间片到期后,谁来触发切换?答案是时钟中断,这就是抢占式调度。
抢占式调度
**抢占式调度(Preemptive Scheduling)**是指操作系统能够强制剥夺当前运行任务的 CPU 使用权,切换到其他任务。这与协作式调度(Cooperative Scheduling)相对,后者要求任务主动让出 CPU。
时钟中断是抢占式调度的驱动力。每当时钟中断到来,中断处理函数检查当前任务的时间片是否用完;如果用完,就触发调度,强制切换到下一个任务。
时钟中断回调的核心代码:
#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 寄存器上下文用于上下文切换。
调度器整体流程:
调度器初始化
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-schedmake cleanmake allmake 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......观察要点
- 任务交替执行:Task 1 和 Task 2 交替运行,体现了 Round-Robin 调度
- 上下文保存恢复:每个任务被切出后,下次切回时继续执行
- 主动让出:通过
schedule_yield()主动放弃 CPU
踩坑记录
-
问题:任务卡住不切换
- 原因:时钟中断未正确触发调度
- 解决:检查
timer_callback是否正确注册,schedule_request()是否被调用
-
问题:Idle 任务未运行
- 原因:idle 任务未创建或队列管理错误
- 解决:检查
idle_task初始化,确保队列确实为空时才使用 idle
-
问题:任务状态不正确
- 原因:任务状态(RUNNABLE/RUNNING)转换错误
- 解决:在
schedule()和schedule_yield()中正确设置状态
小结
本章在上一章的上下文切换基础上,构建了完整的调度器:运行队列用双向链表管理就绪任务,时间片轮转算法给每个任务分配固定时间片实现公平调度,时钟中断驱动抢占式调度让任务切换自动发生,Idle 任务在队列为空时兜底避免 CPU 空转。Round-Robin 虽然简单,但已经具备了实用调度器的基本要素。当多个任务共享资源时,新的问题出现了:如果两个任务互相等待对方持有的资源,就会形成死锁。下一章将引入同步原语(自旋锁、互斥锁)来保护共享资源。
参考
支持与分享
如果这篇文章对你有帮助,欢迎支持作者或分享给更多人
部分信息可能已经过时






