4.2.8 调度器锁定
4.2.8 调度器锁定
📚 本节导读
学习时长: 约 15 分钟
难度级别: ⭐⭐⭐☆☆
前置知识: 任务调度机制、临界区概念
🎯 学习目标
- 理解调度器锁定的作用和适用场景
- 掌握
os_schedule_lock()/os_schedule_unlock()的用法 - 理解调度器锁定与关中断的区别
- 了解调度器锁定期间的注意事项
一、调度器锁定概述
1.1 什么是调度器锁定?
调度器锁定(Schedule Lock)是一种轻量级的临界区保护机制。当调度器被锁定后,内核不会进行任务切换,当前任务可以独占 CPU 直到调度器被解锁。
1.2 调度器锁定的特点
| 特性 | 说明 |
|---|---|
| 可嵌套 | 支持嵌套锁定,使用计数 g_os_sched_lock_cnt 管理 |
| 中断仍可响应 | 锁定调度器不会关闭中断,ISR 仍可正常执行 |
| 不可在锁定期间阻塞 | 锁定期间不能调用会导致任务阻塞的 API |
| 不可在锁定期间延时 | 锁定期间不能调用 os_task_tsleep() / os_task_msleep() |
| SMP 支持 | 每个 CPU 核心独立维护锁定计数 |
1.2.1 为什么不能在锁定期间阻塞/延时?
"不可在锁定期间阻塞/延时"是调度器锁定机制最重要的约束,原因在于:阻塞/延时的本质就是"让出 CPU",而锁定恰恰禁止了让出 CPU。这两者直接矛盾。
阻塞/延时是如何"让出 CPU"的?
- 延时(如
os_task_msleep()):调用后当前任务进入 SLEEP,从就绪队列移除,通过OS_KERNEL_EXIT_SCHED()触发调度切换。 - 阻塞(如获取信号量/互斥锁失败):调用后当前任务进入 BLOCK,等待资源,同样需要触发调度切换到其他任务。
锁定期间发生了什么?
- 调度器被锁定时,
OS_KERNEL_EXIT_SCHED()不会真正切换任务,而是挂起调度请求。 - 但当前任务已经被移出就绪队列(进入 SLEEP/BLOCK)!
于是出现一个致命问题——"没人运行了"(系统死锁):
锁定状态下的错误流程:
① os_schedule_lock() → 调度器锁定,计数 > 0
② os_task_msleep(1000) → 当前任务进入 SLEEP,从就绪队列移除
③ 调度请求被"挂起"(因锁定) → 不切换任务
④ 结果:当前任务已睡眠、调度器又不切换
→ 就绪队列里可能没有可运行的任务
→ CPU 空闲,但没有任何任务被调度 → 系统"假死"锁定到底是如何"禁止让出 CPU"的?(底层实现)
调度器锁定的本质,是在"触发任务切换"的入口处检查锁定计数,若锁定则跳过切换。所有可能触发任务切换的 API(延时、阻塞、唤醒高优先级任务等)最终都会通过 OS_KERNEL_EXIT_SCHED() 来"请求调度"。这个宏(单核版,kernel/source/os_kernel_internal.h)的实现如下:
#define OS_KERNEL_EXIT_SCHED() \
do \
{ \
if ((OS_NULL == g_os_current_task) || (0 != g_os_sched_lock_cnt)) \
{ \
OS_KERNEL_EXIT(); \
} \
else \
{ \
g_os_next_task = g_os_high_task; \
if (g_os_current_task != g_os_next_task) \
{ \
os_task_switch(); \
} \
OS_KERNEL_EXIT(); \
} \
} while (0)核心逻辑:
| 条件 | 结果 |
|---|---|
g_os_sched_lock_cnt != 0(已锁定) | 走 OS_KERNEL_EXIT(),只退出临界区,跳过 os_task_switch() → 任务不切换 |
g_os_sched_lock_cnt == 0(未锁定) | 设置 g_os_next_task,若与当前任务不同则执行 os_task_switch() → 真正切换任务 |
这就解释了"锁定禁止让出 CPU"的真正机制:
- 无论哪个 API 请求调度,最终都汇聚到
OS_KERNEL_EXIT_SCHED()。 - 该宏第一步就检查
g_os_sched_lock_cnt。只要锁定计数非 0,就直接跳过任务切换,即使此刻g_os_high_task(最高优先级任务)已经变了,也不切换。 - 这正是 1.2.1 节错误流程中第 ③ 步"调度请求被挂起"的底层原因——不是没有调度请求,而是调度请求在
OS_KERNEL_EXIT_SCHED()这里被锁定的检查拦截了。
实际代码如何拦截(双重保险)?
除了上述"底层跳过切换",内核还通过断言直接拦截这种非法调用。os_task_tsleep() 等会主动检查调度器是否锁定,若锁定则触发断言失败:
OS_ASSERT(OS_FALSE == os_is_schedule_locked()); /* os_task_tsleep() 内部 */一句话总结:锁定期间不能阻塞/延时,是因为"锁定"禁止了让出 CPU,而"阻塞/延时"要求让出 CPU。二者冲突会导致系统无法继续调度。所以锁定区内的代码必须短小、不阻塞、立即返回,只在执行不需要切换的临界操作时使用。
1.3 锁定计数机制
调度器使用一个计数器来跟踪锁定状态(kernel/source/os_sched.c,与项目一致):
/* 单核模式 */
int16_t g_os_sched_lock_cnt = 0; /* 0 = 未锁定,> 0 = 已锁定 */
/* SMP 模式:每个 CPU 独立计数 */
static int16_t gs_os_sched_lock_cnt[OS_SMP_MAX_CPUS] = {0};计数规则:
- 每次调用
os_schedule_lock(),当前 CPU 的计数器 加 1。 - 每次调用
os_schedule_unlock(),当前 CPU 的计数器 减 1。 - 计数器 > 0 表示已锁定;== 0 表示未锁定;< 0 表示使用错误(lock/unlock 不配对,触发断言)。
- 只有当计数器回到 0 时,调度器才真正解锁并可能触发调度。
关键点:
- 可嵌套:因为用计数而非标志位,支持多次锁定嵌套,每层都必须对应一次解锁。
- SMP 独立:每个 CPU 核心维护自己的计数器,一个 CPU 锁定调度器不影响其他 CPU 的任务调度。
二、API 函数
2.1 os_schedule_lock() —— 锁定调度器
void os_schedule_lock(void);锁定后,调度器不会进行任务切换。即使有更高优先级的任务就绪,当前任务也会继续运行。
完整源码(kernel/source/os_sched.c,与项目一致):
void os_schedule_lock(void)
{
OS_KERNEL_INIT();
OS_KERNEL_ENTER();
#ifdef OS_USING_SMP
gs_os_sched_lock_cnt[os_cpu_id_get()]++;
#else
g_os_sched_lock_cnt++;
#endif
OS_KERNEL_EXIT();
return;
}分步详解:
| 代码 | 作用 |
|---|---|
OS_KERNEL_INIT() | 确保内核已初始化 |
OS_KERNEL_ENTER() | 进入临界区(关中断),保证计数器操作的原子性 |
g_os_sched_lock_cnt++(单核)/ gs_os_sched_lock_cnt[os_cpu_id_get()]++(SMP) | 核心:把当前 CPU 的锁定计数加 1 |
OS_KERNEL_EXIT() | 退出临界区(开中断) |
return | 无返回值 |
关键点:
- 锁定计数用
++加 1,所以多次调用os_schedule_lock()会累计计数,实现嵌套锁定。 - SMP 模式下对当前 CPU 的计数加 1(
os_cpu_id_get()获取当前 CPU 编号),不同 CPU 独立计数。
2.2 os_schedule_unlock() —— 解锁调度器
void os_schedule_unlock(void);解锁调度器。如果锁定计数降为 0,并且有更高优先级的任务就绪,会立即触发任务切换。
完整源码(kernel/source/os_sched.c,与项目一致):
void os_schedule_unlock(void)
{
int16_t sched_lock_cnt;
#ifdef OS_USING_SMP
int32_t current_cpu_index;
#endif
OS_KERNEL_INIT();
OS_KERNEL_ENTER();
#ifdef OS_USING_SMP
current_cpu_index = os_cpu_id_get();
sched_lock_cnt = --gs_os_sched_lock_cnt[current_cpu_index];
#else
sched_lock_cnt = --g_os_sched_lock_cnt;
#endif
if (sched_lock_cnt == 0)
{
#ifdef OS_USING_SMP
OS_BIT_SET(g_os_need_sched_bit, current_cpu_index);
#endif
OS_KERNEL_EXIT_SCHED();
}
else if (sched_lock_cnt > 0)
{
OS_KERNEL_EXIT();
}
else
{
OS_ASSERT_EX(OS_FALSE, "Task use schedule lock correctly");
}
return;
}分步详解:
第一步:准备与计数递减
int16_t sched_lock_cnt;
#ifdef OS_USING_SMP
int32_t current_cpu_index;
#endif
OS_KERNEL_INIT();
OS_KERNEL_ENTER();
#ifdef OS_USING_SMP
current_cpu_index = os_cpu_id_get();
sched_lock_cnt = --gs_os_sched_lock_cnt[current_cpu_index];
#else
sched_lock_cnt = --g_os_sched_lock_cnt;
#endif详细解释:
OS_KERNEL_ENTER():进入临界区,保证计数递减的原子性。--g_os_sched_lock_cnt(单核)/--gs_os_sched_lock_cnt[current_cpu_index](SMP):把当前 CPU 的锁定计数先减 1 再取值,存入sched_lock_cnt。sched_lock_cnt保存递减后的结果,供后续判断。
第二步:根据计数结果分三种情况
if (sched_lock_cnt == 0)
{
#ifdef OS_USING_SMP
OS_BIT_SET(g_os_need_sched_bit, current_cpu_index);
#endif
OS_KERNEL_EXIT_SCHED();
}
else if (sched_lock_cnt > 0)
{
OS_KERNEL_EXIT();
}
else
{
OS_ASSERT_EX(OS_FALSE, "Task use schedule lock correctly");
}详细解释:
| 情况 | 计数 | 处理 | 说明 |
|---|---|---|---|
| ① 计数归零 | == 0 | OS_KERNEL_EXIT_SCHED() | 调度器完全解锁,退出临界区并触发调度(若有更高优先级任务就绪则切换) |
| ② 仍有嵌套 | > 0 | OS_KERNEL_EXIT() | 还有外层锁定,不触发调度,仅退出临界区 |
| ③ 计数为负 | < 0 | OS_ASSERT_EX(...) | 使用错误(lock/unlock 不配对),触发断言 |
关键点:
- 只有计数归零时才触发调度(情况 ①)。因为只有完全解锁,才允许重新选择任务。
- 情况 ③ 的断言
OS_ASSERT_EX(OS_FALSE, "Task use schedule lock correctly")拦截"解锁次数多于锁定次数"的错误。 - SMP 模式下,计数归零时还要置位该 CPU 的调度标志
g_os_need_sched_bit。
2.3 os_is_schedule_locked() —— 检查锁定状态
os_bool_t os_is_schedule_locked(void);| 返回值 | 说明 |
|---|---|
OS_TRUE | 调度器已锁定 |
OS_FALSE | 调度器未锁定 |
完整源码(kernel/source/os_sched.c,与项目一致):
os_bool_t os_is_schedule_locked(void)
{
os_bool_t is_lock;
int16_t sched_lock_cnt;
OS_KERNEL_INIT();
is_lock = OS_FALSE;
OS_KERNEL_ENTER();
#ifdef OS_USING_SMP
sched_lock_cnt = gs_os_sched_lock_cnt[os_cpu_id_get()];
#else
sched_lock_cnt = g_os_sched_lock_cnt;
#endif
if (sched_lock_cnt > 0)
{
is_lock = OS_TRUE;
}
else if (sched_lock_cnt == 0)
{
;
}
else
{
#ifdef OS_USING_SMP
OS_ASSERT_EX(sched_lock_cnt >= 0,
"Task(%s) use schedule lock incorrectly.",
g_os_current_task[os_cpu_id_get()]->name);
#else
OS_ASSERT_EX(sched_lock_cnt >= 0, "Task(%s) use schedule lock incorrectly.", g_os_current_task->name);
#endif
}
OS_KERNEL_EXIT();
return is_lock;
}分步详解:
| 代码 | 作用 |
|---|---|
is_lock = OS_FALSE | 初始默认"未锁定" |
OS_KERNEL_ENTER() | 进入临界区,保证读取计数的原子性 |
sched_lock_cnt = g_os_sched_lock_cnt(单核)/ gs_os_sched_lock_cnt[os_cpu_id_get()](SMP) | 读取当前 CPU 的锁定计数 |
if (sched_lock_cnt > 0) | 计数 > 0 → 已锁定,置 is_lock = OS_TRUE |
else if (sched_lock_cnt == 0) | 计数 = 0 → 未锁定,保持 is_lock = OS_FALSE |
else | 计数 < 0 → 使用错误,触发断言(含当前任务名,便于定位) |
return is_lock | 返回锁定状态 |
关键点:
- 该函数是查询接口,常用于断言判断(如
os_task_tsleep()里检查"调度器未锁定")。 - 与
os_schedule_unlock()一样,计数 < 0(lock/unlock 不配对)会触发断言。
三、使用示例
3.1 基本用法
void critical_operation(void)
{
/* 锁定调度器,确保原子操作 */
os_schedule_lock();
/* 以下代码不会被其他任务打断 */
shared_var1 = new_value1;
shared_var2 = new_value2;
/* 两个变量作为一个整体被更新 */
os_schedule_unlock();
}3.2 嵌套锁定
void outer_function(void)
{
os_schedule_lock(); /* 锁定计数 = 1 */
/* 临界区代码 A */
inner_function();
/* 临界区代码 B */
os_schedule_unlock(); /* 锁定计数 = 0,允许调度 */
}
void inner_function(void)
{
os_schedule_lock(); /* 锁定计数 = 2 */
/* 嵌套临界区代码 */
os_schedule_unlock(); /* 锁定计数 = 1 */
}3.3 保护共享资源
static int g_counter = 0;
void increment_counter(void)
{
os_schedule_lock(); /* 防止任务并发访问 */
int temp = g_counter;
temp++;
g_counter = temp; /* 原子操作 */
os_schedule_unlock();
}注意:上述示例假设只有任务会访问
g_counter。如果 ISR 也会访问,则需要使用关中断或自旋锁来保护。
四、调度器锁定 vs 关中断
| 维度 | 调度器锁定 | 关中断 |
|---|---|---|
| 保护范围 | 仅防止任务切换 | 防止所有中断和任务切换 |
| 中断响应 | ✅ 中断仍可响应 | ❌ 中断被屏蔽 |
| 实时性影响 | 较小(仅影响任务调度) | 较大(影响中断延迟) |
| 可嵌套 | ✅ 支持 | ✅ 支持 |
| 适用场景 | 任务间临界区保护 | 任务与 ISR 共享数据的保护 |
| 性能开销 | 较低 | 较低 |
选择指南
是否需要保护数据免受 ISR 访问?
│
├── 是 → 使用关中断(os_irq_lock / os_irq_unlock)
│
└── 否 → 仅需防止任务间竞争?
│
├── 是 → 使用调度器锁定
│
└── 否 → 使用互斥锁 / 信号量等 IPC 机制五、注意事项
5.1 不要在锁定期间阻塞
调度器锁定期间,绝对不能调用任何可能导致任务阻塞的 API,包括但不限于:
os_task_tsleep()/os_task_msleep()—— 会导致断言失败os_sem_wait()—— 会阻塞当前任务,但调度器已锁定无法切换os_mutex_lock()—— 同上- 任何带有
OS_WAIT_FOREVER超时的 IPC 操作
/* 错误示例:锁定期间延时 */
os_schedule_lock();
os_task_msleep(100); /* ❌ 断言失败!调度器已锁定 */
os_schedule_unlock();5.2 锁定时间尽量短
调度器锁定期间,更高优先级的任务无法运行,这会影响系统的实时性。因此锁定时间应尽可能短:
/* 推荐:锁定时间短 */
os_schedule_lock();
g_flags |= FLAG_READY; /* 简单的标志位操作 */
os_schedule_unlock();
/* 不推荐:锁定时间长 */
os_schedule_lock();
compute_heavy(); /* 复杂计算,耗时较长 */
send_data(); /* 发送大量数据 */
os_schedule_unlock();5.3 确保配对使用
每次 os_schedule_lock() 必须有对应的 os_schedule_unlock()。不配对会导致调度器永久锁定或锁定计数异常。
/* 推荐:使用 RAII 风格(如果支持)或在同一函数中配对 */
void safe_function(void)
{
os_schedule_lock();
/* 临界区代码 */
os_schedule_unlock();
}
/* 不推荐:在不同分支中锁定但未解锁 */
void unsafe_function(void)
{
os_schedule_lock();
if (error_condition)
{
return; /* ❌ 忘记解锁! */
}
os_schedule_unlock();
}5.4 与互斥锁的区别
| 维度 | 调度器锁定 | 互斥锁 |
|---|---|---|
| 粒度 | 全局(所有任务) | 指定资源 |
| 保护对象 | 任务间临界区 | 特定共享资源 |
| 优先级继承 | 不支持 | 支持 |
| 阻塞行为 | 不可阻塞 | 获取失败时阻塞 |
| 死锁风险 | 低(不可阻塞) | 存在 |
对于共享资源的保护,优先使用互斥锁;调度器锁定适用于需要确保多个操作原子性、且操作时间极短的场景。
📝 本节小结
核心要点回顾
本节围绕调度器锁定机制展开,核心要点如下:
1. 调度器锁定是什么
- 一种轻量级临界区保护机制:锁定后不会进行任务切换,当前任务独占 CPU 直到解锁。
- 锁定期间中断仍可响应(不会关中断)。
2. 锁定计数机制
- 用计数器
g_os_sched_lock_cnt(单核)跟踪锁定状态:>0 锁定、==0 未锁定、<0 使用错误。 - 支持嵌套锁定:lock 加 1、unlock 减 1,计数归零才真正解锁并触发调度。
- SMP 下每个 CPU 独立计数。
3. 三个 API
| API | 作用 |
|---|---|
os_schedule_lock() | 锁定调度器(计数 +1) |
os_schedule_unlock() | 解锁调度器(计数 -1,归零触发调度) |
os_is_schedule_locked() | 查询是否锁定 |
4. 与关中断的区别
- 调度器锁定:仅防任务切换,中断仍响应,对实时性影响小。
- 关中断:防所有中断和任务切换,影响中断延迟。
- 调度器锁定无法保护任务与 ISR 共享的数据。
5. 使用原则
- 锁定时间尽量短。
- lock/unlock 必须配对。
- 禁止在锁定期间调用可能阻塞/延时的 API。
关键易错点速查
| 易错点 | 正确做法 |
|---|---|
锁定期间调用 os_task_msleep() 等 | 触发断言失败,禁止在锁定期间阻塞/延时 |
| lock/unlock 不配对(如提前 return) | 计数不为 0 或变负,导致永久锁定或断言;必须保证配对 |
| 用调度器锁定保护与 ISR 共享的数据 | 无法阻止 ISR,应改用关中断或自旋锁 |
| 长时间锁定 | 高优先级任务无法运行,影响实时性;锁定时间应尽量短 |
| 混淆嵌套计数 | 每层 lock 都要对应一层 unlock,计数归零才真正解锁 |
下一步
任务管理相关机制已基本介绍完毕。接下来请学习下一小节:
- 4.2.9 任务钩子:了解如何在任务创建、切换等事件发生时插入自定义处理