第三章 任务调度算法:优先级抢占式调度、时间片轮转调度、协作式调度、调度点与临界区
调度算法,说白了就是操作系统决定「下一个该谁跑」的规则。我做了这么多年嵌入式,见过不少系统因为调度策略选错而翻车。今天咱们就把这几种主流调度算法掰开揉碎了讲清楚。
3.1 优先级抢占式调度
这是RTOS最常用的调度方式。每个任务都有一个优先级,数字越小优先级越高(或者反过来,各家RTOS定义不同)。高优先级的任务就绪后,会立刻抢占正在运行的低优先级任务。
我在项目中遇到过一个问题:一个数据采集任务优先级设得太高,结果它一就绪就把显示任务给抢了,屏幕刷新卡得像幻灯片。嗯,这里要注意,优先级不是越高越好。
核心机制:
- 每个任务有唯一优先级(或同一优先级多个任务)
- 就绪队列按优先级排序
- 调度发生在任何可能改变任务状态的点
- 高优先级任务就绪 → 立即切换
举个例子,FreeRTOS的调度核心代码大概长这样:
/* 简化版优先级抢占调度逻辑 */
void vTaskSwitchContext(void) {
UBaseType_t uxTopPriority = uxTopReadyPriority;
/* 从最高优先级开始找就绪任务 */
while (listLIST_IS_EMPTY(&(pxReadyTasksLists[uxTopPriority]))) {
if (uxTopPriority == 0) break;
uxTopPriority--;
}
/* 选中该优先级队列的第一个任务 */
pxCurrentTCB = listGET_OWNER_OF_HEAD_ENTRY(
&(pxReadyTasksLists[uxTopPriority])
);
}
你想想看,这个算法的时间复杂度是O(1)的,因为优先级数量固定,直接查表就行。我建议你在设计系统时,把关键任务(比如刹车控制)放在最高优先级,把非实时任务(比如日志打印)放在最低优先级。
避坑指南:我曾经在一个项目中把所有任务都设成了不同优先级,结果出现了优先级反转——低优先级任务持有高优先级任务需要的资源,导致高优先级任务被阻塞。后来加了优先级继承协议才解决。
3.2 时间片轮转调度
同优先级的任务怎么调度?时间片轮转就是答案。每个任务运行一个固定时间片(比如10ms),时间到了就切换到下一个同优先级任务。
说白了,这就是给每个任务发一张「时间票」,用完就得排队等下一轮。我习惯把时间片设成系统tick的整数倍,比如5个tick,这样调度时机好控制。
| 参数 | 典型值 | 说明 |
|---|---|---|
| 时间片长度 | 1-100 ms | 太短则切换开销大,太长则响应慢 |
| 同优先级任务数 | 2-8个 | 太多会导致每个任务分到的时间太少 |
| 切换时机 | tick中断 | 在SysTick或定时器中断中触发 |
我记得有一次调试一个网络协议栈,三个任务同优先级,时间片设成1ms。结果切换太频繁,光上下文切换就占了30%的CPU。后来改成10ms,问题就解决了。
小技巧:时间片轮转适合CPU密集型任务,但不适合对响应时间有严格要求的任务。如果某个任务需要快速响应,给它单独提优先级。
3.3 协作式调度
协作式调度,也叫非抢占式调度。任务自己决定什么时候让出CPU。说白了就是「你不主动让,别人就别想跑」。
这种调度方式在早期的嵌入式系统中很常见。我刚开始做单片机开发时用的就是这种——任务里主动调用task_yield()或者等待事件。
/* 协作式调度示例 */
void task_A(void) {
while(1) {
/* 做点事情 */
process_sensor_data();
/* 主动让出CPU */
task_yield();
}
}
void task_B(void) {
while(1) {
/* 做点事情 */
update_display();
/* 主动让出CPU */
task_yield();
}
}
你想想看,这种调度方式有什么好处?没有抢占,所以不需要复杂的临界区保护。坏处也很明显——如果一个任务死循环或者忘记让出CPU,整个系统就卡死了。
注意:我曾经在一个低功耗项目里用协作式调度,因为任务切换只在主动让出时发生,所以可以精确控制功耗。但后来加了一个第三方库,里面有个任务忘了yield,整个系统就挂了。从那以后,我建议除非系统极其简单,否则还是用抢占式调度。
3.4 调度点与临界区
调度点,就是系统检查是否需要切换任务的地方。常见的调度点包括:
- 任务创建/删除时
- 任务阻塞/唤醒时
- 时间片用完时(tick中断)
- 中断退出时
- 调用特定API时(如task_yield)
临界区,就是不能被打断的代码段。在RTOS里,临界区通常用来保护共享数据。我习惯用关中断的方式实现临界区:
/* 进入临界区 */
taskENTER_CRITICAL();
/* 操作共享数据 */
shared_counter++;
buffer[index] = data;
/* 退出临界区 */
taskEXIT_CRITICAL();
嗯,这里要注意,临界区里不能做耗时操作。我见过有人把整个网络协议栈处理放在临界区里,结果中断响应延迟了几十毫秒,系统直接崩了。
调度点与临界区的关系:
- 临界区内禁止调度(因为关了中断)
- 退出临界区时,会检查是否有更高优先级任务就绪
- 如果有关中断的嵌套,要等所有临界区都退出后才允许调度
说白了,调度点和临界区就是一对冤家。调度点想让系统随时切换,临界区却要锁住不让切。好的RTOS设计,就是在保证数据一致性的前提下,尽量缩短临界区长度,让调度点能及时触发。
我记得有一次优化一个电机控制程序,把临界区从50行缩减到3行,系统响应时间从2ms降到了0.1ms。所以说,临界区越短越好,最好就保护那一两行代码。
个人经验:我建议你在设计临界区时,遵循「最小化原则」——只保护真正需要保护的共享数据,不要顺手把无关代码也包进去。另外,能用信号量或互斥量的地方,尽量别用关中断,因为关中断会影响整个系统的实时性。
最后总结一下这几种调度算法的适用场景:
| 调度算法 | 适用场景 | 不适用场景 |
|---|---|---|
| 优先级抢占式 | 实时性要求高的系统 | 优先级反转严重的系统 |
| 时间片轮转 | 同优先级任务公平调度 | 对响应时间有严格要求的任务 |
| 协作式 | 简单系统、低功耗系统 | 复杂系统、第三方库多的系统 |
你想想看,实际项目中往往是混合使用——高优先级任务用抢占式,同优先级任务用时间片轮转,再加上协作式作为补充。没有银弹,只有最适合你场景的方案。