3. 抢占阈值调度:给任务加一道“软屏障”

各位做嵌入式开发的同行,咱们都遇到过这种情况:一个低优先级任务好不容易抢到CPU,刚处理到关键数据,啪,被一个高优先级任务打断了。等它再回来,上下文已经切换了一轮,缓存也凉了。这种频繁的“无效打断”,说白了就是性能的隐形杀手。

ThreadX 提供了一个很巧妙的机制——抢占阈值调度。它允许你设置一个“阈值”,让任务只被优先级高于该阈值的任务打断,而不是被所有更高优先级的任务打断。我个人习惯叫它“软屏障”,因为它不像关中断那么粗暴,又能有效减少不必要的上下文切换。

核心思想: 抢占阈值 = 任务能容忍的最高打断优先级。低于此阈值的任务,即使优先级更高,也得排队等着。

3.1 为什么需要抢占阈值?

先看一个典型场景。系统里有三个任务:Task_A(优先级5)、Task_B(优先级10)、Task_C(优先级15)。数字越小优先级越高。

  • Task_A 正在执行一段临界区代码,操作共享数据。
  • Task_B 就绪了,优先级比 Task_A 高,立刻抢占了 Task_A。
  • Task_B 执行了没几个指令,Task_C 就绪了,又抢占了 Task_B。

你想想看,Task_A 的临界区代码其实很短,如果能让它一口气跑完,根本不会有问题。但因为没有抢占阈值,Task_B 和 Task_C 的“连环抢”导致上下文切换了两次,缓存也刷了两次。我在一个电机控制项目里遇到过类似问题,当时任务切换开销占了 CPU 时间的 15% 以上,排查了很久才发现是“无效抢占”太多。

调度方式 上下文切换次数 临界区完成时间 CPU 有效利用率
完全抢占 3 次(A→B→C→A) 较长(被中断两次) 低
抢占阈值 1 次(A→C) 短(一次跑完) 高

3.2 抢占阈值的工作原理

ThreadX 的每个任务都有一个 tx_preemption_threshold 属性。它的取值逻辑是这样的:

  • 默认值: 等于任务自身的优先级。此时任务可以被所有更高优先级的任务抢占,行为等同于普通抢占式调度。
  • 设置值: 一个比自身优先级更高的数值(即更低的优先级)。此时只有优先级高于该阈值的任务才能抢占当前任务。

举个例子。Task_A 优先级为 5,设置抢占阈值为 10。那么:

  • 优先级 1~4 的任务:可以抢占 Task_A(优先级高于阈值)。
  • 优先级 6~10 的任务:不能抢占 Task_A(优先级低于阈值,但高于 Task_A 自身优先级)。
  • 优先级 11 及以下的任务:本来就不能抢占 Task_A(优先级低于 Task_A)。
我的经验: 抢占阈值不是越大越好。设得太高,高优先级任务的实时性会受影响。我一般从任务自身优先级往上试探,每次加 2~3 个优先级,直到临界区不再被频繁打断为止。

3.3 API 使用与代码示例

ThreadX 提供了专门的 API 来设置抢占阈值:

UINT txe_preemption_threshold_set(
    TX_THREAD *thread_ptr,
    UINT preemption_threshold
);

注意函数名里的 txe_ 前缀,它带参数检查,适合开发阶段使用。正式发布时可以用 tx_preemption_threshold_set 去掉检查开销。

下面是一个实际的使用示例:

/* 定义任务控制块和栈 */
TX_THREAD my_task;
uint8_t my_stack[1024];

/* 任务入口函数 */
void my_task_entry(ULONG initial_input)
{
    UINT status;

    /* 设置抢占阈值为 8,自身优先级为 5 */
    status = txe_preemption_threshold_set(
        &my_task,
        8   /* 抢占阈值:只有优先级高于 8 的任务才能抢占我 */
    );

    if (status != TX_SUCCESS)
    {
        /* 处理错误,一般不会失败 */
        return;
    }

    while(1)
    {
        /* 临界区操作:操作共享数据 */
        /* 此时不会被优先级 6、7 的任务打断 */
        process_shared_data();

        /* 退出临界区后,恢复默认抢占行为 */
        tx_preemption_threshold_set(&my_task, 5);

        /* 执行其他非关键代码 */
        do_other_work();

        tx_thread_sleep(10);
    }
}
注意: 抢占阈值设置后,不会在任务被抢占时自动恢复。如果你在临界区外也保持高阈值,会导致其他任务得不到及时响应。我建议在临界区入口设置阈值,出口立即恢复。

3.4 抢占阈值 vs 互斥量 vs 关中断

很多初学者会问:有了互斥量,为什么还要抢占阈值?这三者其实解决不同层面的问题。

机制 保护范围 开销 适用场景
关中断 所有任务 + 中断 高(影响中断响应) 极短临界区(几个指令)
互斥量 特定共享资源 中(可能引起优先级反转) 保护数据结构、外设
抢占阈值 当前任务执行流 低(只影响调度决策) 减少上下文切换、保护任务级临界区

我在一个数据采集项目里,把这三者组合使用:关中断保护寄存器操作(几个微秒),互斥量保护环形缓冲区,抢占阈值保护数据打包任务不被频繁打断。效果很好,上下文切换减少了 40%。

3.5 抢占阈值调度流程图

下面这张图展示了抢占阈值调度的完整决策流程。你可以看到,调度器在决定是否抢占时,多了一层阈值判断。

抢占阈值调度决策流程 抢占阈值调度决策流程 任务就绪 当前是否有任务正在运行? 是 新任务优先级 > 当前任务? 否 不抢占,继续运行当前任务 是 检查当前任务的抢占阈值 新任务优先级 > 抢占阈值? (阈值 = 当前任务允许的最高打断优先级) 是 允许抢占,切换任务 否 不抢占,阈值保护生效 关键点 • 抢占阈值只影响任务级抢占 • 中断不受阈值限制 • 阈值 = 自身优先级时,退化为 普通抢占式调度

3.6 避坑指南与最佳实践

我在多个项目里用过抢占阈值,踩过一些坑,分享给你:

我曾经踩过的坑: 有一次我把一个任务的抢占阈值设得和自身优先级一样高(等于没设),但代码里忘了在临界区外恢复。结果一个中等优先级的任务被“饿死”了,排查了两天才发现是阈值没恢复。从那以后,我养成了一个习惯:临界区入口设阈值,出口立即恢复,成对出现。

下面是我总结的几个最佳实践:

  1. 阈值不要设得太高: 一般比自身优先级高 2~3 级即可。设得太高会影响高优先级任务的实时性。
  2. 临界区要短: 抢占阈值不是让你写长临界区的借口。临界区越长,对系统实时性的影响越大。我一般控制在 100 个指令以内。
  3. 配合优先级继承使用: 如果临界区里操作了互斥量,建议同时开启优先级继承,防止优先级反转。
  4. 调试时用 tx_thread_info_get: 这个 API 可以查看任务的当前抢占阈值,方便调试时确认设置是否生效。
我的调试小技巧: 在临界区入口和出口各放一个 GPIO 翻转,用示波器看波形。如果临界区执行期间有额外的“毛刺”,说明有任务突破了阈值,需要检查阈值设置是否正确。

嗯,抢占阈值调度就讲到这里。它不是什么高深的技术,但用好了能显著减少上下文切换次数,提升 CPU 的有效利用率。下次你在项目里遇到“任务频繁被打断”的问题,不妨试试这个“软屏障”。