从优先级反转看互斥量

上一篇:从队列到信号量:计数、二值与中断里的 Give。

上一篇讲信号量的用法时提到:把二值信号量的初值设为 1,用之前 Take、用完之后 Give,它就是一把"同一时刻只允许一个任务"的锁。这把锁在大多数时候都能用,但当三个不同优先级的任务碰到一起时,会出现一个二值信号量解决不了的问题:优先级反转。

正常情况下,高优先级任务就绪就能抢到CPU。优先级反转是指:高优先级任务实际等待的时间,取决于一个优先级比它低、而且跟它要的资源毫无关系的任务跑多久。效果上,就像中优先级排到了高优先级前面。

从优先级反转看为什么需要互斥量

设三个任务,优先级 H > M > L。要出现优先级反转,需要同时满足三个条件:

  1. L 拿着锁:L 先 Take 成功,进入临界区,还没 Give。
  2. H 要同一把锁:H 就绪后 Take,计数为 0,H 进入阻塞,等这把锁。
  3. M 一直占着 CPU:M 不需要这把锁,优先级比 L 高,并且一直不阻塞。

这时就绪的只有 M 和 L。M 优先级更高,所以一直是 M 在跑。L 跑不起来,就没法 Give;L 不 Give,H 就一直睡。

三个条件少一个就不会发生:

  • 没有 M:H 阻塞后让出 CPU,L 接着跑完临界区并 Give,H 马上被叫醒。H 等的时间只是 L 临界区剩下的那一段,这是正常的锁等待。
  • M 会阻塞(比如用 vTaskDelay):M 睡着的空档 L 能跑,最终还是会 Give。H 会被拖慢,但不会被 M 一直卡住。
  • H 不要这把锁:H 就绪时直接抢 CPU,跟 L 和 M 都没关系。

这是很危险的,因为正常的锁等待有上界:最多等到持有者把临界区执行完。 发生反转后,H 的等待时间取决于 M 跑多久,而 M 跟这把锁没有任何关系。如果有多个 M 优先级任务轮流就绪,H 的等待时间就没有上界。对实时系统来说,这意味着最高优先级任务的响应时间不再可预测。

要打破僵局,只能让 L 在这段时间里跑得过 M,也就是临时把 L 的优先级提高。但信号量不记录锁在谁手里,内核不知道该提高谁的优先级。 互斥量会记录持有者,H 等待时,内核把持有者 L 的优先级临时提高到和 H 一样(这就是优先级继承),Give 后再恢复原优先级。

互斥量是什么

互斥量是专门用来当锁的二值信号量。计数机制和二值信号量一样:1 表示锁空闲,0 表示被拿走。 互斥量比起二值信号量,多做了两件事:

  1. 记录持有者:哪个任务 Take 成功,内核就记下这把锁归它。
  2. 优先级继承与恢复:有更高优先级的任务在等这把锁时,内核临时把持有者的优先级提高,持有者 Give 时恢复原来的优先级。

我们看看在上面的优先级反转情况中,如果换成了互斥量会发生什么:

  1. H Take,锁被占用,H 阻塞。内核看到持有者是 L,把 L 的优先级提到和 H 一样。
  2. 现在就绪任务里,L 的优先级高于 M,于是 L 抢到 CPU,继续执行临界区。
  3. L Give。内核把 L 的优先级恢复原值,同时叫醒 H。H 是最高优先级,马上运行。
  4. H 用完锁,之后才轮到 M。

这样,H 的等待时间又回到"最多等 L 执行完临界区",不再受 M 影响。

带来的变化

既然内核要按持有者来提升和恢复优先级,就有一条信号量没有的规则:谁 Take,就由谁 Give。普通信号量任何任务都可以 Give,互斥量不行。

这条规则主要靠程序员遵守。内核记录了持有者,但只用 configASSERT 检查:定义了断言时,违规会停在断言里;没定义时,内核不报错,优先级恢复就会出错。

也因此,互斥量不能在中断里使用(原因见文末"拓展")。如果想在中断里发信号,还是用二值信号量。

拓展:中断优先级

中断也有优先级,但是是硬件优先级,和任务优先级不同体系。任务优先级再高,也排在所有中断之后(除非关中断了)。更高优先级的中断可以打断正在执行的低优先级中断。

中断优先级在配置的时候就已经定好了,内核不会改动,所以也不会像对任务那样临时提升中断的优先级。

互斥量不能在中断里用,真正的原因有两点:

  1. 中断不能阻塞,锁被占用时它没法等;
  2. 中断不是任务,没有任务控制块,内核没办法把中断记录成持有者,也就没法做优先级继承。

这也是为什么中断应该尽量短,因为中断可以打断任何任务,包括正在拿着锁的任务。中断跑多久,所有任务就要等多久。