第9章 同步互斥与通信
本章是概述性的内容。可以把多任务系统当做一个团队,里面的每一个任务就相当于团队里的一个人。团队成员之间要协调工作进度(同步)、争用会议室(互斥)、沟通(通信)。多任务系统中所涉及的概念,都可以在现实生活中找到例子。
各类RTOS都会涉及这些概念:任务通知(task notification)、队列(queue)、事件组(event group)、信号量(semaphoe)、互斥量(mutex)等。我们先站在更高角度来讲解这些概念。
9.1 同步与互斥的概念
一句话理解同步与互斥:我等你用完厕所,我再用厕所。
什么叫同步?就是:哎哎哎,我正在用厕所,你等会。
什么叫互斥?就是:哎哎哎,我正在用厕所,你不能进来。
同步与互斥经常放在一起讲,是因为它们之的关系很大,“互斥”操作可以使用“同步”来实现。 我“等”你用完厕所,我再用厕所。这不就是用“同步”来实现“互斥”吗?
再举一个例子。在团队活动里,同事A先写完报表,经理B才能拿去向领导汇报。经理B必须等同事A完成报表,AB之间有依赖,B必须放慢脚步,被称为同步。在团队活动中,同事A已经使用会议室了,经理B也想使用,即使经理B是领导,他也得等着,这就叫互斥。经理B跟同事A说:你用完会议室就提醒我。这就是使用"同步"来实现"互斥"。
有时候看代码更容易理解,伪代码如下:
01 void 抢厕所(void)
02 {
03 if (有人在用) 我眯一会;
04 用厕所;
05 喂,醒醒,有人要用厕所吗;
06 }
假设有A、B两人早起抢厕所,A先行一步占用了;B慢了一步,于是就眯一会;当A用完后叫醒B,B也就愉快地上厕所了。
在这个过程中,A、B是互斥地访问“厕所”,“厕所”被称之为临界资源。我们使用了“休眠-唤醒”的同步机制实现了“临界资源”的“互斥访问”。
同一时间只能有一个人使用的资源,被称为临界资源。比如任务A、B都要使用串口来打印,串口就是临界资源。如果A、B同时使用串口,那么打印出来的信息就是A、B混杂,无法分辨。所以使用串 口时,应该是这样:A用完,B再用;B用完,A再用。
9.2 同步与互斥并不简单
在裸机程序里,可以使用一个全局变量或静态变量实现互斥操作,比如要互斥地使用LCD,可以使用如下代码。
示例代码1:
01 int LCD_PrintString(int x, int y, char *str)
02 {
03 static int bCanUse = 1;
04 if (bCanUse)
05 {
06 bCanUse = 0;
07 /* 使用LCD */
08 bCanUse = 1;
09 return 0;
10 }
11 return -1;
12 }
但是在RTOS里,使用上述代码实现互斥操作时,大概率是没问题的,但是无法确保万无一失。
假设如下场景:有两个任务A、B都想调用LCD_PrintString,任务A执行到第4行代码时发现bCanUse为1,可以进入if语句块,它还没执行第6句指令就被切换出去了;然后任务B也调用LCD_PrintString,任务B执行到第4行代码时也发现bCanUse为1,也可以进入if语句块使用LCD。在这种情况下,使用静态变量并不能实现互斥操作。
上述例子中,是因为第4、第6两条指令被打断了,那么如下改进:在函数入口处先然让bCanUse减一。这能否实现万无一失的互斥操作呢?
示例代码2:
01 int LCD_PrintString(int x, int y, char *str)
02 {
03 static int bCanUse = 1;
04 bCanUse--;
05 if (bCanUse == 0)
06 {
07 /* 使用LCD */
08 bCanUse++;
09 return 0;
10 }
11 else
12 {
13 bCanUse++;
14 return -1;
15 }
16 }
把第4行的代码使用汇编指令表示如下:
04.1 LDR R0, [bCanUse] // 读取bCanUse的值,存入寄存器R0
04.2 DEC R0, #1 // 把R0的值减一
04.3 STR R0, [bCanUse] // 把R0写入变量bCanUse
假设如下场景:有两个任务A、B都想调用LCD_PrintString,任务A执行到第04.1行代码时读到的bCanUse为1,存入寄存器R0就被切换出去了;然后任务B也调用LCD_PrintString,任务B执行到第4行时发现bCanUse为1并把它减为0,执行到第5行代码时发现条件成立可以进入if语句块使用LCD,然后任务B也被切换出去了;现在任务A继续运行第04.2行代码时R0为1,运行到第04.3行代码时把bCanUse设置为0,后续也能成功进入if的语句块。在这种情况下,任务A、B都能使用LCD。
上述方法不能保证万无一失的原因在于:在判断过程中,被打断了。如果能保证这个过程不被打断,就可以了:通过关闭中断来实现。
示例1的代码改进如下:在第5~7行前关闭中断。
01 int LCD_PrintString(int x, int y, char *str)
02 {
03 static int bCanUse = 1;
04 disable_irq();
05 if (bCanUse)
06 {
07 bCanUse = 0;
08 enable_irq();
09 /* 使用LCD */
10 bCanUse = 1;
11 return 0;
12 }
13 enable_irq();
14 return -1;
15 }
示例2的代码改进如下:在第5行前关闭中断。
```c
01 int LCD_PrintString(int x, int y, char *str)
02 {
03 static int bCanUse = 1;
04 disable_irq();
05 bCanUse--;
06 enable_irq();
07 if (bCanUse == 0)
08 {
09 /* 使用LCD */
10 bCanUse++;
11 return 0;
12 }
13 else
14 {
15 disable_irq();
16 bCanUse++;
17 enable_irq();
18 return -1;
19 }
20 }
9.3 示例8:同步互斥的缺陷
假设有2个任务:任务1进行复杂的计算,任务2在等待任务1计算完毕,然后打印结果,它们之间就存在“同步”的关系。在等待过程中,如果任务2也参与调度的话,任务1就不能独享CPU,导致最终消耗的时间变长。
在0802_create_task_use_params的基础上,修改出0901_task_sync_exclusion。 关键代码在“applications\calc_and_display_app.c”中,代码如下:
12 static uint32_t g_time = 0;
13 static volatile uint32_t g_sum = 0;
14 static volatile int g_calc_end = 0;
15 static volatile int g_LCDCanUse = 1;
16
17 void CalcTask(void *params)
18 {
19 uint32_t i = 0;
20
21 while (!g_LCDCanUse);
22 g_LCDCanUse = 0;
23 LCD_PrintString(0, 0, "CalcTask doing...");
24 LCD_PrintString(0, 16, "PrintTask Waiting...");
25 LCD_Flush();
26 g_LCDCanUse = 1;
27
28 g_time = system_get_us();
29 for (i = 0; i < 10000000; i++)
30 {
31 g_sum += i;
32 }
33 g_calc_end = 1;
34 g_time = system_get_us() - g_time;
35
36 vTaskDelete(NULL);
37 }
38
39
40 void LcdPrintTask(void *params)
41 {
42 int len;
43 while (1)
44 {
45 vTaskDelay(3000);
46
47 while (g_calc_end == 0);
48
49 /* 打印信息 */
50 while (!g_LCDCanUse);
51 g_LCDCanUse = 0;
52
53 LCD_ClearLine(0, 32);
54 len = LCD_PrintString(0, 32, "Sum: ");
55 LCD_PrintHex(len*8, 32, g_sum, 1);
56
57 LCD_ClearLine(0, 48);
58 len = LCD_PrintString(0, 48, "Time(ms): ");
59 LCD_PrintSignedVal(len*8, 48, g_time/1000);
60 LCD_Flush();
61
62 g_LCDCanUse = 1;
63
64 vTaskDelete(NULL);
65 }
66 }
第28~34行是CalcTask任务的代码,它累加10000000个数据,统计出耗时。
第47行是LcdPrintTask任务的代码,它在等待CalcTask任务计算完毕。
注意第45行代码,如果这行代码不注释掉,让LcdPrintTask任务阻塞,计算耗时596ms;如果把第45行注释掉,LcdPrintTask也参与调度,那么计算耗时1192ms,刚好是2倍。
可见,使用全局变量来实现同步时,另一个“等待的任务”也会抢占CPU资源,程序效率低。后面会引入FreeRTOS的同步机制,使得“等待的任务”进入阻塞状态,提高CPU利用率。
9.4 各类方法的对比
能实现同步、互斥的内核方法有:任务通知(task notification)、队列(queue)、事件组(event group)、信号量(semaphoe)、互斥量(mutex)。
它们都有类似的操作方法:获取/释放、阻塞/唤醒、超时。比如:
- 任务A获取资源,用完后任务A释放资源
- 任务A获取不到资源则阻塞,任务B释放资源并把任务A唤醒
- 任务A获取不到资源则阻塞,并定个闹钟;A要么超时返回,要么在这段时间内因为任务B释放资源而被唤醒。
这些内核对象五花八门,记不住怎么办?我也记不住,通过对比的方法来区分它们。
- 能否传信息?还是只能传递状态?
- 为众生(所有任务都可以使用)?只为你(只能指定任务使用)?
- 我生产,你们消费?
- 我上锁,只能由我开锁
| 内核对象 | 生产者 | 消费者 | 数据/状态 | 说明 |
|---|---|---|---|---|
| 队列 | ALL | ALL | 数据:若干个数据 谁都可以往队列里扔数据, 谁都可以从队列里读数据 | 用来传递数据, 发送者、接收者无限制, 一个数据只能唤醒一个接收者 |
| 事件组 | ALL | ALL | 多个位:或、与 谁都可以设置(生产)多个位, 谁都可以等待某个位、若干个位 | 用来传递事件, 可以是N个事件, 发送者、接受者无限制, 可以唤醒多个接收者:像广播 |
| 信号量 | ALL | ALL | 数量:0~n 谁都可以增加一个数量, 谁都可消耗一个数量 | 用来维持资源的个数, 生产者、消费者无限制, 1个资源只能唤醒1个接收者 |
| 任务通知 | ALL | 只有我 | 数据、状态都可以传输, 使用任务通知时, 必须指定接受者 | N对1的关系: 发送者无限制, 接收者只能是这个任务 |
| 互斥量 | 只能A开锁 | A上锁 | 位:0、1 我上锁:1变为0, 只能由我开锁:0变为1 | 就像一个空厕所, 谁使用谁上锁, 也只能由他开锁 |
使用图形对比如下:
- 队列:
- 里面可以放任意数据,可以放多个数据
- 任务、ISR都可以放入数据;任务、ISR都可以从中读出数据
- 事件组:
- 一个事件用一bit表示,1表示事件发生了,0表示事件没发生
- 可以用来表示事件、事件的组合发生了,不能传递数据
- 有广播效果:事件或事件的组合发生了,等待它的多个任务都会被唤醒
- 信号量:
- 核心是"计数值"
- 任务、ISR释放信号量时让计数值加1
- 任务、ISR获得信号量时,让计数值减1
- 任务通知:
- 核心是任务的TCB里的数值
- 会被覆盖
- 发通知给谁?必须指定接收任务
- 只能由接收任务本身获取该通知
- 互斥量:
- 数值只有0或1
- 谁获得互斥量,就必须由谁释放同一个互斥量
