多任务间传输数据常用的做法有三种:全局变量、环形buffer、队列。
全局变量
全局变量实现起来最方便,但是有硬伤:
- 只能保存1份数据
- 没有阻塞唤醒,消费者只能轮询,浪费CPU
- 没有互斥,所以可能会读到没写完的半成品数据。因此只适合存简单数据,不适合比如一组相关的数据保存在结构体或者数组中。
环形buffer
环形buffer本质就是一个数组+两个下标(r+w),在逻辑上把数组首尾接成环。
环形buffer是提供给生产者-消费者模式的两个任务用的。
如果 r==w,环形buffer就是空的。因此环形buffer数组的格子不能用完,因为用完也是 r==w 了,也就是用1个格子换来了空和满不歧义。
环形buffer不要加 count,因为一旦加上这个变量,消费者和生产者两个任务就有可能同时写这个变量,而 count++ 在CPU上通常不是原子的:
- 从内存读到寄存器
- 寄存器 +1
- 写回内存
同样地,环形buffer也没有阻塞唤醒,需要任务自己去判断,可能会造成忙等。
队列
可以把队列理解为加了互斥保护和阻塞唤醒的环形buffer。
有了互斥保护,环形buffer中不敢用的 count 在队列中能用了。
也因为有了互斥保护,队列是可以多读多写的,不像环形buffer那样只支持生产者-消费者两个任务一读一写。
队列内部其实有三样东西:
- 环形buffer,用来保存数据
- send list,想写队列但是队列满了,在这里阻塞等待的任务
- receiver list,想读队列但队列空了,在这里阻塞等待的任务
写队列成功后,会看 receiver list 里有没有任务,有就把第一个移动回就绪链表。 同样地,读队列成功后,会看 send list 中有没有任务,有就把第一个移动回就绪链表。 就是用这种方式,队列实现了阻塞唤醒。
读到空队列的时候,任务会被同时挂到两个地方:
- 挂进队列的 receiver list
- 挂进系统的 delay list
所以一个阻塞中的读队列任务,会同时出现在两条链表里。
然后分为两种唤醒路径:
- 有任务写入了队列,叫醒了读队列任务。任务被唤醒后能直接从队列中读出数据来。
- 一直没有任务写入队列,直到达到了预设的超时时间,调度器把读队列任务放回就绪链表,读队列函数会返回错误码——告知没有读到数据。
写满队列的情况也是对称的。
队列如果在如果超时的一瞬间刚好被写入,会怎样?
这种情况是不会发生的,因为超时检查和操作队列都会先进入内核的临界区,会关中断、锁调度,保证了别的改队列的代码要等待当前这段代码执行完成。 这种临界区不是高级语言的那种 mutex 临界区,而是通过关中断实现的。一旦关中断,所有的代码都无法抢占运行了。也因为这样,关中断内执行的操作必须要短时间完成。
更关键的是:读队列不是“被叫醒就立刻带着某种结果返回”,而是醒了再看一眼队列里到底有没有东西: 如果有东西,就当作是成功了,把数据拿走然后返回。 如果是空的,就可以确认是超时醒来的,会返回错误码。