041 深入理解线程间同步与互斥

041 深入理解线程间同步与互斥
小米里的大麦深入理解线程间同步与互斥
1. 什么是线程互斥?
简单来说,线程互斥就是当一个线程在访问某个共享资源时,其他线程不能同时访问。就好像多个线程都想去用一台打印机,为了避免打印乱套,得保证同一时间只有一个线程能使用,这就是互斥。
线程互斥 就是为了防止多个线程 同时访问某个共享资源(如变量、文件、临界区),导致数据错误或冲突。
先来看一段代码:
1 |
|
运行结果示例(运行结果不唯一):
1 | …… |
从运行结果看,总票数 100 张,由 5 个线程并发抢票。正常情况下票数不应为负,但结果出现 -1、 -2 和 -3 ,说明存在线程安全问题。多个线程同时访问和修改票数这一共享资源时,没有进行有效的同步控制,导致数据不一致,出现超卖现象,即没有实现线程互斥来保证操作的原子性 。
2. 临界资源 VS 临界区
临界资源是指多个进程或线程共享的资源,像共享内存、文件、打印机等硬件设备,还有消息队列、变量、数组等软件资源。当多个进程或线程要访问或修改同一临界资源时,得进行同步,不然就会出现数据损坏或不一致的问题。比如上面代码中的:
1 | int tickets = 100; // 多个线程同时抢票,这个变量是“临界资源” |
为什么是“临界”的:多个线程同时读写这个变量,就会发生数据竞争,导致错误票号、重复票、甚至崩溃。
临界区是指访问临界资源的代码段,即进程或线程中访问临界资源的那部分代码。 为确保并发程序正确运行,要使用同步机制保护临界区,避免多个进程或线程同时进入,造成数据混乱,所以这个区域是 必须加锁的重点部分。比如上面代码中的:
1 | // 临界资源:tickets 是共享变量 |
所以,临界资源是 必须保护的变量/资源,临界区是 保护它的代码范围,必须用锁机制确保同一时刻只能有一个线程进入临界区,从而保护临界资源的正确性。
概念 | 说明 | 类比(洗手间排队) |
---|---|---|
临界资源 | 多个进程或线程共享的资源 | 一个洗手间(一个资源,不能同时进两人) |
临界区 | 进程或线程中访问临界资源的那部分代码 | 进入洗手间的这段操作(占用了这个资源的代码) |
- 如何确定哪些代码是临界区?(答:确定临界区得看代码访问共享资源的情况,像读写全局变量、共享数据结构的代码通常就是)
- 临界区越小越好还是越大越好?(答:大多数情况下越小越好,小了能减少竞争/锁冲突,提高并发效率,但太小可能把相关操作拆开,增加同步开销)
- STL 容器是线程安全的吗?(答:大多数不是,使用时要保护)
3. 互斥量 VS 互斥锁
互斥量(mutex)是一个数据结构或变量,代表“锁的实体”;互斥锁(mutex lock)是一个行为,表示“加锁/解锁”操作。
它们关系很紧密,在很多情况下可以看成是一回事,但严格来说也有点小区别。互斥锁是一种概念,用于确保共享资源在某一时刻只能被一个线程访问。而互斥量是实现互斥锁的一种具体技术或数据结构。可以把互斥锁想象成一个抽象的锁的概念,互斥量则是实际的那把锁。
所以,互斥量是互斥锁的一种实现方式,通过它来保证在同一时刻只有一个线程能访问共享资源,就像一把钥匙,同一时间只能有一个线程拿着它去打开资源的 “门”。
可以简单理解成:
名称 | 类比 | 含义 |
---|---|---|
互斥量 | “门锁本身” | 是一个数据结构/变量,用于控制访问 |
互斥锁 | “上锁/解锁动作” | 是对互斥量的加锁/解锁行为 |
我们通过“加锁”来 互斥访问,做到“一个线程进来了,其他线程就得等”。把多个线程比作“人”,共享资源比作“厕所”,互斥锁就是“门锁”:
- 没加锁:几个人同时冲进厕所,尴尬出问题
- 加了锁:进一个人锁门,别人必须等他出来再进
- 正确加锁解锁 = 文明如厕
4. 原子操作的底层原理
CPU 眼里的:Atomic | 原子操作 | B 站(荐)
C/C++原子操作与 atomic CAS 底层实现原理 | 阿里云 原子操作 . 原理与底层实现 | CSDN 原子操作解释:从硬件到高级代码(使用 Go) | sahaj 操作系统中的原子操作 | geeksforgeeks
1. 什么是原子操作?
原子操作 是指不可分割、不可中断的操作 —— 要么完全执行,要么完全不执行,不存在中间状态。
核心特征:
- 不可分割性:操作过程无法拆分
- 不可中断性:执行中不会被线程调度打断
- 结果可见性:操作完成后,所有线程立即看到最新结果
2. 为什么普通操作不是原子的?
以 tickets--
为例,看似简单的一行代码,实则分解为三步:
- Load:从内存读取
tickets
到 CPU 寄存器。 - Update:寄存器中执行减 1 运算。
- Store:将结果写回内存。
对应三条汇编指令:
1 | movl tickets(%rip), %eax ; 加载 |
多线程环境下,线程可能在这三步之间被切换,导致数据混乱。比如:
- 线程 1 加载
tickets=1000
后被挂起。 - 线程 2 连续减 100 次,使
tickets=900
。 - 线程 1 恢复后,基于旧值 1000 继续操作,最终写回 999。
- 结果:实际多减了 100 张票,数据严重不一致。
3. 原子操作的底层实现(硬件 + 汇编)
原子操作的核心是 硬件支持的单条汇编指令,配合 CPU 机制保证不可分割性:
特殊原子指令
CPU 提供原子指令(如
xchg
交换指令),单条指令完成 “读取 - 修改 - 写回” 全流程,例如:1
lock dec dword ptr [tickets] ; 原子自减指令
硬件级保障
总线锁定:执行原子指令时,CPU 锁定系统总线,阻止其他核心访问内存。
缓存一致性:通过 MESI 协议,在多核环境下保证数据同步。
指令不可中断:单条汇编指令执行期间,不会被任何调度机制打断。
[!IMPORTANT]
所以:原子操作的核心是由硬件支持的单条汇编指令,配合总线或缓存锁定机制,确保 “读取 - 修改 - 写回” 全流程不可分割、不被中断,这正是解决多线程数据竞争的底层方案 —— 因为多线程数据竞争的根源在于操作的非原子性,比如高级语言中看似简单的
++
/--
语句,实际会被拆分为多条汇编指令,执行过程中可能因线程切换导致数据混乱;而互斥锁等同步机制底层依赖原子指令实现,高级语言的原子类则是对这些底层机制的封装。
5. 函数调用
1. pthread_mutex_init
—— 初始化
1. 功能
初始化一个静态或动态定义的互斥量(pthread_mutex_t
),使其可以用于后续加锁和解锁操作。
2. 函数原型
1 |
|
3. 参数详解
pthread_mutex_t* mutex
: 指向互斥量的指针(锁实体)。const pthread_mutexattr_t* attr
:NULL/nullptr
:使用默认属性。- 非 NULL:指向
pthread_mutexattr_t
结构,用于设置自定义属性。
初始化方式 宏/函数名 使用场景 是否传入属性 静态初始化 PTHREAD_MUTEX_INITIALIZER
定义互斥量时立即初始化(常量宏) 不支持 动态初始化 pthread_mutex_init()
运行时初始化,可定制属性 支持 方式一:动态初始化(运行时)
1
2 >pthread_mutex_t mutex;
>pthread_mutex_init(&mutex, NULL); // NULL 表示默认属性方式二:静态初始化(编译时)
1
2
3
4
5
6
7
8
9
10
11
12
13
14 >// 全局互斥锁 - 提前装好
>pthread_mutex_t ticket_mutex = PTHREAD_MUTEX_INITIALIZER; // 仅适用于默认属性
>int tickets = 1000;
>void* sell_ticket(void* arg)
>{
pthread_mutex_lock(&ticket_mutex);
if (tickets > 0)
{
tickets--;
}
pthread_mutex_unlock(&ticket_mutex);
return nullptr;
>}注意:不能对已初始化的互斥锁再次调用
pthread_mutex_init
,否则会导致未定义行为。 这就像:已经装好了一把锁,现在又强行往同一个锁孔里塞第二把锁 —— 结果可能是锁坏了,门打不开了!
1
2
3 >pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
>// ❌ 错误:不能对已初始化的锁再次 init
>pthread_mutex_init(&mutex, NULL); // 未定义行为!
特性 动态初始化 静态初始化 初始化时机 运行时 编译时 语法 pthread_mutex_init()
= PTHREAD_MUTEX_INITIALIZER
是否需要销毁 必须 pthread_mutex_destroy
不需要(但也可以 destroy) 适用变量 任何(包括局部、动态分配) 全局、静态 灵活性 高(可设置属性) 低(只能默认属性) 安全性 需小心避免重复 init 高(不会重复初始化)
4. 返回值
- 0: 初始化成功。
- 非 0: 初始化失败(返回错误码)。
5. 代码示例
1 |
|
2. pthread_mutex_destroy
—— 删除/销毁
1. 功能
销毁互斥量,释放其占用的资源。注意:只能在没有线程占用锁时销毁!
2. 函数原型
1 |
|
3. 参数详解
mutex
:要销毁的互斥量指针。
4. 返回值
- 0: 销毁成功。
- EBUSY: 互斥量当前被加锁,不能销毁。
- EINVAL: 互斥量未初始化或非法。
3. pthread_mutex_lock
—— 加锁
1. 功能
对互斥量加锁,如果锁已被其他线程持有,则阻塞等待,直到锁可用。
2. 函数原型
1 |
|
3. 参数详解
mutex
:要加锁的互斥量指针。
4. 返回值
- 0: 加锁成功。
- EINVAL: 互斥量未初始化或非法。
- EDEADLK: 当前线程已持有此锁(非递归锁会导致死锁)。
4. pthread_mutex_unlock
—— 解锁
1. 功能
对互斥量解锁,释放资源,允许其他线程进入临界区。
2. 函数原型
1 |
|
3. 参数详解
mutex
:要解锁的互斥量指针。
4. 返回值
- 0: 解锁成功。
- EPERM: 当前线程并未持有该锁,无法解锁。
5. 小结
函数名 | 作用 | 是否阻塞 | 常用配合 |
---|---|---|---|
pthread_mutex_init |
初始化互斥量 | 否 | 通常在主线程中做一次 |
pthread_mutex_destroy |
销毁互斥量 | 否 | 在线程退出或程序结束时销毁 |
pthread_mutex_lock |
加锁(进入临界区) | 是 | 如果已加锁,当前线程会阻塞等待 |
pthread_mutex_unlock |
解锁(退出临界区) | 否 | 必须由加锁线程来解锁 |
6. 代码示例
makefile 的小知识:
1. 静态互斥锁
1 |
|
2. 动态互斥锁
1 |
|
[!NOTE]
这两个代码的数据量比较大是为了更好的观察多线程并发抢票的效果,如果数据量小,那么大概率只能观察到:
1
2
3
4
5
6
7 线程0 获得 1 张票,剩余 10000 张票
线程0 获得 1 张票,剩余 9999 张票
线程0 获得 1 张票,剩余 9998 张票
线程0 获得 1 张票,剩余 9997 张票
线程0 获得 1 张票,剩余 9996 张票
线程0 获得 1 张票,剩余 9995 张票
……发现好像只有一个线程在工作,这个的主要的原因是 CPU 太快,导致还没执行到下一个线程就把代码跑完了,但是由于数据量太大,打印数据太多,shell 中和这里又不方便展示,可以重定向到一个文件中进行观察,下面是运行结果的部分展示:
6. 深入理解锁
- 锁是为了保证临界区串行化执行,解决数据竞争问题。
- 核心:用原子指令修改“锁变量”来控制资源的独占访问。
- 加锁与解锁要成对、尽量小范围、尽快完成,避免性能问题和死锁。
1. 锁的本质与核心原则
1. 本质:用时间换安全
- 多线程访问共享资源时,会出现 数据竞争。
- 锁通过 让线程串行化 访问临界区代码,避免数据不一致。
- 换句话说:牺牲部分性能(等待)换取正确性。
2. 原则:越小越好(大多数情况) + 原子性
- 尽量减少临界区范围
- 临界区代码执行越多,锁持有时间越长,线程阻塞越多,性能越差。
- 应该只保护 真正会引发数据竞争的代码,不要多写一行。
- 加锁/解锁必须是原子操作
- 锁本身的操作必须不可被打断,否则会导致多个线程同时认为自己获取了锁 → 失效。
比喻:多个线程想上厕所(共享资源),锁就是门锁:
- 门锁加锁和开锁的动作必须瞬间完成,不能“半锁半开”。
- 厕所里的时间越短,大家等的时间越少。
2. 互斥锁的特性与规则
1. 特性
- 原子性:锁的获取和释放操作本身是原子的,保证“要么成功获取,要么失败重试”,不会出现“多个线程同时获得锁”。
- 独占性(互斥性):同一时刻,最多一个线程能持有锁。其他线程必须等待。
- 非重入性(默认特性):
- 一个线程如果已经加锁,再次调用
pthread_mutex_lock
会造成 死锁,因为自己在等待自己释放。 - 如果需要支持重入,必须使用 递归锁(
PTHREAD_MUTEX_RECURSIVE
)。
- 一个线程如果已经加锁,再次调用
2. 使用规则
- 外部线程必须排队等待:没抢到锁的线程,进入阻塞或自旋,等待持有锁的线程释放。
- 释放锁要公平(排队)
- 等待队列中的线程应有公平性,避免某个线程永远抢不到锁。
- 部分实现还要考虑 优先级反转 问题(低优先级线程持锁,阻塞高优先级线程)。
- 加锁与解锁要成对出现
- 忘记解锁 → 死锁;
- 多次解锁 → 未定义行为。
3. 常见锁的概念
1. 死锁的定义
死锁是指两个或多个线程(或进程)因 互相持有资源并等待对方释放,导致程序无法继续执行的情况(僵持状态)。这就好比两个人过独木桥,面对面走到中间,谁都不肯退回去,结果谁也走不了啦。
单进程场景也可能发生(如频繁申请单锁但不释放)。
多线程中典型表现为:线程 A 持有锁 1 申请锁 2,线程 B 持有锁 2 申请锁 1,形成循环等待。
特殊场景:单进程/单线程如果频繁申请资源而不释放,也可能出现类似死锁的“永久阻塞”现象。
2. 死锁四个必要条件
产生死锁 必须同时满足 以下四个条件,但满足全部条件 不一定必然发生死锁(需具体场景触发)。破坏任意一个条件即可避免死锁。
注意:四个条件是死锁的 充分必要条件——缺一不可导致死锁,但满足全部仅表示“可能”死锁(需资源竞争实际发生)。
条件 | 含义 | 说明 |
---|---|---|
互斥条件 | 一个资源每次只能被一个执行流使用。 | 资源本质属性(如锁、I/O 设备),无法完全消除,但可通过设计减少互斥场景。 |
请求与保持条件 | 线程在申请新资源时,不释放已经占有的资源。 | 例如:线程持有锁 A 后申请锁 B 失败,仍不释放锁 A。 |
不剥夺条件 | 资源一旦被占用,不能被强制剥夺,只能主动释放。 | 操作系统通常不支持强制剥夺资源(避免数据损坏)。 |
循环等待条件 | 若干执行流形成头尾相接的循环等待资源链(如 A→B→C→A)。 | 是前三个条件的综合结果,最易通过设计规避。 |
3. 避免死锁的策略
核心思路:破坏上述四个必要条件之一,或通过设计优化资源分配。
策略方向 | 具体方法 | 说明 |
---|---|---|
破坏互斥条件 | 重写代码,尽量减少锁的使用或使用无锁数据结构 | 实现难度高,代价较大,代码改动多,仅适用于特定场景(如只读资源)。 |
破坏请求与保持条件 | 使用 trylock 等非阻塞加锁机制,获取失败时释放已占有的锁。 |
避免“持有并等待”,需配合重试逻辑,可能增加代码复杂度。 |
破坏不剥夺条件 | 在申请失败时主动释放已有锁,稍后重新申请,或设计可抢占式资源管理。 | 需谨慎处理,避免资源状态不一致。 |
破坏循环等待条件 | 加锁顺序一致:所有线程按固定顺序申请锁,如全局定义锁 ID,从小到大,避免混乱。 资源一次性分配:线程启动时申请所有所需资源,避免分步申请。 尽快释放锁:尽快释放锁,减少竞争时间/窗口。 |
最常用且有效,尤其“加锁顺序一致”是工程实践首选。 |
4. 避免死锁的算法(了解即可)
- 死锁检测算法:定期检查系统里有没有死锁。它通过资源分配图这些方式,看进程和资源之间的关系,要是有循环等待且资源都被占着,那就可能死锁了。
- 银行家算法:像银行放贷,查放贷后是否安全,安全则贷,否则不贷,避免死锁。
说明:算法适用于理论场景,实际开发中常通过设计规避,而非依赖复杂算法。
4. 同步
1. 同步的定义与必要性
同步定义:在 保证数据安全 的前提下,协调多线程 按特定顺序访问共享资源,确保数据安全和执行逻辑正确,避免竞争条件。与死锁的关系:同步是解决资源竞争的手段,而死锁是同步使用不当的结果之一。
单纯依赖 互斥锁 可能导致问题:
线程饥饿:强线程频繁抢锁,弱线程可能长期无法执行。
死锁风险:若锁申请顺序混乱或未释放,易触发死锁条件(尤其请求与保持、循环等待)。
同步是避免死锁的关键手段:通过有序排队机制,消除“请求与保持”和“循环等待”条件。
2. 同步的核心机制:条件变量
1. 什么是条件变量?
条件变量 是一种 等待队列 + 通知机制,用来协调线程按顺序执行,当资源不可用时,线程可进入条件变量队列等待(主动排队等待),被其他线程唤醒后再去竞争锁。
- 核心功能:提供 等待队列 和 通知机制,使线程在资源不可用时 主动排队等待,而非忙等或阻塞。
- 工作流程:
- 线程尝试访问资源 → 失败 → 进入条件变量等待队列(自动释放锁)。
- 资源可用时(如其他线程释放资源),唤醒队列中的一个/所有线程 → 唤醒线程重新申请锁。
- 关键特性:
- 必须依赖锁使用:线程排队前需先获得锁,排队时锁自动释放;唤醒后需重新竞争锁。
- 避免“虚假唤醒”:线程被唤醒后需 重新检查资源状态(在加锁后),防止误判。
- 注意: 当线程调用
pthread_cond_wait
进入等待队列时,会自动释放互斥锁,唤醒后自动重新加锁。
2. 标准接口(以 POSIX 为例)
下面这些接口和 pthread_mutex_init
系列函数十分相似,共用头文件 <pthread.h>
,这里就不详细讲解了,类比使用即可。
接口 | 作用 | 使用场景 | 返回值 |
---|---|---|---|
pthread_cond_t |
定义条件变量。 | 全局声明条件变量。 | 类型定义,无返回值 |
pthread_cond_init(&cond, &attr) |
动态初始化条件变量(可指定属性)。 | 局部变量或需自定义属性(如跨进程共享)。 | 成功返回 0;失败返回错误码。 |
pthread_cond_wait(&cond, &mutex) |
线程进入等待队列,自动释放 mutex 并挂起。 | 资源不可用时调用(需在锁保护内)。 | 成功返回 0;失败返回错误码。 |
pthread_cond_signal(&cond) |
唤醒等待队列中的 一个 线程。 | 资源可用时通知(如生产者-消费者模型)。 | 成功返回 0;失败返回错误码。 |
pthread_cond_broadcast(&cond) |
唤醒等待队列中的 所有 线程。 | 多线程需同时响应资源变化时使用。 | 成功返回 0;失败返回错误码。 |
pthread_cond_wait
的第二个参数 &mutex 说明:需先通过pthread_mutex_lock
获得该互斥锁;线程进入等待时自动释放锁,允许其他线程操作资源;被唤醒后自动重新获取锁,确保后续操作安全。PTHREAD_COND_INITIALIZER
:这是条件变量的 静态初始化宏,用于在定义时直接初始化条件变量,简化代码:1
pthread_cond_t cond = PTHREAD_COND_INITIALIZER; // 静态初始化(无需手动调用 pthread_cond_init)
等价于动态初始化:
1
2pthread_cond_t cond;
pthread_cond_init(&cond, NULL); // 动态初始化,需手动销毁示例片段:
1
2
3
4
5
6
7
8pthread_cond_t cond = PTHREAD_COND_INITIALIZER; // 静态初始化
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
// 线程等待条件
pthread_mutex_lock(&mutex); // 先加锁
pthread_cond_wait(&cond, &mutex); // 等待时释放锁,唤醒后重获锁
// 被唤醒后操作共享资源...
pthread_mutex_unlock(&mutex); // 最终解锁
3. 代码实现关键注意事项
- 参数传递:创建线程时 优先使用值传递(拷贝),避免多线程共享同一变量导致数据竞争。
- 共享资源保护:例如:多线程打印到显示器(显示器也是文件,属于共享资源),需加锁保护,否则输出内容会交叉混乱。
- 状态检查逻辑:判断线程是否等待或唤醒必须放在 加锁解锁区间内,保证资源状态判断与操作的原子性。
3. 为什么需要排队?
- 根本原因:单纯互斥锁无法解决“线程申请能力不均衡”问题。
- 排队机制的作用:
- 防止新线程插队:确保等待线程按序获取资源,避免强线程持续抢占。
- 提升公平性:通过队列管理,减少线程饥饿,间接破坏“请求与保持”条件。
- 保证数据安全:排队过程由操作系统协调,避免用户代码手动管理导致的竞态。
4. 小结
- 死锁是结果,同步是手段。同步用得不好会死锁,合理使用同步才能保证线程安全。
- 避免死锁的方法:
- 加锁顺序统一。
- 锁尽快释放。
- 使用
trylock
、资源一次性分配等方式减少锁依赖。
- 条件变量是解决线程公平性和协调顺序的利器,必须和互斥锁配合使用。
5. 代码示例
1 |
|
注:上面代码模拟的是一个“多个工作线程等待任务信号,由主线程定时唤醒执行”的场景,用来演示条件变量如何控制线程的等待与唤醒、线程同步、临界资源保护、条件变量解耦协作等内容。