从零开始学习嵌入式P29----线程间的通信
上一篇我们学会了创建和管理线程,也埋下一个伏笔:线程住在同一个进程空间里,共享全局变量、静态变量和堆区------通信是零成本的,但多个线程同时读写同一份数据就会"打架",结果不可预料。这个坑就是本篇要填的:线程间的通信与同步 。我们先看清楚"资源竞争"到底怎么把数据搞坏的,然后学会两件武器:互斥锁 (保证一段代码同一时刻只有一个线程在跑)和信号量(用计数器协调线程间的先后次序)。学完这一篇,多线程程序才能既跑得快又不出错。
本篇目标
学完这一篇,我们应该能够:
- 说清线程间通信为什么需要"立规矩":共享全局变量会引发资源竞争;
- 理解原子操作 和临界区两个核心概念;
- 用
pthread_mutex_init/pthread_mutex_lock/pthread_mutex_unlock/pthread_mutex_destroy保护临界区; - 说清死锁产生的四个必要条件,以及避免死锁的两条原则;
- 用
sem_init/sem_wait/sem_post/sem_destroy实现线程间的先后协作; - 写一个"生产者-消费者"式的双线程收发程序。
一、线程间通信的方式与资源竞争
1. 线程间通信的方式
上一篇说过,线程间通信是"零成本"的------多个线程共享同一个进程的全局变量、静态变量和堆区 ,一个线程往全局变量里写,另一个线程立刻就能读到。这就是线程间最基本的通信方式:使用全局变量。
但天下没有免费的午餐。多线程同时使用全局变量会引入资源竞争(竞态),需要通过互斥锁解决。
2. 先看一个"打起来"的例子
两个线程,线程1负责把 num 写成 100,线程2负责把 num 写成 200 然后打印出来:
c
#include "head.h"
pthread_mutex_t lock;
int num = 0;
void *thread1(void *arg)
{
while (1)
{
pthread_mutex_lock(&lock);
num = 100;
pthread_mutex_unlock(&lock);
}
return NULL;
}
void *thread2(void *arg)
{
while (1)
{
pthread_mutex_lock(&lock);
num = 200;
printf("num = %d\n", num);
pthread_mutex_unlock(&lock);
}
return NULL;
}
int main(void)
{
pthread_t tid1;
pthread_t tid2;
pthread_mutex_init(&lock, NULL);
pthread_create(&tid1, NULL, thread1, NULL);
pthread_create(&tid2, NULL, thread2, NULL);
pthread_join(tid1, NULL);
pthread_join(tid2, NULL);
pthread_mutex_destroy(&lock);
return 0;
}
先把 pthread_mutex_lock / pthread_mutex_unlock 这两行当空气(假装没有锁),看问题出在哪:线程2执行 num = 200; 之后、执行 printf 之前,时间片恰好用完,CPU 切给线程 1 执行了 num = 100;,等线程2再切回来打印时,打出来的就是 100------明明是自己刚写的 200,转眼被别人改了。
这就是资源竞争:多个线程读写同一份共享数据,谁后写谁覆盖,执行顺序由调度器说了算,结果不可预料。
3. 两个核心概念
要解决竞争,先立两个概念:
- 原子操作 :不能被 CPU 任务调度打断的一次最小的操作称为原子操作。
num = 200; printf(...)是两步操作,中间可以被调度打断,所以不是原子的------竞争正是从这里钻进来的; - 临界代码 / 临界区:加锁解锁中间的代码,这段代码不会被同时执行。只要让所有访问共享数据的代码都排好队一个一个过,竞争自然就消失了。
让临界区"同一时刻只有一个线程在跑"的机制,就是互斥锁。
二、互斥锁
1. 函数接口
互斥锁(mutex)是 pthread 库提供的同步机制,一把锁配四个函数:
c
int pthread_mutex_init(pthread_mutex_t *restrict mutex,
const pthread_mutexattr_t *restrict attr);
- 功能:初始化互斥锁;
- 参数 :
mutex:互斥锁空间首地址;attr:互斥锁的属性,默认属性传NULL;
- 返回值 :成功返回
0,失败返回错误码。
c
int pthread_mutex_lock(pthread_mutex_t *mutex);
int pthread_mutex_unlock(pthread_mutex_t *mutex);
- 功能 :互斥锁上锁 / 解锁。
lock拿到锁就继续往下走,拿不到就阻塞在原地,直到别的线程释放锁。
c
int pthread_mutex_destroy(pthread_mutex_t *mutex);
- 功能:互斥锁销毁。线程用完、确定不再使用时销毁。
用法是固定的套路:初始化锁 → 访问共享数据前加锁 → 访问完解锁 → 全部结束后销毁锁 。回头再看上面的例子:线程2的 num = 200; 和 printf 被夹在 lock/unlock 之间,成了临界区------线程1想改 num,必须等线程2打印完释放锁,数据就再也不会被中途篡改了。
2. 再体会一次:临界区保护"一组"操作
下面这个例子更能说明临界区的意义。线程1把 val 分别赋给 num1 和 num2,然后 val++;线程2检查 num1 和 num2 是否相等:
c
#include "head.h"
pthread_mutex_t lock;
int num1 = 0;
int num2 = 0;
int val = 100;
void *thread1(void *arg)
{
while (1)
{
pthread_mutex_lock(&lock);
num1 = val;
num2 = val;
pthread_mutex_unlock(&lock);
val++;
}
return NULL;
}
void *thread2(void *arg)
{
while (1)
{
pthread_mutex_lock(&lock);
if (num1 != num2)
{
printf("num1 = %d, num2 = %d\n", num1, num2);
}
pthread_mutex_unlock(&lock);
}
return NULL;
}
int main(void)
{
pthread_t tid1;
pthread_t tid2;
pthread_mutex_init(&lock, NULL);
pthread_create(&tid1, NULL, thread1, NULL);
pthread_create(&tid2, NULL, thread2, NULL);
pthread_join(tid1, NULL);
pthread_join(tid2, NULL);
pthread_mutex_destroy(&lock);
return 0;
}
逻辑上 num1 = val; num2 = val; 之后两数必然相等,所以 printf 永远不该执行。但如果把两把 lock/unlock 去掉再跑:线程1执行完 num1 = val、还没执行 num2 = val 时被切走,线程2恰好在这时检查,num1 != num2 成立,屏幕上就打印出了"不相等"的诡异结果。加锁不是保护某一条语句,而是保护"必须连续做完"的一整段临界区。
顺便注意这个例子的写法细节:线程1把 val++ 放在了临界区外面 。因为 val++ 只被线程1自己使用,不存在竞争,没必要进临界区------临界区越小越好,锁住的代码越多,其他线程排队等待的时间就越长,并发效率越低。
三、死锁
锁能保护数据,但用不好会引入新问题:死锁。
1. 什么是死锁
由于多个锁的加锁解锁导致线程无法继续执行的状态称为死锁。经典场景:线程1拿着锁 A 去申请锁 B,线程2拿着锁 B 去申请锁 A------你等我、我等你,两个线程一起卡死,谁也动不了。
2. 死锁产生的四个必要条件
- 互斥条件:一把锁同一时刻只能被一个线程持有;
- 不可剥夺条件:锁只能由持有者主动释放,别人抢不走;
- 请求保持:拿着一把锁不放,又去申请另一把锁;
- 循环等待:你等我、我等你,形成等待环。
四个条件缺一不可------反过来,只要破坏其中任何一个,死锁就不会发生。
3. 如何避免死锁
- 用
pthread_mutex_trylock替代pthread_mutex_lock:trylock拿不到锁不会傻等,而是立即返回失败,线程可以释放自己手里的锁过会儿再试------破坏了"请求保持"; - 多把锁的加锁顺序保持一致:所有线程都按 A → B 的顺序申请,就不会出现"你拿A我拿B"的死结------破坏了"循环等待"。
四、信号量
1. 什么是信号量
互斥锁解决的是"排他"问题,但线程间还有另一类需求:协调先后次序。比如一个线程往缓冲区写数据,另一个线程读数据------读线程必须等写线程写完才能动,光靠互斥锁就只能靠"抢到锁再检查、不行就退出来重抢"的空转,又笨拙又浪费 CPU。
这就要用到信号量:
- 信号量是一种资源------在程序运行过程中,代码、变量、CPU、内存都可以看成是一种资源;
- 资源一定是有限的,只能被初始化、销毁、申请和释放。
信号量内部维护一个计数器:申请一次计数减 1,释放一次计数加 1,计数为 0 时申请者阻塞等待。初值为 1 的信号量就能当互斥锁用(二元信号量),初值为 0 的信号量则天然适合做"等对方完成"的先后同步。
2. 函数接口
c
int sem_init(sem_t *sem, int pshared, unsigned int value);
- 功能:初始化信号量;
- 参数 :
sem:信号量的地址;pshared:0表示同一进程的线程间共享,非0表示进程间共享;value:初始化的值;
- 返回值 :成功返回
0,失败返回-1。
c
int sem_destroy(sem_t *sem);
- 功能:销毁信号量;
- 参数 :
sem为信号量的首地址; - 返回值 :成功返回
0,失败返回-1。
c
int sem_wait(sem_t *sem);
- 功能 :申请信号量。如果资源数 > 0 则让资源数 -1 并继续执行;如果资源数 == 0,阻塞等待直到资源 > 0 才能申请到资源继续向下执行;
- 返回值 :成功返回
0,失败返回-1。
c
int sem_post(sem_t *sem);
- 功能:释放资源,让资源数 + 1;
- 返回值 :成功返回
0,失败返回-1。
套路和互斥锁一样工整:sem_init 初始化 → sem_wait 申请 → sem_post 释放 → sem_destroy 销毁。
3. 练习:两个信号量实现"你写我读"
写一个小型生产者-消费者程序:线程1从终端接收一行输入写进共享缓冲区 tmpbuff,线程2把缓冲区内容打印出来,循环往复。要求严格交替:写一次、读一次,不能连写也不能连读。
思路:用两个信号量互相放行------
sem_w表示"缓冲区可写",初值 1(一开始缓冲区是空的,可以先写);sem_r表示"缓冲区可读",初值 0(一开始没数据,读者必须先等)。
写线程先申请 sem_w 再写、写完释放 sem_r;读线程先申请 sem_r 再读、读完释放 sem_w:
c
#include "head.h"
char tmpbuff[4096] = {0};
sem_t sem_r;
sem_t sem_w;
void *thread1(void *arg)
{
while (1)
{
sem_wait(&sem_w);
gets(tmpbuff);
sem_post(&sem_r);
}
return NULL;
}
void *thread2(void *arg)
{
while (1)
{
sem_wait(&sem_r);
printf("tmpbuff = %s\n", tmpbuff);
sem_post(&sem_w);
}
return NULL;
}
int main(void)
{
pthread_t tid1;
pthread_t tid2;
sem_init(&sem_w, 0, 1);
sem_init(&sem_r, 0, 0);
pthread_create(&tid1, NULL, thread1, NULL);
pthread_create(&tid2, NULL, thread2, NULL);
pthread_join(tid1, NULL);
pthread_join(tid2, NULL);
sem_destroy(&sem_w);
sem_destroy(&sem_r);
return 0;
}
拆开看这对"接力棒"是怎么传的:
- 初始时
sem_w = 1、sem_r = 0。写线程申请sem_w成功,开始等待键盘输入;读线程申请sem_r时被阻塞------数据还没写,乖乖等着; - 用户敲一行回车,写线程写完缓冲区,释放
sem_r------读线程被唤醒,打印内容; - 读线程打完释放
sem_w,写线程从阻塞中醒来,读下一行。如此循环。
整个过程中两个信号量的和始终是 1------同一时刻只有一个线程持有"缓冲区使用权",谁拿到谁干活:这就是信号量的精髓,用计数表达"资源可用没有",用阻塞代替干等。
如果把这个程序改成"生产者写、消费者读"的真实收发,只需要把 gets 换成数据采集、把 printf 换成存储逻辑------骨架完全一样。
五、常见错误
1. 临界区里出现耗时的阻塞操作,或者临界区划得太大
c
pthread_mutex_lock(&lock);
sleep(5); // 拿着锁睡觉,其他线程全被堵死
num = 100;
pthread_mutex_unlock(&lock);
临界区只应包含必须互斥的那几行 。sleep、I/O 这类与共享数据无关的耗时操作放在临界区里,会让所有等这把锁的线程陪绑,多线程形同串行。
2. 加锁后忘了解锁
c
pthread_mutex_lock(&lock);
if (error) return NULL; // 直接返回,锁永远还不上了
pthread_mutex_unlock(&lock);
持有锁的线程一旦"一去不复返",其他申请这把锁的线程将永远阻塞。临界区的每一条退出路径上都必须有配对的 unlock。
3. 两个线程交叉申请两把锁,造成死锁
c
// 线程1: lock(A) ... lock(B)
// 线程2: lock(B) ... lock(A) // 各自拿着一把等另一把,死锁
多把锁时全程序统一加锁顺序 ,或者用 pthread_mutex_trylock 拿不到就放手重试。
4. 用互斥锁做"先后同步",靠轮询空转
c
// 读线程:反复抢锁检查"写好了吗",没写好就释放再抢------CPU 空转
判断"对方干完没有"是信号量的活。互斥锁管"排他",信号量管"次序",别混用。
5. 信号量初值设错
上面的收发例子里,如果把 sem_r 也初始化成 1,读线程第一次就能读到还没写入的垃圾数据;如果 sem_w 初始化成 0,写线程一开始就被堵死,程序永久卡死。信号量的初值就是"开局时有几份资源",必须想清楚再写。
六、线程间通信速查
| 机制 | 接口 | 要点 |
|---|---|---|
| 互斥锁初始化 | pthread_mutex_init(&lock, NULL) |
默认属性传 NULL,失败返回错误码 |
| 加锁 / 解锁 | pthread_mutex_lock / pthread_mutex_unlock |
保护临界区,拿不到锁会阻塞;加锁路径必须都能解锁 |
| 尝试加锁 | pthread_mutex_trylock(&lock) |
拿不到立即返回失败,可用来避免死锁 |
| 互斥锁销毁 | pthread_mutex_destroy(&lock) |
用完销毁 |
| 信号量初始化 | sem_init(&sem, 0, 初值) |
pshared 为 0 表示线程间共享 |
| 申请资源 | sem_wait(&sem) |
计数 -1,为 0 时阻塞等待 |
| 释放资源 | sem_post(&sem) |
计数 +1,唤醒等待者 |
| 信号量销毁 | sem_destroy(&sem) |
用完销毁 |
| 易混淆点 | 结论 |
|---|---|
| 原子操作 | 不能被调度打断的最小操作;多条语句的组合不是原子的 |
| 临界区 | 加锁与解锁之间的代码,同一时刻只有一个线程执行 |
| 互斥锁 vs 信号量 | 互斥锁解决"排他"(同时只能一个访问),信号量解决"次序"(谁先谁后) |
| 死锁四条件 | 互斥、不可剥夺、请求保持、循环等待------破坏其一即可避免 |
小结
这一篇我们填上了上一篇留下的坑。线程共享全局变量让通信零成本,但多个线程同时读写共享数据会引发资源竞争 ------num = 200 和 printf 之间被插进一次 num = 100,结果就乱了。破解之道是理解原子操作 和临界区 :临界区是加锁解锁之间的代码,同一时刻只允许一个线程执行。互斥锁 用 pthread_mutex_init / lock / unlock / destroy 四个接口保护临界区,但用不好会导致死锁 ------记住互斥、不可剥夺、请求保持、循环等待四个必要条件,破坏其一即可,比如改用 pthread_mutex_trylock、统一加锁顺序。信号量 则从另一个角度协调线程:一个计数器,sem_wait 申请、sem_post 释放,计数为 0 就阻塞------初值为 1 能当锁用,初值为 0 能做先后同步。最后的双线程收发练习展示了两把信号量"接力"传数据的经典骨架,这正是真实嵌入式项目里采集、存储、显示各线程协作的雏形。
到目前为止,我们的多任务都发生在一个进程内部。下一篇我们走出单一进程,学习进程间通信(IPC)------管道、信号、消息队列、共享内存,看看彼此隔离的进程之间是如何交换数据的。