目录
[1. 多线程问题](#1. 多线程问题)
[1. 什么是互斥量](#1. 什么是互斥量)
[2. 售票系统](#2. 售票系统)
[3. Mutex 接口](#3. Mutex 接口)
[4. 修正售票系统](#4. 修正售票系统)
[四、Mutex 原理](#四、Mutex 原理)
[1. lock 伪代码分析](#1. lock 伪代码分析)
[2. unlock 伪代码分析](#2. unlock 伪代码分析)
[五、RAII 封装](#五、RAII 封装)
[1. Mutex 类](#1. Mutex 类)
[2. LockGuard 类](#2. LockGuard 类)
[3. 设计分析](#3. 设计分析)
[1. 为什么需要同步](#1. 为什么需要同步)
[2. 同步概念](#2. 同步概念)
[3. 条件变量接口](#3. 条件变量接口)
[4. 为什么 pthread_cond_wait 需要 mutex](#4. 为什么 pthread_cond_wait 需要 mutex)
[5. 使用规范](#5. 使用规范)
[1. 为什么需要生产消费模型](#1. 为什么需要生产消费模型)
[2. 生产者消费者模型的优点](#2. 生产者消费者模型的优点)
[3. 321原则](#3. 321原则)
[1. 信号量概念](#1. 信号量概念)
[2. 信号量接口](#2. 信号量接口)
[九、 基于信号量的生产者消费者模型](#九、 基于信号量的生产者消费者模型)
[1. 逻辑简述](#1. 逻辑简述)
[2. 代码实现](#2. 代码实现)
一、线程同步问题
在本篇博客中,我们将深入探讨多线程编程中最具挑战性的核心问题:线程同步与互斥机制
当多个执行线程在同一个地址空间中并发运行时,它们会共享大量的资源。这种资源共享机制虽然提供了高效的进程间通信方式,却也潜藏着严重的数据一致性问题,特别是当非原子操作引发数据竞争时,这种隐患尤为突出
1. 多线程问题
在多线程环境下,当多个线程同时对同一个全局变量或静态变量进行修改时,最终的结果往往具有不确定性
实例:并发自增操作
假设我们定义了一个全局变量 int count = 0;,并创建 10 个线程,每个线程对其进行 10000 次 count++ 操作。理论上,最终结果应该是 100,000。但在实际运行中,结果往往会小于这个数值
为什么 count++ 不是原子的?
在 C/C++ 层面,count++ 只有一行代码,但在 CPU 执行层面,这一行代码通常会被翻译成三条汇编指令(以 x86 架构为例):
-
Load:将变量 count 从内存加载到 CPU 寄存器中
- mov eax, [count]
-
Add:在寄存器中执行加法操作
- add eax, 1
-
Store:将修改后的值从寄存器写回内存
- mov [count], eax

冲突场景分析:
-
线程 A 执行了 Load,将 count=0 读入寄存器
-
此时线程 A 的时间片耗尽,发生上下文切换。线程 A 的寄存器状态(eax=0)被保存到其 TCB 中
-
线程 B 被调度,它完整地执行了三步,将 count 增加到了 100,并写回内存
-
线程 A 恢复执行,它恢复了之前的寄存器状态(eax=0),继续执行 Add 变为 1,随后执行 Store 将 count=1 写回内存

结果: 线程 B 增加的 100 次操作被线程 A 覆盖了。这种由于多个执行流对共享数据的交错访问导致结果错误的现象,被称为数据竞争
二、基础概念
为了解决上述问题,我们需要建立一套严谨的术语体系来描述并发控制
共享资源
指在多个线程之间可以共同访问的资源。在 Linux 进程中,全局变量、静态变量、堆上开辟的空间以及打开的文件描述符表都属于共享资源
临界资源
在共享资源中,那些在同一时刻只允许一个执行流访问的资源被称为临界资源。如果多个线程同时访问临界资源,就会导致数据不一致
临界区
临界区是指每个执行流中访问临界资源的片段代码。
-
注意:临界区是代码段 ,而临界资源是数据或设备。
-
我们进行线程同步与互斥的核心任务,就是保护临界区,确保同一时间只有一个执行流进入该代码段
互斥
互斥是一种控制机制。它保证当一个执行流正在临界区内访问临界资源时,其他执行流必须在临界区外等待。直到当前执行流离开临界区,其他执行流才有机会进入
原子性
一个操作如果被称为是原子的,那么它必须满足:
-
不可分割性:该操作要么全部执行完成,要么完全不执行
-
不可观测性:在其他执行流看来,该操作是瞬时完成的,不存在中间状态
在硬件层面,单条汇编指令通常被认为是原子的。而像 count++ 这种多指令组合操作,如果不加保护,则不具备原子性
三、Mutex
为了从根本上解决数据竞争问题,Linux 提供了互斥锁(Mutex)机制。其核心目标是通过强制排队的方式,将临界区内的并发执行转化为串行执行,从而保证操作的完整性
1. 什么是互斥量
Mutex(全称 Mutual Exclusion)是一种用于保护临界资源的硬件/软件原语。它像是一把 "锁",在任意时刻,只允许一个执行流持有这把锁
-
锁的特性:如果一个线程尝试获取已被其他线程持有的锁,该线程将被挂起(阻塞),直到锁被释放
-
粒度:Mutex 保护的是代码路径(临界区),通过限制对代码的访问来间接保护数据
2. 售票系统
为了直观地理解互斥锁的必要性,我们设计一个典型的多线程售票系统
场景设计
定义全局变量 int tickets = 100。创建 5 个线程,每个线程模拟一个售票窗口,执行如下逻辑:
cpp
void* sell_tickets(void* arg) {
while (tickets > 0) {
usleep(1000); // 模拟业务处理耗时
printf("售出一张票,剩余: %d\n", --tickets);
}
}
运行示例:

竞态条件分析
我们看到最后打印出的剩余票数出现了 0 甚至 -1 、-2 的情况。这显然违反了业务逻辑。结合上一节讨论的非原子性,我们可以还原其崩溃过程:
-
判定阶段:假设当前 tickets 为 1。线程 A 进入 while 循环判定 tickets > 0 成立
-
上下文切换:线程 A 在执行 --tickets 之前时间片耗尽。此时内核保存 A 的上下文,切换到线程 B
-
重复判定:线程 B 也判定 tickets > 0(此时仍为 1),进入循环并成功执行 --tickets,将内存中的 tickets 改为 0
-
错误执行:线程 A 恢复执行。由于它已经通过了判定阶段,它会直接执行 --tickets
-
从内存读取 tickets=0 到寄存器
-
Modify: 寄存器值减 1,变为 -1
-
Store: 将 -1 写回内存
-
结论 :由于 tickets > 0 的判断与 --tickets 的修改之间不是原子的,中间存在的空窗期导致了竞态条件 。要修复此问题,必须保证判断+修改这一整套逻辑在同一时间只有一个线程在运行
3. Mutex 接口
在 POSIX 线程库中,互斥锁的类型为 pthread_mutex_t。其核心操作接口如下:
(1) 初始化与销毁
-
静态分配
cpppthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER; -
动态分配:
cpp初始化互斥量 int pthread_mutex_init(pthread_mutex_t *restrict mutex, const pthread_mutexattr_t *restrict attr); 参数: mutex:要初始化的互斥量 attr:NULL 即可 销毁互斥量 int pthread_mutex_destroy(pthread_mutex_t *mutex); 注意: - 使用 PTHREAD_ MUTEX_ INITIALIZER 初始化的互斥量不需要销毁 - 不要销毁一个已经加锁的互斥量 - 已经销毁的互斥量,要确保后面不会有线程再尝试加锁
(2) 加锁与解锁
-
加锁:
cppint pthread_mutex_lock(pthread_mutex_t *mutex); 如果锁处于空闲状态,调用后立即获得锁并返回 如果锁已被占用,调用线程将阻塞等待,直到获取成功 -
尝试加锁:
cppint pthread_mutex_trylock(pthread_mutex_t *mutex); 非阻塞版本。如果锁不可用,立即返回错误码 EBUSY 而不是挂起 -
解锁:
cppint pthread_mutex_unlock(pthread_mutex_t *mutex); 释放锁,唤醒正在等待该锁的线程
4. 修正售票系统
通过引入 Mutex,我们可以将售票逻辑修改为:
cpp
void* sell_tickets(void* arg) {
while (1) {
pthread_mutex_lock(&lock); // 进入临界区前加锁
if (tickets > 0) {
usleep(1000);
printf("窗口 %d 售票成功,余票: %d\n", (int)arg, --tickets);
pthread_mutex_unlock(&lock); // 逻辑完成后解锁
} else {
pthread_mutex_unlock(&lock); // 判定失败也必须解锁,防止死锁
break;
}
}
}
关键点:
-
覆盖完整临界区:必须从判断 tickets > 0 开始加锁,直到 --tickets 结束。
-
保证出口解锁:在循环的所有退出分支(如 break 前)都必须包含解锁操作,否则会导致其他线程永久阻塞
四、Mutex 原理
为了理解互斥锁如何保证原子性,我们不能仅停留在 C 语言层面,因为 pthread_mutex_lock 本身也是一段代码。如果加锁的代码逻辑不是原子的,就会陷入 "用另一把锁来保护这把锁" 的无限递归中
实现原子性的核心不在于软件算法,而在于硬件支持。现代 CPU 提供了能够在一个总线周期内完成 "读-改-写" 的原子指令
1. lock 伪代码分析
在大多数体系结构中,实现互斥锁的核心指令是 swap 或 exchange(如 x86 的 xchgb)。该指令的功能是将寄存器中的内容与内存单元的内容进行交换。由于这是单条硬件指令,它在执行过程中不会被中断,具有天然的原子性
lock 操作的汇编伪代码
假设内存中有一个变量 mutex,其初始值为 1(表示锁可用)。当一个线程尝试加锁时,其底层的执行逻辑如下
bash
lock:
movb $0, %al ; 1. 将寄存器 %al 的内容清 0
xchgb %al, mutex ; 2. 原子交换:%al 的内容与内存中 mutex 的内容互换
if (al寄存器的内容 > 0) {
return 0; ; 3. 交换后 %al 为 1,说明抢锁成功,直接返回
} else {
挂起等待; ; 4. 交换后 %al 为 0,说明锁已被占用,线程进入等待队列
}
goto lock; ; 5. 被唤醒后重新尝试加锁
关键点解析:
-
数据权转移:在执行 xchgb 之前,内存中存着唯一的"1"(代表进入临界区的凭证)
-
原子性体现:xchgb 指令保证了 "读取内存值" 和 "向内存写入 0" 这两步合并为一个不可分割的操作
-
抢占结果:
-
第一个线程 :执行交换后,寄存器 %al 拿到了内存里的 1 ,而内存变成了0 。由于
%al > 0,该线程成功进入临界区
-
后续线程 :执行交换后,由于内存里已经是 0 ,交换到寄存器 %al 里的也是 0。判定失败,只能挂起等待
-
2. unlock 伪代码分析
相比加锁,解锁的逻辑非常简单,它本质上是将进入临界区的 "凭证" 还回内存
bash
unlock:
movb $1, mutex ; 1. 将内存中的 mutex 重新置为 1
唤醒等待线程; ; 2. 告诉内核,可以唤醒在等待队列里的线程了
return 0;
关键点解析:
-
无需交换:解锁操作只需要将内存值置为 1 即可。因为能执行到 unlock 的线程必然是已经持有锁的线程,此时没有其他线程能进入临界区修改这个值,因此简单的赋值操作即具有逻辑上的原子性
-
唤醒机制:赋值完成后,内核会从该锁的等待队列中挑选一个线程唤醒,被唤醒的线程会重新执行上述 lock 流程中的 xchgb 指令
Mutex 的原子性并不是靠纯软件实现的,而是通过一条关键的原子交换指令将内存中的 "可用状态" 移动到线程私有的 "寄存器" 中
-
唯一性:系统中只有一个 "1",谁交换到了这个 "1",谁就获得了进入临界区的通行证
-
上下文安全:寄存器是线程私有的上下文。即使线程在加锁后被切走,它寄存器里的 "1" 也会被保存起来,其他线程依然只能交换到内存里的 "0"
-
硬件闭环:通过总线锁定等机制,硬件确保了多核 CPU 在同一时刻只有一个核能执行这条 xchgb 指令
五、RAII 封装
在并发编程中,手动管理锁的释放不仅繁琐,而且极易出错。如果临界区内发生了异常、提前 return 或执行了 goto 语句,而开发者忘记调用 unlock,就会导致锁永远无法释放,进而引发全系统的死锁
为了从根本上杜绝此类逻辑漏洞,C++ 引入了 RAII 机制
1. Mutex 类
首先,我们将 pthread_mutex_t 及其相关的初始化、加锁、解锁操作封装进一个独立的类中。这样做的好处是将资源与操作进行内聚,使代码更符合面向对象的逻辑
cpp
#include <pthread.h>
// 对原生互斥锁的简单封装
class Mutex {
public:
Mutex() {
pthread_mutex_init(&_mutex, nullptr);
}
~Mutex() {
pthread_mutex_destroy(&_mutex);
}
void lock() {
pthread_mutex_lock(&_mutex);
}
void unlock() {
pthread_mutex_unlock(&_mutex);
}
// 提供给底层的接口(如果需要结合条件变量使用)
pthread_mutex_t* get_pthread_mutex() {
return &_mutex;
}
// 禁止拷贝,防止资源管理混乱
Mutex(const Mutex&) = delete;
Mutex& operator=(const Mutex&) = delete;
private:
pthread_mutex_t _mutex;
};
2. LockGuard 类
在拥有了 Mutex 类之后,我们设计 LockGuard 类来实现真正的 RAII 保护
(1) 设计思想
LockGuard 的设计核心是 "局部对象控制生命周期"
-
在 LockGuard 对象的构造函数中执行 lock()
-
在 LockGuard 对象的析构函数中执行 unlock()。 由于 C++ 保证局部对象在离开作用域时其析构函数必然被调用,因此锁的释放动作被强制自动化了
(2) 代码实现
cpp
class LockGuard {
public:
// 构造时自动加锁
explicit LockGuard(Mutex &mutex) : _mutex(mutex) {
_mtx.lock();
}
// 析构时自动解锁
~LockGuard() {
_mtx.unlock();
}
// 严禁拷贝和赋值
LockGuard(const LockGuard&) = delete;
LockGuard& operator=(const LockGuard&) = delete;
private:
Mutex &_mutex; // 核心:持有 Mutex 的引用
};
3. 设计分析
在 LockGuard 的成员变量设计中,必须使用 Mutex &(引用),而不是直接使用变量或指针。这是基于以下严谨的工程考量:
(1) 为什么不能是变量(传值)?
-
语义错误:如果 _mutex 是一个变量,构造函数执行 _mutex(mutex) 时会发生拷贝构造。由于 Mutex 类通常禁止拷贝(因为物理上的锁是唯一的),这会导致编译失败
-
逻辑失效:即使允许拷贝,LockGuard 内部持有的也将是一个全新的、副本锁。对副本锁加锁并不能保护外部原本的临界资源
(2) 为什么是引用而不是指针?
-
生存期一致:引用在语法上是对象的别名,它强调 LockGuard 必须依附于一个已经存在的 Mutex 实体。使用引用可以确保 LockGuard 的整个生命周期内,它操作的始终是传入的那个唯一对象
-
非空保证与安全 :指针可以为 nullptr。如果使用指针,构造函数必须进行复杂的判空校验,否则在解引用时会引发崩溃。引用则强制要求在构造时必须绑定一个有效对象,从编译器层面消除了空锁操作的可能性
-
接口简洁性:使用引用后,在 LockGuard 内部调用 _mtx.lock() 的语法更加自然,符合管理对象别名的直觉
通过 Mutex 和 LockGuard 的配合,我们将原本危险、易错的底层并发控制转化为了安全、自动的对象管理:
cpp
Mutex mutex; // 全局或成员锁
void business_logic() {
// 临界区开始
LockGuard lock(mutex); // 自动加锁
// ... 执行业务逻辑 ...
// 如果这里有 return,锁依然会被安全释放
// 临界区结束,lock 销毁,自动解锁
}
这种封装模式不仅提高了代码的可读性,更重要的是它构建了一层逻辑上的安全壁垒
六、条件变量
在多线程编程中,互斥锁解决了 "多个线程抢夺共享资源" 带来的数据一致性问题。但在复杂的逻辑中,仅有互斥是不够的,因为它无法解决执行流之间的协作问题
1. 为什么需要同步
在售票系统的例子中,如果票卖完了,剩余的执行流如果不加控制,依然会频繁地执行 "加锁 -> 判定票数 -> 解锁" 的逻辑
-
资源浪费:这种高频率的轮询操作会占用大量 CPU 资源,但实际上没有做任何有效工作
-
饥饿问题:如果某个执行流获取锁的能力极强,它可能导致其他执行流长时间无法进入临界区,造成调度不公
去银行自动柜员机(ATM)取钱
-
只有互斥的情况:你(消费者)想取 1000 元,但 ATM 没钱了。由于有互斥锁(门锁),你进屋关门,发现没钱,开门出来。但你担心被别人抢先,于是刚出来又立刻刷卡进去,发现还是没钱,再出来... 如此循环
-
后果:你累得半死(消耗 CPU),后面排队存钱的人(生产者)因为你反复进出占着门,一直没机会进去存钱

我们需要一种机制,让线程在条件不满足时挂起等待 ,并在条件满足时被通知唤醒
2. 同步概念
为了解决上述问题,我们需要引入同步机制
(1) 同步
同步是在保证互斥的前提下,通过某种条件限制,让执行流按照某种特定的顺序访问临界资源。同步的目标是有序性
- 生活实例:餐厅排队取餐 厨师(生产者)做好菜放在取餐口,食客(消费者)在取餐口等待。这里不需要食客每隔一秒就冲进厨房问 "菜好没",而是食客在外面坐着等(挂起),厨师按铃(发送信号)通知对应的食客来取。这种 "按序取餐" 就是同步
(2) 竞态条件
在同步语境下,竞态条件是指:程序的结果取决于多个执行流运行的精确时序。 如果没有同步逻辑,两个线程的执行顺序可能是随机的,导致输出结果不可预测
-
生活实例:接力赛跑 假设两名运动员 A 和 B。逻辑要求:A 跑完第一棒,B 才能跑第二棒
-
没有同步:枪声一响,B 可能直接冲出去了,而 A 还在起点。结果是这场比赛违规(程序逻辑崩溃)
-
存在同步:B 必须看到 A 递过来的接力棒(条件满足)才能出发。无论 A 跑得快还是慢,B 都会等待这个确定的信号,从而消除时序上的不确定性
-

3. 条件变量接口
在 POSIX 库中,条件变量的类型为 pthread_cond_t。其核心接口如下:
初始化与销毁
-
静态初始化:
cpppthread_cond_t cond = PTHREAD_COND_INITIALIZER; -
动态初始化:
cppint pthread_cond_init(pthread_cond_t *cond, const pthread_condattr_t *attr); 参数: cond:要初始化的条件变量 attr:NULL 即可 -
销毁:
cppint pthread_cond_destroy(pthread_cond_t *cond);
等待与唤醒
-
等待:
cppint pthread_cond_wait(pthread_cond_t *cond, pthread_mutex_t *mutex); 参数: cond:要在这个条件变量上等待 mutex:互斥量 -
单发信号:
cppint pthread_cond_signal(pthread_cond_t *cond); 唤醒至少一个等待线程 -
广播信号:
cppint pthread_cond_broadcast(pthread_cond_t *cond); 唤醒所有等待线程
4. 为什么 pthread_cond_wait 需要 mutex
这是条件变量设计中最关键的细节。pthread_cond_wait 的执行逻辑实际上包含以下三个原子步骤:
-
释放锁:将传入的互斥锁原子地释放,让其他线程能够进入临界区修改条件
-
挂起等待:将当前线程放入该条件变量的等待队列中
-
重新加锁:当线程被唤醒返回时,它会自动尝试重新获取互斥锁。只有获取锁成功,wait 函数才会返回

如果不传入 Mutex 会发生什么? 如果在释放锁和进入等待之间不是原子的,可能会出现错过唤醒的情况:线程 A 判定条件不满足,准备去睡,但在它真正入睡前,线程 B 修改了条件并发送了信号。此时线程 A 随后入睡,将永远无法被这个已经发出的信号唤醒
5. 使用规范
在使用条件变量时,判定条件必须使用 while 而不是 if
cpp
pthread_mutex_lock(&mtx);
while (resource_is_empty()) { // 必须使用 while
pthread_cond_wait(&cond, &mtx);
}
// 处理资源...
pthread_mutex_unlock(&mtx);
为什么要用 while?
(1)多线程竞争资源:假设只有一个资源,signal 唤醒了两个线程(A 和 B)
-
线程 A 运气好,先抢到了 Mutex,从 wait 返回,消耗了资源,然后释放锁。
-
线程 B 终于抢到了锁,从 wait 返回
-
问题来了:如果用 if,线程 B 会直接向下执行,但此时资源已经被 A 拿走了!

解决方案:用 while 让线程 B 返回后再判断一次,发现没资源了,乖乖回去继续执行 wait (再次释放锁并入睡)
使用 while 可以保证线程在被唤醒后重新检查条件,如果条件仍不满足,则继续挂起
(2)虚假唤醒
在多线程编程中,虚假唤醒是指:一个正在 pthread_cond_wait 队列中挂起等待的线程,在没有任何其他线程调用 pthread_cond_signal 或 pthread_cond_broadcast 的情况下,或者在条件尚未满足的情况下,竟然从 wait 函数中返回了
1 为什么会发生虚假唤醒?
这并非是操作系统出了 Bug,而是基于性能优化 和系统底层设计的考量:
-
多核处理器效率 :在多核 CPU 架构下,为了保证线程唤醒的效率,系统底层的信号传递很难保证绝对的点对点精确。确保 100% 不发生虚假唤醒的成本极高,会严重拖慢整个线程库的并发性能
-
信号中断:当线程正在睡眠时,如果接收到了系统信号,某些系统实现会强制唤醒睡眠中的线程
-
惊群效应:虽然这不属于严格意义上的无中生有,但在使用 broadcast 时,所有线程被唤醒,但资源可能只有一个。对于后面抢到锁的线程来说,它们看到的景象也是 "被唤醒了但没活干",逻辑上等同于虚假唤醒
POSIX 标准明确指出:pthread_cond_wait 允许产生虚假唤醒。为了让程序具有健壮性,程序员必须在线程返回后重新检查条件
七、生产消费模型
在掌握了互斥锁与条件变量这两大工具后,我们终于可以构建多线程编程中最经典、最实用的架构------生产者消费者模型
1. 为什么需要生产消费模型
在简单的程序中,我们通常让一个函数产生数据,然后直接调用另一个函数去处理数据。这种模式被称为紧耦合。但在复杂或高并发的系统中,这种直接调用会带来显著的问题
场景举例:快递配送系统
想象如果没有快递柜或驿站:
-
直接交付:快递员(生产者)必须给收件人(消费者)打电话,并在家门口等待收件人下楼
-
等待成本:如果收件人在洗澡或开会,快递员就必须原地打转,无法去送下一份快递。反之,如果收件人急需包裹,但快递员还没到,收件人也只能在大门口干等

生产者消费者模型 通过引入一个中间媒介(如超市货架、快递柜,程序中通常是一个循环队列 或缓冲区),将产生数据和处理数据的逻辑彻底拆开。生产者只需将数据放入缓冲区,消费者只需从缓冲区取走数据,双方不再需要直接感知对方的存在
2. 生产者消费者模型的优点
该模型之所以成为工业界处理并发问题的黄金准则,是因为它完美解决了以下核心痛点:
(1) 解耦
在直接调用的模式下,生产者必须知道消费者的接口细节。一旦消费者逻辑发生变化(例如处理函数改名或参数变动),生产者的代码也必须随之修改
- 模型优势:生产者和消费者只对中间的缓冲区负责。生产者不关心数据被谁消费了,消费者也不关心数据是谁产生的。这种依赖关系的简化极大地提高了代码的可维护性

(2) 支持并发
如果生产和消费都在同一个执行流中,那么整个过程是串行的
- 模型优势 :由于生产者和消费者是独立的线程,它们可以运行在不同的 CPU 核心上。当消费者正在处理上一个任务时,生产者可以同时准备下一个任务。这种并行化显著提升了系统的整体吞吐量

(3) 解决忙闲不均问题
在现实业务中,数据的产生速度(生产)和处理速度(消费)往往是不匹配的,且具有波动性
-
模型优势:
-
当瞬间爆发大量请求(生产高峰)时,缓冲区可以起到缓冲作用,防止消费者被瞬间冲垮
-
如果消费者处理能力较弱,缓冲区允许生产者先完成工作并释放资源,而不必为了等待消费者而一直挂起
-

3. 321原则
为确保模型在多线程环境中的绝对安全性,我们提炼出了 "321原则":
-
3 种关系:
-
生产者与生产者 :互斥(防止多个生产者同时写同一块内存)
-
消费者与消费者 :互斥(防止多个消费者同时抢夺同一个数据)
-
生产者与消费者 :互斥且同步(不能同时读写;缓冲区满时生产者等待,空时消费者等待)
-
-
2 种角色:生产者(产生数据的一个或多个线程)、消费者(处理数据的一个或多个线程)。
-
1 个交易场所:一个具有存储能力的特定缓冲区
八、POSIX信号量
在互斥锁与条件变量之后,POSIX 提供了另一种强大的同步原语:信号量 。与互斥锁不同,信号量不仅能提供互斥功能,其核心特长在于对有限资源数量的管理
1. 信号量概念
信号量本质上是一个计数器,用于描述临界资源中可用资源的数量
在之前的生产者消费者模型中,我们需要先加锁,再判断资源是否就绪。而信号量允许线程在进入临界区之前,先对资源进行预订
-
P 操作:申请资源。如果信号量值大于 0,则将其减 1 并立即返回;如果值为 0,则线程挂起等待
-
V 操作:释放资源。将信号量值加 1,并唤醒正在等待该信号量的执行流
-
原子性:信号量的 P / V 操作在硬件层面保证是原子的,不会出现多个线程同时修改计数器导致的数据不一致
信号量与互斥锁的区别
-
二元信号量:初始值为 1 的信号量,逻辑上等同于互斥锁
-
多元信号量:初始值大于 1,用于管理具有多个副本的资源。互斥锁只能保证 "有或无",信号量则能描述 "有多少"
2. 信号量接口
POSIX 信号量定义在 <semaphore.h> 中,其核心接口如下:
-
初始化:
cppint sem_init(sem_t *sem, int pshared, unsigned int value); 参数: pshared: 0 表示线程间共享,非 0 表示进程间共享 value: 信号量的初始值 (可用资源数) -
销毁:
cppint sem_destroy(sem_t *sem); -
P 操作(等待):
cppint sem_wait(sem_t *sem); -
V 操作(发布):
cppint sem_post(sem_t *sem);
九、 基于信号量的生产者消费者模型
1. 逻辑简述
当我们将信号量引入生产者消费者模型时,通常结合环形缓冲区或阻塞队列来实现。信号量的引入使得我们可以通过计数的方式精准控制生产与消费
设计思路:利用信号量计数
为了管理缓冲区,我们需要两个信号量来维持同步关系:
-
sem_blank(空间信号量):代表缓冲区中剩余的可写入空间。生产者关注它
-
sem_data (数据信号量):代表缓冲区中已有的、可读取的数据条目。消费者关注它
核心逻辑流程
生产者逻辑:
-
P(sem_blank):申请一个空格。如果没有空格,生产者挂起
-
加锁:如果是多生产者环境,需要互斥锁保护写入索引
-
写入数据:将数据放入缓冲区
-
解锁
-
V(sem_data):发布一个数据信号,告诉消费者有新数据可取
消费者逻辑:
-
P(sem_data):申请一个数据。如果没有数据,消费者挂起
-
加锁:如果是多消费者环境,保护读取索引
-
读取数据:从缓冲区取出数据
-
解锁。
-
V(sem_blank):发布一个空格信号,告诉生产者有新空间可用
2. 代码实现
cpp
#include <vector>
#include <semaphore.h>
#include <pthread.h>
template <class T>
class RingQueue
{
private:
std::vector<T> _queue;
int _capacity;
int _p_step; // 生产者写入位置
int _c_step; // 消费者读取位置
sem_t _blank_sem; // 空间计数器
sem_t _data_sem; // 数据计数器
pthread_mutex_t _p_mtx; // 保护生产者竞争
pthread_mutex_t _c_mtx; // 保护消费者竞争
public:
RingQueue(int cap = 10) : _queue(cap), _capacity(cap), _p_step(0), _c_step(0)
{
sem_init(&_blank_sem, 0, _capacity); // 初始有 cap 个空格
sem_init(&_data_sem, 0, 0); // 初始有 0 个数据
pthread_mutex_init(&_p_mtx, nullptr);
pthread_mutex_init(&_c_mtx, nullptr);
}
void push(const T& in)
{
sem_wait(&_blank_sem); // 申请空格资源(P操作)
pthread_mutex_lock(&_p_mtx); // 临界区加锁
_queue[_p_step] = in;
_p_step = (_p_step + 1) % _capacity;
pthread_mutex_unlock(&_p_mtx);
sem_post(&_data_sem); // 发布数据信号(V操作)
}
void pop(T* out)
{
sem_wait(&_data_sem); // 申请数据资源(P操作)
pthread_mutex_lock(&_c_mtx); // 临界区加锁
*out = _queue[_c_step];
_c_step = (_c_step + 1) % _capacity;
pthread_mutex_unlock(&_c_mtx);
sem_post(&_blank_sem); // 发布空格信号(V操作)
}
~RingQueue()
{
sem_destroy(&_blank_sem);
sem_destroy(&_data_sem);
pthread_mutex_destroy(&_p_mtx);
pthread_mutex_destroy(&_c_mtx);
}
};
为什么信号量模式比 "互斥锁+条件变量" 更高效?
-
资源预分配:在 "互斥锁+条件变量" 模式下,线程必须先持有锁,进入临界区后才能检查条件。如果条件不满足,还需要释放锁并挂起
-
减少无效竞争 :在信号量模式下,P 操作发生在加锁操作之前。这意味着如果资源不满足,生产者线程在还没开始抢锁时就被拦截并挂起了。这有效减少了临界区周围的无效竞争,使得真正拥有执行条件的线程能够更顺畅地获取锁并执行
总结
综上所述,从共享资源、临界区、原子性,到互斥锁、条件变量以及生产者消费者模型,我们逐步理解了多线程编程中最核心的问题:多个执行流如何在共享资源的前提下安全、有序地协同工作
其中,互斥量解决的是 "同一时刻谁能访问资源" 的问题,而条件变量解决的则是 "什么时候允许访问资源" 的问题。两者相互配合,构成了现代多线程同步机制的核心基础。而通过 RAII 封装 Mutex 与 LockGuard,我们也进一步体会到了现代 C++ 在资源管理上的工程化思想
与此同时,无论是售票系统中的竞态条件,还是生产者消费者模型中的等待与唤醒,本质上都说明了一件事:
多线程编程最大的难点,从来不是并发执行,而是并发下的数据正确性
至此,我们已经基本完成了线程同步与互斥部分的学习。而在真实工程开发中,仅仅掌握线程接口与锁机制还远远不够,如何组织线程、如何管理任务、如何进行异步日志输出,同样是高并发系统设计中的重要课题
在下一篇中,我们将进一步进入工程实战,开始设计线程安全日志系统与线程池
