3. 任务调度算法:优先级抢占式调度、时间片轮转调度、协作式调度、调度点分析
调度算法,说白了就是操作系统决定「下一个该谁跑」的规则。我做了十几年嵌入式,见过太多因为调度选型翻车的项目。有的系统跑着跑着就卡死,有的任务优先级设得乱七八糟,还有的明明CPU负载不高,响应就是慢半拍。嗯,这些坑我基本都踩过。
今天咱们就把几种主流调度算法掰开揉碎讲清楚。你想想看,一个RTOS的核心竞争力,其实就体现在调度器上。
3.1 优先级抢占式调度
这是RTOS里最常用的调度方式。它的核心逻辑很简单:谁优先级高,谁先跑。而且高优先级任务一旦就绪,可以立刻打断正在运行的低优先级任务。
我个人习惯把这种调度比作「急诊室机制」。普通门诊按号排队,但急诊病人来了,不管前面排了多少人,都得先处理。抢占式调度就是这个道理。
核心规则:
- 每个任务分配一个优先级数值(通常数值越小优先级越高)
- 调度器永远选择就绪态中优先级最高的任务运行
- 高优先级任务就绪时,立即抢占当前运行的低优先级任务
- 被抢占的任务进入就绪态,等待下次被调度
我在项目中遇到过一个问题:一个数据采集任务优先级设得特别高,结果它一启动就把所有低优先级任务都饿死了。后来我加了个「同优先级时间片轮转」,才把问题解决。这里要记住:优先级不是越高越好,够用就行。
// 伪代码:优先级抢占式调度核心逻辑
void scheduler(void) {
task_t *current = get_current_task();
task_t *highest = find_highest_ready_task();
if (highest->priority < current->priority) {
// 高优先级任务就绪,执行抢占
save_context(current);
restore_context(highest);
}
}
避坑指南:我曾经在一个项目中把所有任务优先级都设成0(最高优先级),结果系统表现跟裸机轮询差不多。优先级要有层次感,一般建议保留3-5个有效优先级层级就够了。
3.2 时间片轮转调度
时间片轮转,说白了就是「一人跑一会儿」。每个任务分配一个固定时间片,时间到了就切换到下一个同优先级的任务。它解决的是同优先级任务之间的公平性问题。
你想想看,如果没有时间片轮转,两个同优先级的任务,一个跑死循环,另一个永远没机会执行。这显然不合理。
| 参数 | 典型值 | 说明 |
|---|---|---|
| 时间片长度 | 1-10ms | 取决于系统滴答时钟精度 |
| 切换开销 | 几微秒到几十微秒 | 上下文保存+恢复的时间 |
| 适用场景 | 同优先级任务较多 | 需要公平分配CPU时间 |
我记得有一次做工业控制器,有4个同优先级的通信任务。一开始没开时间片轮转,结果某个任务一直占用CPU,其他三个任务的数据包全丢了。开了时间片轮转后,每个任务都能按时收发数据,问题迎刃而解。
小技巧:时间片不宜设得太短。太短会导致频繁切换,上下文开销占比过高。我一般建议时间片长度是任务典型执行时间的3-5倍。
3.3 协作式调度
协作式调度,也叫非抢占式调度。它的特点是:任务主动让出CPU,调度器才能切换。任务不主动调用yield或阻塞,就一直跑下去。
这种调度方式在小型MCU上很常见,比如早期的uC/OS就有协作模式。它的优点是:没有抢占,就没有竞态问题。你不需要考虑锁、信号量这些同步机制,因为任务切换只发生在你主动让出的时刻。
但缺点也很明显:一个任务死循环,整个系统就挂了。我在一个智能家居项目里用过协作式调度,结果某个传感器的while循环忘了加延时,整个系统卡死。从那以后,我对协作式调度就格外谨慎。
// 协作式调度:任务主动让出CPU
void task_a(void) {
while (1) {
do_something();
task_yield(); // 主动让出CPU
}
}
void task_b(void) {
while (1) {
do_other_thing();
task_yield(); // 主动让出CPU
}
}
避坑指南:我曾经在一个协作式调度系统里,某个任务调用了阻塞式API(比如等待消息队列),结果它阻塞了,但调度器不知道要切换,整个系统就卡在那里。记住:协作式调度下,所有阻塞操作都必须是非阻塞的轮询版本。
3.4 调度点分析
调度点,就是调度器被触发执行任务切换的时刻。理解调度点,是理解RTOS行为的关键。我见过不少开发者,代码写得挺好,但就是搞不清任务什么时候会被切换,结果调试起来一头雾水。
常见的调度点有以下几个:
- 时间中断:系统滴答时钟触发,检查是否有更高优先级任务就绪
- 任务主动阻塞:任务调用delay、等待信号量、等待消息队列等
- 任务主动让出:调用yield或sleep(0)
- 中断退出时:中断服务程序结束后,检查是否需要重新调度
- 创建/删除任务时:任务状态变化,可能触发调度
关键理解:调度点决定了系统的实时性上限。如果调度点只发生在时间中断,那么任务切换的最大延迟就是一个时间片。如果调度点还包含中断退出,那么高优先级任务的响应时间可以缩短到微秒级。
我个人习惯把调度点分为两类:被动调度点和主动调度点。被动调度点由外部事件触发(如中断),主动调度点由任务自身行为触发(如阻塞)。理解这个分类,能帮你更好地设计系统的实时响应策略。
举个例子:一个紧急报警任务,优先级最高。如果调度点只包含时间中断,那么报警信号来了,最快也要等到下一个时间片才能响应。但如果调度点包含中断退出,报警中断一结束,调度器立刻切换,响应时间可以做到微秒级。
// 调度点示例:中断退出时触发调度
void SysTick_Handler(void) {
// 系统滴答中断
tick_increment();
// 调度点:检查是否需要切换任务
if (need_reschedule()) {
scheduler();
}
}
void EXTI_Handler(void) {
// 外部中断,比如按键
set_event_flag();
// 调度点:中断退出前检查
if (need_reschedule()) {
scheduler();
}
}
小技巧:调试调度问题时,我习惯在每个调度点加一个GPIO翻转。用示波器一看,就知道任务切换发生在哪里,频率是多少。这个方法比打印日志高效得多。
最后总结一下:优先级抢占式调度保证实时性,时间片轮转保证公平性,协作式调度保证简单性。实际项目中,往往是混合使用。比如高优先级任务用抢占式,同优先级任务用时间片轮转。调度点的设计,则决定了系统的响应极限。
嗯,调度算法这块内容比较多,但核心就这些。下一章咱们聊聊任务间通信和同步,那又是另一片天地了。