从零开始学习嵌入式P29----线程间的通信

从零开始学习嵌入式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 分别赋给 num1num2,然后 val++;线程2检查 num1num2 是否相等:

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_locktrylock 拿不到锁不会傻等,而是立即返回失败,线程可以释放自己手里的锁过会儿再试------破坏了"请求保持";
  • 多把锁的加锁顺序保持一致:所有线程都按 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:信号量的地址;
    • pshared0 表示同一进程的线程间共享,非 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;
}

拆开看这对"接力棒"是怎么传的:

  1. 初始时 sem_w = 1sem_r = 0。写线程申请 sem_w 成功,开始等待键盘输入;读线程申请 sem_r 时被阻塞------数据还没写,乖乖等着;
  2. 用户敲一行回车,写线程写完缓冲区,释放 sem_r------读线程被唤醒,打印内容;
  3. 读线程打完释放 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 = 200printf 之间被插进一次 num = 100,结果就乱了。破解之道是理解原子操作临界区 :临界区是加锁解锁之间的代码,同一时刻只允许一个线程执行。互斥锁pthread_mutex_init / lock / unlock / destroy 四个接口保护临界区,但用不好会导致死锁 ------记住互斥、不可剥夺、请求保持、循环等待四个必要条件,破坏其一即可,比如改用 pthread_mutex_trylock、统一加锁顺序。信号量 则从另一个角度协调线程:一个计数器,sem_wait 申请、sem_post 释放,计数为 0 就阻塞------初值为 1 能当锁用,初值为 0 能做先后同步。最后的双线程收发练习展示了两把信号量"接力"传数据的经典骨架,这正是真实嵌入式项目里采集、存储、显示各线程协作的雏形。

到目前为止,我们的多任务都发生在一个进程内部。下一篇我们走出单一进程,学习进程间通信(IPC)------管道、信号、消息队列、共享内存,看看彼此隔离的进程之间是如何交换数据的。

相关推荐
kmomo..13 分钟前
Linux 进程学习笔记
linux·笔记·学习
白猫不黑15 分钟前
运维如何转安全(个人经验篇)
运维·学习·安全·web安全·网络安全·信息安全
SMT贴片河南芯途电子26 分钟前
中小批量SMT贴片加工的痛点与现代化柔性制造解决方案!
嵌入式硬件·物联网·边缘计算·制造
kyrie_sakura1 小时前
python学习笔记 7--- 文件(IO)操作
笔记·python·学习
xiaoxiangsiyan1 小时前
网络智能化转型核心模块全解析
运维·服务器·网络·数据库·学习·架构·php
恒锐丰小瑞1 小时前
率能SS8837T 12V/1.8A/单通道H桥电机驱动IC,超低睡眠电流(120nA)与独立逻辑电源,用于摄像机/玩具/机器人
嵌入式硬件·机器人
minglie11 小时前
espidf的iic波特律动oled
学习
从零开始的嵌入式之旅1 小时前
day32
linux·c语言·经验分享·笔记·嵌入式硬件
集芯微电科技有限公司2 小时前
低压功率MOSFETs选型手册
人工智能·单片机·嵌入式硬件·神经网络·生成对抗网络