深入理解 Linux 线程机制(四):线程同步——条件变量与信号量

目录

四、线程同步

[4.1 线程同步产生的原因](#4.1 线程同步产生的原因)

[4.2 线程同步的概念](#4.2 线程同步的概念)

[4.3 条件变量](#4.3 条件变量)

[4.3.1 为什么需要条件变量](#4.3.1 为什么需要条件变量)

[4.3.2 条件变量的概念](#4.3.2 条件变量的概念)

[4.3.3 条件变量为什么需要互斥锁](#4.3.3 条件变量为什么需要互斥锁)

[4.3.4 POSIX 的条件变量接口](#4.3.4 POSIX 的条件变量接口)

[4.4 信号量](#4.4 信号量)

[4.4.1 为什么需要信号量](#4.4.1 为什么需要信号量)

[4.4.2 信号量的概念](#4.4.2 信号量的概念)

[4.4.3 POSIX 的信号量接口](#4.4.3 POSIX 的信号量接口)

五、线程话题的额外补充

[5.1 线程安全和重入](#5.1 线程安全和重入)

[5.2 STL中的容器与线程安全](#5.2 STL中的容器与线程安全)

[5.3 智能指针与线程安全](#5.3 智能指针与线程安全)


四、线程同步

4.1 线程同步产生的原因

在上一篇文章:深入理解 Linux 线程机制(三):线程互斥 中,我们通过互斥锁解决了多个线程同时访问共享资源导致的数据不一致性的问题。

但是,在模拟抢票的实际运行过程中,我们发现了另一个问题:

由于线程之间竞争的是同一把锁,而互斥锁本身并不保证线程获取锁的公平性,因此可能出现某一个线程连续多次获得锁,而其他线程长期无法获得锁的情况。

例如:线程 A 获取锁后完成一次抢票操作,释放锁资源,唤醒在锁中的其他等待线程,并重新参与锁的竞争,但是其他线程从被唤醒到运行是需要经历很多步骤的,而线程 A 不需要经历该过程,所以线程 A 再次竞争锁的能力强于其他线程,就会导致其他线程长期无法获得锁。

这种线程长期无法获得所需资源的现象,被称为线程饥饿

那么,如何让多个线程按照一定的规则协同工作,使线程之间不仅互斥,而且具有合理的执行顺序呢?

这就需要引入线程同步机制。

需要注意的是:

互斥解决的是多个线程之间的资源竞争问题,保证同一时间只有一个线程访问临界资源。

而同步解决的是多个线程之间的协作问题,保证线程按照一定的顺序完成任务。

4.2 线程同步的概念

线程同步:在保证临界资源安全的前提下,通过同步机制协调多个线程的执行顺序,使线程按照特定的顺序访问临界资源,这种机制被称为线程同步。

例如:

在生产者消费者模型中,生产者负责向缓冲区中生产数据,而消费者负责从缓冲区中消费数据。

如果缓冲区为空,消费者不能继续消费;如果缓冲区为满,生产者不能继续生成。

此时,仅仅依靠互斥锁只能保证同一时间只有一个线程访问缓冲区,却无法控制生产者和消费者之间的执行关系。

因此,需要引入线程同步机制。
Linux 中常见的线程同步方案包括:

方案一:条件变量(Condition Variable)

通过等待和通知机制,让线程等待某个条件成立后再继续执行。

方案二:信号量(Semaphore)

通过维护一个计数器,表示可用资源数量,从而控制多个线程之间的执行关系。

4.3 条件变量

4.3.1 为什么需要条件变量

在生产者和消费者模型中,存在一个共享缓冲区。

cpp 复制代码
queue<int> q;

消费者线程负责取数据:

cpp 复制代码
while(true)
{
    q.pop();
}

如果缓冲区为空:

cpp 复制代码
消费者:
    数据在哪里?
    没数据,我不能消费

此时有两种方案:

方案一:不断检测

复制代码
while(q.empty())
{
    ;
}

这种方式叫忙等

缺点:

  • 消耗 CPU;
  • 浪费系统资源;
  • 没有实际工作。

方案二:阻塞等待

消费者发现没有数据:

cpp 复制代码
消费者:
    当前没有数据
    我先睡眠
    等生产者通知我

生产者生产数据后:

cpp 复制代码
生产者:
    数据来了
    唤醒消费者

这种"等待某个条件成立,然后被通知继续执行"的机制,就是条件变量。

4.3.2 条件变量的概念

条件变量:条件变量是一种线程同步机制,它允许线程在某个条件不满足时主动进入等待状态,当其他线程改变条件后,通过通知机制唤醒等待线程继续执行。

条件变量本身不保存条件。

例如:

cpp 复制代码
queue.empty()

这个条件实际上由共享数据决定。

条件变量只负责:等待 + 通知

可以理解为:

cpp 复制代码
共享资源
    |
    |
条件是否满足
    |
    |
条件变量
    |
    |
线程等待 / 唤醒

4.3.3 条件变量为什么需要互斥锁

条件变量通常需要和互斥锁配合使用。

原因:条件变量保护的是线程之间的执行关系,而互斥锁保护的是临界资源的访问安全。

场景一:在生产者消费者模型中

共享资源:

复制代码
queue<int> q;

消费者线程:

复制代码
while(q.empty())
{
    等待生产者生产数据;
}

消费数据;

这里存在一个问题:

消费者发现队列为空,需要等待。

但是等待期间,它不能一直占用互斥锁。

如果消费者:

复制代码
1. 获取 mutex

2. 发现 q 为空

3. 进入等待状态

4. 但是没有释放 mutex

那么:

复制代码
消费者线程:
    持有 mutex
    睡眠等待


生产者线程:
    想生产数据
    需要获取 mutex


结果:
    永远无法获取 mutex
    无法生产数据
    无法唤醒消费者

此时就产生了死锁。

因此,条件变量等待操作必须完成一个原子过程:

复制代码
释放锁
    ↓
进入等待

不能拆开。

否则会产生竞争问题:

场景二:

复制代码
消费者:

获取锁

判断 q.empty()

准备释放锁等待


生产者:

获取锁

生产数据

发送通知


消费者:

释放锁

开始等待

消费者错过了生产者的通知,导致永久等待。

这就是丢失唤醒问题

因此条件变量必须和互斥锁配合使用。

4.3.4 POSIX 的条件变量接口

初始化条件变量

cpp 复制代码
int pthread_cond_init(
    pthread_cond_t *cond,
    const pthread_condattr_t *attr
);
cpp 复制代码
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;

功能:初始化条件变量。

参数:cond:需要初始化的条件变量对象的地址

attr:条件变量属性,一般设置为 nullptr,表示使用默认属性。

示例:

cpp 复制代码
pthread_cond_t cond;

pthread_cond_init(&cond, nullptr);

等待条件满足

cpp 复制代码
int pthread_cond_wait(
    pthread_cond_t *cond,
    pthread_mutex_t *mutex
);

它完成的三个关键操作:

cpp 复制代码
1. 释放锁

2. 将当前线程加入条件变量的等待队列中

3. 阻塞当前进程

当其他线程唤醒该线程后

cpp 复制代码
1. 从等待队列中移除
2. 重新参与锁的竞争
3. 获得锁返回,未获得锁,阻塞在申请锁中

因此:线程通过该函数返回后,线程一定重新持有锁。

注意:

条件判断必须使用 while,而不是 if:

cpp 复制代码
pthread_mutex_lock(&mutex);

while(queue.empty())
{
    pthread_cond_wait(&cond, &mutex);
}

queue.pop();

pthread_mutex_unlock(&mutex);

原因一:被唤醒不代表条件一定满足

例如:假设多个消费者同时等待,而生产者只生产了一个任务而唤醒所有消费者,就会导致消费者不再阻塞在条件变量的等待队列中,而是竞争互斥锁,倘若生产者以后不再生产任务,而一个消费之获取锁资源,执行完任务,其他消费者就会获得锁资源,如果采用 if 就会继续向下执行任务,导致程序崩溃。

例如:如果是生产者只唤醒一个消费者,是不是也会存在上述问题?

会,假设生产者线程 A,消费者线程 B、C,共享资源为1,消费者 C 正在条件变量下等待,消费者 B 正在执行任务,然后生产者 A 生产了一个任务,消费者 B 执行完任务,此时生产者 A 唤醒消费者 C,进而消费者 B 和消费者 C 竞争锁资源,如果消费者 B 获得锁,判断共享资源不为空,就会执行任务,而消费者 C 等待消费者 B 释放锁资源,获取锁资源时,此时共享资源为空,如果采用 if 就会导致程序崩溃。

上述两种现象均被称为伪唤醒

原因二:存在虚假唤醒

虚假唤醒:线程在条件变量下阻塞等待时,没有任何线程执行条件变量的唤醒,操作系统可能主动把线程从阻塞中唤醒,从内核态返回用户态执行用户代码,这种无通知唤醒被称为虚假唤醒。

唤醒一个等待线程

cpp 复制代码
int pthread_cond_signal(
    pthread_cond_t *cond
);

功能:唤醒等待在条件变量上的一个线程。

唤醒所有等待线程

cpp 复制代码
int pthread_cond_broadcast(
    pthread_cond_t *cond
);

功能:唤醒所有等待在条件变量上的线程。

销毁条件变量

cpp 复制代码
int pthread_cond_destroy(
    pthread_cond_t *cond
);

功能:释放条件变量资源。

4.4 信号量

4.4.1 为什么需要信号量

在上一节中,我们介绍了条件变量。

条件变量可以实现:

线程等待某个条件成立

其他线程修改条件后通知等待线程

但是,条件变量本身并不记录资源状态。

例如:

一个大小为5的缓冲区:

cpp 复制代码
vector<int> v(5);

我们需要知道:

当前缓冲区有多少数据

当前缓冲区还有多少空闲位置

这些信息需要程序员自己维护。

如果存在一种同步机制:

复制代码
资源存在数量 = N

线程申请资源:
    N--

资源释放:
    N++

并且当资源数量不足时,线程自动等待。

那么就可以更加方便地实现线程同步。

这种机制就是信号量。

4.4.2 信号量的概念

信号量本质就是一个计数器,表示当前可用共享资源的数量,就是一种对资源预定的机制。

线程访问资源时:

申请资源

执行:

复制代码
P操作 / wait操作

效果:

复制代码
Semaphore--

如果:

复制代码
Semaphore > 0

表示还有资源:

复制代码
线程继续执行

如果:

复制代码
Semaphore == 0

表示资源耗尽:

复制代码
线程阻塞等待

释放资源

执行:

复制代码
V操作 / post操作

效果:

复制代码
Semaphore++

并唤醒等待线程。

所以:

复制代码
         信号量

       资源计数器
            |
     ----------------
     |              |
  wait(P)       post(V)

  申请资源       释放资源

  数量减少       数量增加

4.4.3 POSIX 的信号量接口

初始化信号量

cpp 复制代码
int sem_init(
    sem_t *sem,
    int pshared,
    unsigned int value
);

功能:初始化信号量

参数:

sem:信号量对象的地址

pshared:0 表示线程间共享,非 0 表示进程间共享

value:初始资源的数量

示例:

cpp 复制代码
sem_t sem;

sem_init(&sem,0,5);

等待资源

cpp 复制代码
int sem_wait(sem_t *sem);

作用:申请资源

如果信号量 > 0 则信号量--,函数返回,开始访问共享资源。如果信号量 == 0 则当前线程阻塞,直到信号量被其他线程++,当前线程才能被唤醒申请资源

释放资源

cpp 复制代码
int sem_post(sem_t *sem);

作用:释放资源

信号量++,唤醒等待线程

注意:申请资源和释放资源本身均是原子操作的,不需要额外加锁保护。

销毁信号量

cpp 复制代码
int sem_destroy(sem_t *sem);

功能:释放信号量占用的资源


五、线程话题的额外补充

5.1 线程安全和重入

线程安全:多个线程并发访问共享资源时,能够正确地执⾏,不会相互⼲扰彼此的执⾏结果。
⼀般⽽⾔,多个线程并发同⼀段只有局部变量的代码时,不会出现不同的结果。但是对全局变量或者静态变量进⾏操作,并且没有锁保护的情况下,容易出现该问题。
重⼊:同⼀个函数被不同的执⾏流调⽤,当前⼀个执行流还没有执⾏完,就有其他的执⾏流再次⼊, 这种情况被称为重⼊。
⼀个函数在重⼊的情况下,运⾏结果正确,则该函数被称为可重⼊函数,否则是不可重⼊函数。
重入可以分为两种情况

  1. 多线程重入函数
  1. 信号导致一个执行流重复进入函数

可重入与线程安全的关系

  1. 可重入函数 => 线程安全
  2. 不可重入函数,就是不能由多个线程调用函数或信号导致一个执行流重复进入函数,如果是前者,则一定引发线程安全问题,如果是后者,可能引发线程安全问题。
  3. 线程安全不一定是可重入的
    核心:重入和线程安全是站在不同的视角来思考问题
    线程安全侧重说明线程访问公共资源的安全情况,表现的是并发线程的特点。
    可重⼊描述的是⼀个函数是否能被重复进⼊,表示的是函数的特点

5.2 STL中的容器与线程安全

STL中的容器不是线程安全的,原因:STL的设计初衷是将性能达到极致,而一旦涉及到加锁保证线程安全,会对性能产生巨大影响。而且对于不同的容器,加锁的方式不同,性能也会不同。
因此 STL 默认不是线程安全的,如果在多线程环境下使用,需要开发者保证线程安全

5.3 智能指针与线程安全

对于 unique_ptr 本身,不涉及线程安全问题。
对于 shared_ptr 本身,多个 share_ptr 本身需要共用同一个引用计数变量,存在线程安全问题,但是标准库的实现考虑了这个问题,并基于原子操作(CAS)的方式保证 shared_ptr 的线程安全。
注意:对于标准库中的智能指针是线程安全的,但对于它们指向的资源不是线程安全的,如果在多线程环境下使用,需要开发者保证线程安全。

相关推荐
smallerxuan43 分钟前
Linux 设备树(Device Tree)概览
linux·设备树·dts
爱喝水的鱼丶43 分钟前
SAP-ABAP:ALV数据导出增强——实现Excel/PDF/CSV多格式自定义导出
开发语言·性能优化·sap·abap·erp
不会c+1 小时前
C语言:入门到精通(408考研版)系列七 派生的数据类型
c语言·开发语言
Huangjin007_1 小时前
【Linux 系统篇(一)】入门基础指令详解(一)
linux·服务器
嵌入式小能手1 小时前
飞凌嵌入式ElfBoard-i2c与从设备通讯编程示例之i2c设备通信编程示例
linux
mounter6251 小时前
现代 Linux 内核中的数据持久化艺术:从回写(Writeback)到多样化写策略
linux·linux kernel·kernel·writeback·writethrough
不定积分要+C_yyy2 小时前
Java基础核心精讲:基本数据类型与包装类区别、自动装箱拆箱与缓存陷阱实战解析
java·开发语言
会飞的小新2 小时前
《深入理解 DDR Memory Margin》系列之第一章:从 DDR 到 Memory Margin——为什么服务器需要进行内存裕量测试?
linux·服务器
xingxiliang2 小时前
ReliableAgent:类似工程级可用的agent示例
java·开发语言·agent