4. ThreadX抢占式调度机制:抢占阈值、优先级继承与调度器内部逻辑

抢占式调度,说白了就是高优先级任务能随时打断低优先级任务的执行。这在RTOS里是基本功,但ThreadX在这基础上加了几个很实在的“硬菜”——抢占阈值和优先级继承协议。这两个东西,我当年做工业控制器时帮了大忙。

4.1 抢占阈值(Preemption-Threshold)原理

先说说抢占阈值。标准抢占式调度里,只要优先级更高的任务就绪,当前任务就得让出CPU。但有些场景下,你并不希望所有高优先级任务都能抢进来——比如某个临界区代码,你只允许比当前任务高两个优先级的任务打断,其他更高的反而要等一等。

抢占阈值就是干这个的。每个任务除了有一个基本优先级,还可以设置一个抢占阈值优先级。只有优先级高于这个阈值的任务才能抢占当前任务。

核心规则:任务A的抢占阈值 = T,则只有优先级 > T 的任务才能抢占A。优先级在A和T之间的任务,即使就绪了也得排队。

举个例子,任务A优先级10,抢占阈值设为8。那么优先级9的任务就绪了,它抢不了A;但优先级7的任务可以。这就像给任务加了个“护城河”,只让真正紧急的任务进来。

我在一个多传感器数据采集项目里用过这个特性。当时有3个传感器任务,优先级分别是5、6、7,还有一个显示任务优先级3。正常情况下,传感器任务会频繁打断显示任务,导致屏幕闪烁。我把显示任务的抢占阈值设为4,这样只有优先级高于4的任务(即优先级3的显示任务自己?不对,这里要理清)——嗯,实际上我把显示任务的抢占阈值设得比它自己优先级高,让传感器任务无法打断它,只在显示任务主动挂起时才切换。效果立竿见影。

场景 标准抢占 抢占阈值
高优先级任务就绪 立即抢占 需高于阈值才抢占
临界区保护 关中断或锁调度器 阈值控制,粒度更细
优先级反转风险 高 低(配合协议使用)
上下文切换次数 多 少,更高效

我的习惯:在配置抢占阈值时,我会先画出任务优先级图谱,标出哪些任务之间有共享资源,然后针对临界区所在的任务设置阈值。阈值一般比任务自身优先级高2-3级,留出余量。

4.2 优先级继承协议

优先级反转是个老生常谈的问题。低优先级任务拿着锁,中优先级任务抢占了CPU,高优先级任务等锁——结果高优先级任务反而被中优先级任务“踩在脚下”。

ThreadX的解决方案是优先级继承。当高优先级任务因为锁被低优先级任务持有而阻塞时,低优先级任务会临时“继承”高优先级任务的优先级,直到释放锁。

我曾经在一个机器人关节控制项目里踩过这个坑。三个任务:控制任务(优先级5)、通信任务(优先级3)、日志任务(优先级1)。控制任务和日志任务共享一个I2C总线锁。正常情况下没问题,但有一次日志任务拿着锁时,通信任务(优先级3)开始大量收发数据,占用了CPU。控制任务(优先级5)等锁等不到,关节控制指令延迟了200ms,机器人直接抖了一下。

后来我开了优先级继承,日志任务在持有锁期间临时提升到优先级5,通信任务抢不过它,锁很快释放,控制任务顺利拿到锁。问题解决。

注意:优先级继承不是万能的。它只能缓解优先级反转,不能完全消除。如果多个高优先级任务竞争同一个锁,继承链会变得复杂。我建议在锁的持有时间很短(微秒级)的场景下使用,长时间持锁的场景考虑用互斥信号量+优先级天花板协议。

4.3 调度器内部实现逻辑

ThreadX的调度器核心是一个优先级位图结构。每个优先级对应一个bit,就绪的任务在对应bit上置1。调度时,调度器用一条指令找到最高优先级的就绪任务——这比遍历链表快得多。

内部逻辑大致分三步:

  1. 就绪队列更新:任务状态变化时(创建、挂起、恢复、释放锁等),更新优先级位图和对应的就绪链表。
  2. 抢占检查:每次系统服务调用(如tx_thread_sleep、tx_semaphore_get)返回前,检查是否有更高优先级任务就绪。如果有,触发上下文切换。
  3. 上下文切换:保存当前任务的寄存器、栈指针到TCB,恢复新任务的寄存器、栈指针,然后执行新任务。

抢占阈值在第二步介入。调度器在检查抢占时,不光看优先级,还要看当前任务的抢占阈值。只有新任务的优先级高于阈值,才允许切换。

/* 伪代码:ThreadX调度器抢占检查逻辑 */
void _tx_thread_schedule(void) {
    UINT priority_found = 0;
    TX_THREAD *next_thread;

    /* 1. 找到最高优先级的就绪任务 */
    priority_found = _tx_thread_highest_priority();
    next_thread = _tx_thread_priority_list[priority_found];

    /* 2. 如果当前任务存在,检查抢占阈值 */
    if (_tx_thread_current_ptr != TX_NULL) {
        /* 只有新任务优先级高于当前任务的抢占阈值,才允许抢占 */
        if (priority_found < _tx_thread_current_ptr->tx_thread_preempt_threshold) {
            /* 允许抢占,执行上下文切换 */
            _tx_thread_context_save();
            _tx_thread_current_ptr = next_thread;
            _tx_thread_context_restore();
        } else {
            /* 不允许抢占,继续执行当前任务 */
            return;
        }
    } else {
        /* 无当前任务,直接切换 */
        _tx_thread_current_ptr = next_thread;
    }
}

优先级继承的实现则是在获取互斥量时动态调整优先级。当高优先级任务因为互斥量阻塞时,持有互斥量的低优先级任务会被提升到高优先级任务的优先级。释放互斥量后,恢复原始优先级。

关键点:ThreadX的优先级继承是立即继承,不是延迟继承。也就是说,一旦检测到阻塞,立刻提升持有者的优先级,而不是等到调度点才处理。这减少了优先级反转的持续时间。

下面这张图展示了抢占阈值和优先级继承在调度器中的位置关系:

ThreadX调度器核心逻辑 ThreadX调度器核心逻辑 就绪队列更新 优先级位图 + 链表 抢占检查 优先级 vs 抢占阈值 上下文切换 保存/恢复寄存器 优先级继承 互斥量阻塞时提升持有者 调度器关键路径说明 1. 就绪队列更新:任务状态变化时,更新优先级位图和就绪链表 2. 抢占检查:比较新任务优先级与当前任务的抢占阈值 3. 优先级继承:互斥量阻塞时,临时提升持有者优先级 4. 上下文切换:保存当前任务上下文,恢复新任务上下文 注:抢占阈值和优先级继承是ThreadX调度器的两大特色机制

4.4 实战配置建议

说了这么多原理,最后给几条实际配置的建议:

  • 抢占阈值用在临界区短的场景:比如操作一个全局变量、发一条短消息。如果临界区太长(毫秒级),阈值会导致其他中优先级任务饿死。
  • 优先级继承配合互斥量使用:ThreadX的互斥量(tx_mutex)默认支持优先级继承,不需要额外配置。但信号量(tx_semaphore)不支持,注意区分。
  • 不要滥用抢占阈值:我见过有人把所有任务的抢占阈值都设成最高优先级,结果系统退化成非抢占式调度。阈值是用来“精确控制”的,不是用来“一刀切”的。
  • 调试时打开调度跟踪:ThreadX的tx_trace_event_trace可以记录调度事件。我曾经靠这个抓到过一次优先级继承没生效的bug——原因是互斥量被意外删除后重建,继承关系丢失了。

避坑指南:我曾经在一个项目里把抢占阈值设得比任务自身优先级还低,结果任务一运行就被更高优先级的任务抢占,连临界区都进不去。后来查文档才发现,抢占阈值必须 ≥ 任务自身优先级。这个坑,我替你们踩过了。