从信号量到事件组

前文:从队列到信号量:计数、二值与中断里的 Give、从优先级反转看互斥量。

本质

队列和信号量传递的是令牌:Give/Send 产生一个,Take/Receive 消费一个。一个令牌只能被一个任务拿走,所以一次操作最多叫醒一个任务。

事件组保存的是状态:一组 bool 标志,每一位表示“某件事是不是已经发生”。等待方读取这个状态,读本身不消费,所以一次置位可以让任意多个任务同时满足条件。

差别的根源就是一对一和一对多的区别。

内核会替事件组的等待者判断条件,只叫醒满足条件的那些,而不是把所有等待者都叫醒然后让他们自己检查条件。

结构

事件组由一个 32 位 int 和一条等待链表组成:

  • 32 位 int 的低 24 位每位代表一个事件,含义由应用约定
  • 32 位 int 的高 8 位不用
  • 等待链表挂着正在等的任务,每个任务记着自己在等的位和条件

每个等待中的任务的条件也有一个整数:

  • 低 24 位记录这个任务关心什么位,相当于掩码
  • 高 8 位记录这个任务的信息:与/或、醒来清除

操作

等待操作给出掩码、与或、醒来是否清除、超时。条件满足就返回,不满足就挂到等待链表里,带超时的话还会挂到 delay list。

置位操作把若干位置为 1,然后从头到尾检查等待链表上的每一个任务,满足条件的全部叫醒。所有任务都检查完后,内核才会统一清除那些设置了醒来清除的位,所以多个等待者不会因为别人先清除就被漏掉。 但如果某个等待者设置了醒来清除,那么在它之后才开始等待的任务就看不到这次事件了。广播只会覆盖置位那一刻已经在链表里的任务。

谁来清除的区别

如果是等待者醒来自动清除,代表这个事件是一次性事件,用掉了就没了。 如果等待者醒来不自动清除,而是另外的时机手动清除甚至不清除,代表这个事件是个持久状态,之后每个来等的任务都会立刻通过。

And等待的区别

与等待会等多个条件同时成立,而且不会关心它们发生的先后顺序。如果用信号量做这件事,需要自己记录哪些已经到了,还要处理竞态,而用事件组只需要一次等待。

每个任务置上自己的位,然后等所有位都被置上,相当于栅栏,几个任务在同一个点上会合后再一起往下走。注意“置位”和“等待”如果分成两次调用,中间可能被别的任务插进来,所以 FreeRTOS 为栅栏专门提供了 xEventGroupSync,把这两步合成一次原子操作。

在任务里和中断里置位

在任务里调用 xEventGroupSetBits,全程都会在同一个任务里执行:

  1. 暂停调度器,但不关中断
  2. 置位,遍历等待链表,把满足条件的任务移动到就绪链表
  3. 恢复调度器,如果叫醒了比自己优先级高的任务,马上切换过去

在中断里调用 xEventGroupSetBitsFromISR,中断里不会做遍历,因为置位要遍历整条等待链表,耗时取决于有多少任务在等,时间不好控制。中断里只会提交一个请求:

  1. ISR 往定时器服务任务的命令队列里放一条命令,内容是“对这个事件组置这些位”
  2. ISR 退出时,事件位还没被置上
  3. 定时器服务任务从命令队列里读到这条命令,在任务上下文里执行普通的置位流程

所以要等到服务任务被调度到,才会真正遍历,延迟不可控。如果要求响应及时,服务任务的优先级要足够高。

另外,如果定时器命令队列满了,xEventGroupSetBitsFromISR 会返回失败,这次置位就丢了。