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

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

1. 什么是线程互斥?

简单来说,线程互斥就是当一个线程在访问某个共享资源时,其他线程不能同时访问。就好像多个线程都想去用一台打印机,为了避免打印乱套,得保证同一时间只有一个线程能使用,这就是互斥。

线程互斥 就是为了防止多个线程 同时访问某个共享资源(如变量、文件、临界区),导致数据错误或冲突

先来看一段代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
#include <iostream>
#include <stdio.h>
#include <pthread.h>
#include <vector>
#include <string>
#include <unistd.h>
using namespace std;

#define NUM 5 // 定义线程数量
int tickets = 100; // 全局共享变量:票数(多个线程将同时访问这个变量,临界资源)

class thread_data
{
public:
thread_data(int number) // 构造函数:根据线程编号初始化线程名称
{
thread_name = "线程" + to_string(number);
}
public:
string thread_name; // 线程名称
};

void* getticket(void* arg)
{
thread_data* data = static_cast<thread_data*>(arg); // 将传入的参数转换为 threadData 类型指针
const char* name = data->thread_name.c_str(); // 获取线程名称

while(true) // 无限循环买票,竞态条件发生在这里!
{
if(tickets > 0) // 票数大于 0 时,买票
{
usleep(1000); // 模拟票务系统延时

printf("%s 获得 1 张票,剩余 %d 张票\n", name, tickets); // 打印票数信息
tickets--; // 票数减 1
}
else // 票数不足时,跳出循环
{
break;
}
}

cout << "线程" << name << "执行结束!" << endl;
return nullptr;
}

int main()
{
vector<pthread_t> tids; // 存储线程 ID 的向量容器
vector<thread_data*> thread_datas; // 存储线程数据的向量容器

cout << "多线程买票演示程序" << endl;
cout << "总票数: " << tickets << " 张" << endl;
cout << "创建 " << NUM << " 个线程同时买票..." << endl << endl;

for(int i = 0; i < NUM; i++)
{
pthread_t tid; // 线程 ID 变量
thread_data* data = new thread_data(i); // 为每个线程创建独立的数据对象
thread_datas.push_back(data); // 保存数据对象指针

pthread_create(&tid, nullptr, getticket, data); // 创建线程,传入参数为线程数据对象指针
tids.push_back(tid); // 保存线程 ID
}

cout << "所有线程创建完成,开始并发买票..." << endl;
sleep(3); // 等待 3 秒,让主进程等待子进程结束

for(auto data : thread_datas)
{
delete data; // 释放每个线程的数据对象
}

return 0;
}

运行结果示例(运行结果不唯一):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
……
线程1 获得 1 张票,剩余 9 张票
线程2 获得 1 张票,剩余 8 张票
线程3 获得 1 张票,剩余 7 张票
线程4 获得 1 张票,剩余 6 张票
线程0 获得 1 张票,剩余 5 张票
线程2 获得 1 张票,剩余 4 张票
线程3 获得 1 张票,剩余 3 张票
线程1 获得 1 张票,剩余 2 张票
线程4 获得 1 张票,剩余 1 张票
线程线程4执行结束!
线程0 获得 1 张票,剩余 0 张票
线程线程0执行结束!
线程3 获得 1 张票,剩余 -1 张票
线程线程3执行结束!
线程2 获得 1 张票,剩余 -2 张票
线程线程2执行结束!
线程1 获得 1 张票,剩余 -3 张票
线程线程1执行结束!
[hcc@hcss-ecs-be68 Synchronization and Mutex]$

从运行结果看,总票数 100 张,由 5 个线程并发抢票。正常情况下票数不应为负,但结果出现 -1、 -2 和 -3 ,说明存在线程安全问题。多个线程同时访问和修改票数这一共享资源时,没有进行有效的同步控制,导致数据不一致,出现超卖现象,即没有实现线程互斥来保证操作的原子性 。


2. 临界资源 VS 临界区

临界资源是指多个进程或线程共享的资源,像共享内存、文件、打印机等硬件设备,还有消息队列、变量、数组等软件资源。当多个进程或线程要访问或修改同一临界资源时,得进行同步,不然就会出现数据损坏或不一致的问题。比如上面代码中的:

1
int tickets = 100; // 多个线程同时抢票,这个变量是“临界资源”

为什么是“临界”的:多个线程同时读写这个变量,就会发生数据竞争,导致错误票号、重复票、甚至崩溃。

临界区是指访问临界资源的代码段,即进程或线程中访问临界资源的那部分代码。 为确保并发程序正确运行,要使用同步机制保护临界区,避免多个进程或线程同时进入,造成数据混乱,所以这个区域是 必须加锁的重点部分。比如上面代码中的:

1
2
3
4
5
6
7
8
9
10
// 临界资源:tickets 是共享变量
if(tickets > 0) // 票数大于 0 时,买票
{
usleep(1000); // 模拟票务系统延时

printf("%s 获得 1 张票,剩余 %d 张票\n", name, tickets); // 打印票数信息
tickets--; // 票数减 1
}
// 上面这段就是临界区(但没有保护它)
// 这种写法在高并发下肯定会乱套,因为多个线程可以 同时进入临界区,造成冲突。

所以,临界资源是 必须保护的变量/资源,临界区是 保护它的代码范围,必须用锁机制确保同一时刻只能有一个线程进入临界区,从而保护临界资源的正确性。

概念 说明 类比(洗手间排队)
临界资源 多个进程或线程共享的资源 一个洗手间(一个资源,不能同时进两人)
临界区 进程或线程中访问临界资源的那部分代码 进入洗手间的这段操作(占用了这个资源的代码)
  • 如何确定哪些代码是临界区?(答:确定临界区得看代码访问共享资源的情况,像读写全局变量、共享数据结构的代码通常就是)
  • 临界区越小越好还是越大越好?(答:大多数情况下越小越好,小了能减少竞争/锁冲突,提高并发效率,但太小可能把相关操作拆开,增加同步开销)
  • STL 容器是线程安全的吗?(答:大多数不是,使用时要保护)

3. 互斥量 VS 互斥锁

互斥量(mutex)是一个数据结构或变量,代表“锁的实体”;互斥锁(mutex lock)是一个行为,表示“加锁/解锁”操作。

它们关系很紧密,在很多情况下可以看成是一回事,但严格来说也有点小区别。互斥锁是一种概念,用于确保共享资源在某一时刻只能被一个线程访问。而互斥量是实现互斥锁的一种具体技术或数据结构。可以把互斥锁想象成一个抽象的锁的概念,互斥量则是实际的那把锁。

所以,互斥量是互斥锁的一种实现方式,通过它来保证在同一时刻只有一个线程能访问共享资源,就像一把钥匙,同一时间只能有一个线程拿着它去打开资源的 “门”。

可以简单理解成:

名称 类比 含义
互斥量 “门锁本身” 是一个数据结构/变量,用于控制访问
互斥锁 “上锁/解锁动作” 是对互斥量的加锁/解锁行为

我们通过“加锁”来 互斥访问,做到“一个线程进来了,其他线程就得等”。把多个线程比作“人”,共享资源比作“厕所”,互斥锁就是“门锁”:

  • 没加锁:几个人同时冲进厕所,尴尬出问题
  • 加了锁:进一个人锁门,别人必须等他出来再进
  • 正确加锁解锁 = 文明如厕

4. 原子操作的底层原理

(10min 理解)锁、原子操作和 CAS | B 站

CPU 眼里的:Atomic | 原子操作 | B 站(荐)

Linux 线程安全 | CSDN

C/C++原子操作与 atomic CAS 底层实现原理 | 阿里云 原子操作 . 原理与底层实现 | CSDN 原子操作解释:从硬件到高级代码(使用 Go) | sahaj 操作系统中的原子操作 | geeksforgeeks

1. 什么是原子操作?

原子操作 是指不可分割、不可中断的操作 —— 要么完全执行,要么完全不执行,不存在中间状态。

核心特征:

  • 不可分割性:操作过程无法拆分
  • 不可中断性:执行中不会被线程调度打断
  • 结果可见性:操作完成后,所有线程立即看到最新结果

2. 为什么普通操作不是原子的?

tickets-- 为例,看似简单的一行代码,实则分解为三步:

  1. Load:从内存读取 tickets 到 CPU 寄存器。
  2. Update:寄存器中执行减 1 运算。
  3. Store:将结果写回内存。

对应三条汇编指令:

1
2
3
movl tickets(%rip), %eax  ; 加载
subl $1, %eax ; 更新
movl %eax, tickets(%rip) ; 存储

多线程环境下,线程可能在这三步之间被切换,导致数据混乱。比如:

  • 线程 1 加载 tickets=1000 后被挂起。
  • 线程 2 连续减 100 次,使 tickets=900
  • 线程 1 恢复后,基于旧值 1000 继续操作,最终写回 999。
  • 结果:实际多减了 100 张票,数据严重不一致。

3. 原子操作的底层实现(硬件 + 汇编)

原子操作的核心是 硬件支持的单条汇编指令,配合 CPU 机制保证不可分割性:

  1. 特殊原子指令

    CPU 提供原子指令(如 xchg 交换指令),单条指令完成 “读取 - 修改 - 写回” 全流程,例如:

    1
    lock dec dword ptr [tickets]  ; 原子自减指令
  2. 硬件级保障

    • 总线锁定:执行原子指令时,CPU 锁定系统总线,阻止其他核心访问内存。

    • 缓存一致性:通过 MESI 协议,在多核环境下保证数据同步。

    • 指令不可中断:单条汇编指令执行期间,不会被任何调度机制打断。

[!IMPORTANT]

所以:原子操作的核心是由硬件支持的单条汇编指令,配合总线或缓存锁定机制,确保 “读取 - 修改 - 写回” 全流程不可分割、不被中断,这正是解决多线程数据竞争的底层方案 —— 因为多线程数据竞争的根源在于操作的非原子性,比如高级语言中看似简单的++/--语句,实际会被拆分为多条汇编指令,执行过程中可能因线程切换导致数据混乱;而互斥锁等同步机制底层依赖原子指令实现,高级语言的原子类则是对这些底层机制的封装。


5. 函数调用

1. pthread_mutex_init —— 初始化

1. 功能

初始化一个静态或动态定义的互斥量pthread_mutex_t),使其可以用于后续加锁和解锁操作。

2. 函数原型

1
2
#include <pthread.h>
int pthread_mutex_init(pthread_mutex_t* mutex, const pthread_mutexattr_t* attr);

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
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <pthread.h>
#include <cstdio>

int main()
{
pthread_mutex_t lock; // 声明互斥量

if (pthread_mutex_init(&lock, nullptr) != 0) // 初始化互斥量
{
printf("mutex init failed\n");
return 1;
}

pthread_mutex_destroy(&lock); // 使用完后销毁互斥量
return 0;
}

2. pthread_mutex_destroy —— 删除/销毁

1. 功能

销毁互斥量,释放其占用的资源。注意:只能在没有线程占用锁时销毁!

2. 函数原型

1
2
#include <pthread.h>
int pthread_mutex_destroy(pthread_mutex_t* mutex);

3. 参数详解

mutex:要销毁的互斥量指针。

4. 返回值

  • 0: 销毁成功。
  • EBUSY: 互斥量当前被加锁,不能销毁。
  • EINVAL: 互斥量未初始化或非法。

3. pthread_mutex_lock —— 加锁

1. 功能

对互斥量加锁,如果锁已被其他线程持有,则阻塞等待,直到锁可用。

2. 函数原型

1
2
#include <pthread.h>
int pthread_mutex_lock(pthread_mutex_t* mutex);

3. 参数详解

mutex:要加锁的互斥量指针。

4. 返回值

  • 0: 加锁成功。
  • EINVAL: 互斥量未初始化或非法。
  • EDEADLK: 当前线程已持有此锁(非递归锁会导致死锁)。

4. pthread_mutex_unlock —— 解锁

1. 功能

对互斥量解锁,释放资源,允许其他线程进入临界区。

2. 函数原型

1
2
#include <pthread.h>
int pthread_mutex_unlock(pthread_mutex_t* mutex);

3. 参数详解

mutex:要解锁的互斥量指针。

4. 返回值

  • 0: 解锁成功。
  • EPERM: 当前线程并未持有该锁,无法解锁。

5. 小结

函数名 作用 是否阻塞 常用配合
pthread_mutex_init 初始化互斥量 通常在主线程中做一次
pthread_mutex_destroy 销毁互斥量 在线程退出或程序结束时销毁
pthread_mutex_lock 加锁(进入临界区) 如果已加锁,当前线程会阻塞等待
pthread_mutex_unlock 解锁(退出临界区) 必须由加锁线程来解锁

6. 代码示例

makefile 的小知识:

PixPin_2025-07-28_23-19-15

1. 静态互斥锁

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
#include <iostream>
#include <stdio.h>
#include <pthread.h>
#include <vector>
#include <string>
#include <unistd.h>
using namespace std;

#define NUM 5 // 定义线程数量
int tickets = 10000; // 全局共享变量:票数(多个线程将同时访问这个变量)
pthread_mutex_t ticket_mutex = PTHREAD_MUTEX_INITIALIZER; // 互斥锁(全局)

class thread_data
{
public:
thread_data(int number) // 构造函数:根据线程编号初始化线程名称
{
thread_name = "线程" + to_string(number);
}
public:
string thread_name; // 线程名称
};

void* getticket(void* arg)
{
thread_data* data = static_cast<thread_data*>(arg); // 将传入的参数转换为 threadData 类型指针
const char* name = data->thread_name.c_str(); // 获取线程名称

while(true) // 无限循环买票,竞态条件发生在这里!
{
pthread_mutex_lock(&ticket_mutex); // 加锁,进入临界区

if(tickets > 0) // 票数大于 0 时,买票
{
usleep(1000); // 模拟票务系统延时

printf("%s 获得 1 张票,剩余 %d 张票\n", name, tickets); // 打印票数信息
tickets--; // 票数减 1
}
else // 票数不足时,跳出循环
{
pthread_mutex_unlock(&ticket_mutex); // 解锁,防止死锁
break;
}

pthread_mutex_unlock(&ticket_mutex); // 解锁,退出临界区
}

cout << data->thread_name << "执行结束!" << endl;
return nullptr;
}

int main()
{
vector<pthread_t> tids; // 存储线程 ID 的向量容器
vector<thread_data*> thread_datas; // 存储线程数据的向量容器

cout << "多线程买票演示程序" << endl;
cout << "总票数: " << tickets << " 张" << endl;
cout << "创建 " << NUM << " 个线程同时买票..." << endl << endl;

for(int i = 0; i < NUM; i++)
{
pthread_t tid; // 线程 ID 变量
thread_data* data = new thread_data(i); // 为每个线程创建独立的数据对象
thread_datas.push_back(data); // 保存数据对象指针

pthread_create(&tid, nullptr, getticket, data); // 创建线程,传入参数为线程数据对象指针
tids.push_back(tid); // 保存线程 ID
}

cout << "所有线程创建完成,开始并发买票..." << endl;

for (int i = 0; i < NUM; ++i)
{
pthread_join(tids[i], nullptr); // 等待第 i 个线程执行完毕
}

for(auto data : thread_datas)
{
delete data; // 释放每个线程的数据对象
}

return 0;
}

2. 动态互斥锁

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
#include <iostream>
#include <stdio.h>
#include <pthread.h>
#include <vector>
#include <string>
#include <unistd.h>
using namespace std;

#define NUM 5 // 定义线程数量
int tickets = 10000; // 全局共享变量:票数
pthread_mutex_t ticket_mutex; // 动态互斥锁(全局)

class thread_data
{
public:
thread_data(int number) // 构造函数
{
thread_name = "线程" + to_string(number);
}
public:
string thread_name; // 线程名称
};

void* getticket(void* arg)
{
thread_data* data = static_cast<thread_data*>(arg);
const char* name = data->thread_name.c_str();

while(true)
{
pthread_mutex_lock(&ticket_mutex); // 加锁

if(tickets > 0)
{
usleep(1000); // 模拟延时

// 为了减少输出量,只显示关键信息
if(tickets % 1000 == 0 || tickets <= 10) // 每 1000 张票或最后 10 张票时显示
{
printf("%s 获得 1 张票,剩余 %d 张票\n", name, tickets);
}

tickets--;
}
else
{
pthread_mutex_unlock(&ticket_mutex); // 解锁防止死锁
break;
}

pthread_mutex_unlock(&ticket_mutex); // 解锁
}

cout << data->thread_name << "执行结束!" << endl;
return nullptr;
}

int main()
{
// 初始化动态互斥锁(不使用属性)
if(pthread_mutex_init(&ticket_mutex, nullptr) != 0)
{
cerr << "互斥锁初始化失败!" << endl;
return -1;
}

vector<pthread_t> tids;
vector<thread_data*> thread_datas;

cout << "多线程买票演示程序" << endl;
cout << "总票数: " << tickets << " 张" << endl;
cout << "创建 " << NUM << " 个线程同时买票..." << endl << endl;

for(int i = 0; i < NUM; i++)
{
pthread_t tid;
thread_data* data = new thread_data(i);
thread_datas.push_back(data);

if(pthread_create(&tid, nullptr, getticket, data) != 0)
{
cerr << "线程创建失败:" << i << endl;
delete data;
continue;
}

tids.push_back(tid);
}

cout << "所有线程创建完成,开始并发买票..." << endl;

// 等待所有线程执行完毕
for (size_t i = 0; i < tids.size(); ++i)
{
if(pthread_join(tids[i], nullptr) != 0)
{
cerr << "等待线程 " << i << " 失败!" << endl;
}
}

cout << "\n最终剩余票数: " << tickets << " 张" << endl;
cout << "买票结束!" << endl;

// 释放线程数据内存
for(auto data : thread_datas)
{
delete data;
}

// 销毁互斥锁
pthread_mutex_destroy(&ticket_mutex);

return 0;
}

[!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 中和这里又不方便展示,可以重定向到一个文件中进行观察,下面是运行结果的部分展示:

image-20250730002516300


6. 深入理解锁

  • 锁是为了保证临界区串行化执行,解决数据竞争问题。
  • 核心:用原子指令修改“锁变量”来控制资源的独占访问。
  • 加锁与解锁要成对、尽量小范围、尽快完成,避免性能问题和死锁。

1. 锁的本质与核心原则

1. 本质:用时间换安全

  • 多线程访问共享资源时,会出现 数据竞争
  • 锁通过 让线程串行化 访问临界区代码,避免数据不一致。
  • 换句话说:牺牲部分性能(等待)换取正确性

2. 原则:越小越好(大多数情况) + 原子性

  1. 尽量减少临界区范围
    • 临界区代码执行越多,锁持有时间越长,线程阻塞越多,性能越差。
    • 应该只保护 真正会引发数据竞争的代码,不要多写一行。
  2. 加锁/解锁必须是原子操作
    • 锁本身的操作必须不可被打断,否则会导致多个线程同时认为自己获取了锁 → 失效。

比喻:多个线程想上厕所(共享资源),锁就是门锁:

  • 门锁加锁和开锁的动作必须瞬间完成,不能“半锁半开”。
  • 厕所里的时间越短,大家等的时间越少。

2. 互斥锁的特性与规则

1. 特性

  1. 原子性:锁的获取和释放操作本身是原子的,保证“要么成功获取,要么失败重试”,不会出现“多个线程同时获得锁”。
  2. 独占性(互斥性):同一时刻,最多一个线程能持有锁。其他线程必须等待。
  3. 非重入性(默认特性)
    • 一个线程如果已经加锁,再次调用 pthread_mutex_lock 会造成 死锁,因为自己在等待自己释放。
    • 如果需要支持重入,必须使用 递归锁PTHREAD_MUTEX_RECURSIVE)。

2. 使用规则

  1. 外部线程必须排队等待:没抢到锁的线程,进入阻塞或自旋,等待持有锁的线程释放。
  2. 释放锁要公平(排队)
    • 等待队列中的线程应有公平性,避免某个线程永远抢不到锁。
    • 部分实现还要考虑 优先级反转 问题(低优先级线程持锁,阻塞高优先级线程)。
  3. 加锁与解锁要成对出现
    • 忘记解锁 → 死锁;
    • 多次解锁 → 未定义行为。

3. 常见锁的概念

【死锁算法】校园死锁连环案——蹲坑之争 | B 站

1. 死锁的定义

死锁是指两个或多个线程(或进程)因 互相持有资源并等待对方释放,导致程序无法继续执行的情况(僵持状态)。这就好比两个人过独木桥,面对面走到中间,谁都不肯退回去,结果谁也走不了啦。

  • 单进程场景也可能发生(如频繁申请单锁但不释放)。

  • 多线程中典型表现为:线程 A 持有锁 1 申请锁 2,线程 B 持有锁 2 申请锁 1,形成循环等待。

  • 特殊场景:单进程/单线程如果频繁申请资源而不释放,也可能出现类似死锁的“永久阻塞”现象。

2. 死锁四个必要条件

产生死锁 必须同时满足 以下四个条件,但满足全部条件 不一定必然发生死锁(需具体场景触发)。破坏任意一个条件即可避免死锁

注意:四个条件是死锁的 充分必要条件——缺一不可导致死锁,但满足全部仅表示“可能”死锁(需资源竞争实际发生)。

条件 含义 说明
互斥条件 一个资源每次只能被一个执行流使用。 资源本质属性(如锁、I/O 设备),无法完全消除,但可通过设计减少互斥场景。
请求与保持条件 线程在申请新资源时,不释放已经占有的资源。 例如:线程持有锁 A 后申请锁 B 失败,仍不释放锁 A。
不剥夺条件 资源一旦被占用,不能被强制剥夺,只能主动释放。 操作系统通常不支持强制剥夺资源(避免数据损坏)。
循环等待条件 若干执行流形成头尾相接的循环等待资源链(如 A→B→C→A)。 是前三个条件的综合结果,最易通过设计规避。

3. 避免死锁的策略

核心思路:破坏上述四个必要条件之一,或通过设计优化资源分配。

策略方向 具体方法 说明
破坏互斥条件 重写代码,尽量减少锁的使用或使用无锁数据结构 实现难度高,代价较大,代码改动多,仅适用于特定场景(如只读资源)。
破坏请求与保持条件 使用 trylock 等非阻塞加锁机制,获取失败时释放已占有的锁。 避免“持有并等待”,需配合重试逻辑,可能增加代码复杂度。
破坏不剥夺条件 在申请失败时主动释放已有锁,稍后重新申请,或设计可抢占式资源管理。 需谨慎处理,避免资源状态不一致。
破坏循环等待条件 加锁顺序一致:所有线程按固定顺序申请锁,如全局定义锁 ID,从小到大,避免混乱。
资源一次性分配:线程启动时申请所有所需资源,避免分步申请。
尽快释放锁:尽快释放锁,减少竞争时间/窗口。
最常用且有效,尤其“加锁顺序一致”是工程实践首选。

4. 避免死锁的算法(了解即可)

  • 死锁检测算法:定期检查系统里有没有死锁。它通过资源分配图这些方式,看进程和资源之间的关系,要是有循环等待且资源都被占着,那就可能死锁了。
  • 银行家算法:像银行放贷,查放贷后是否安全,安全则贷,否则不贷,避免死锁。

说明:算法适用于理论场景,实际开发中常通过设计规避,而非依赖复杂算法。


4. 同步

1. 同步的定义与必要性

同步定义:在 保证数据安全 的前提下,协调多线程 按特定顺序访问共享资源,确保数据安全和执行逻辑正确,避免竞争条件。与死锁的关系:同步是解决资源竞争的手段,而死锁是同步使用不当的结果之一。

  1. 单纯依赖 互斥锁 可能导致问题:

    • 线程饥饿:强线程频繁抢锁,弱线程可能长期无法执行。

    • 死锁风险:若锁申请顺序混乱或未释放,易触发死锁条件(尤其请求与保持、循环等待)。

  2. 同步是避免死锁的关键手段:通过有序排队机制,消除“请求与保持”和“循环等待”条件。

2. 同步的核心机制:条件变量

一起来学 C++ 47. 条件变量 | B 站

条件变量 | Punmy

1. 什么是条件变量?

条件变量 是一种 等待队列 + 通知机制,用来协调线程按顺序执行,当资源不可用时,线程可进入条件变量队列等待(主动排队等待),被其他线程唤醒后再去竞争锁。

  1. 核心功能:提供 等待队列通知机制,使线程在资源不可用时 主动排队等待,而非忙等或阻塞。
  2. 工作流程
    1. 线程尝试访问资源 → 失败 → 进入条件变量等待队列(自动释放锁)。
    2. 资源可用时(如其他线程释放资源),唤醒队列中的一个/所有线程 → 唤醒线程重新申请锁。
  3. 关键特性
    • 必须依赖锁使用:线程排队前需先获得锁,排队时锁自动释放;唤醒后需重新竞争锁。
    • 避免“虚假唤醒”:线程被唤醒后需 重新检查资源状态(在加锁后),防止误判。
    • 注意: 当线程调用 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
      2
      pthread_cond_t cond;
      pthread_cond_init(&cond, NULL); // 动态初始化,需手动销毁
    • 示例片段:

      1
      2
      3
      4
      5
      6
      7
      8
      pthread_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. 为什么需要排队?

  • 根本原因:单纯互斥锁无法解决“线程申请能力不均衡”问题。
  • 排队机制的作用
    1. 防止新线程插队:确保等待线程按序获取资源,避免强线程持续抢占。
    2. 提升公平性:通过队列管理,减少线程饥饿,间接破坏“请求与保持”条件。
    3. 保证数据安全:排队过程由操作系统协调,避免用户代码手动管理导致的竞态。

4. 小结

  1. 死锁是结果,同步是手段。同步用得不好会死锁,合理使用同步才能保证线程安全。
  2. 避免死锁的方法
    • 加锁顺序统一。
    • 锁尽快释放。
    • 使用 trylock、资源一次性分配等方式减少锁依赖。
  3. 条件变量是解决线程公平性和协调顺序的利器,必须和互斥锁配合使用。

5. 代码示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
#include <iostream>
#include <pthread.h>
#include <cstdlib>
#include <cstring>
#include <unistd.h>
using namespace std;

// uint64_t: 64 位无符号整数类型,用于确保能存储 64 位的线程 ID 或大数值
// uintptr_t: 足够大以容纳指针值的无符号整数类型,用于安全的指针到整数转换

int cnt = 0; // 临界资源,全局计数器,多个线程共享访问
int tid_cnt = 5; // 线程数量
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER; // 互斥锁,保护临界资源的访问,防止数据竞争
pthread_cond_t cond = PTHREAD_COND_INITIALIZER; // 条件变量,用于线程间通信,控制线程的等待和唤醒

void* Count(void* arg)
{
pthread_detach(pthread_self()); // 分离线程,这样线程结束时会自动回收资源
uint64_t number = (uint64_t)(uintptr_t)arg; // 修复指针到整数的转换警告,两次类型转换确保安全
cout << "[创建]线程" << number << "创建成功!" << endl;

while(true)
{
// 1. 加锁进入临界区
pthread_mutex_lock(&mutex); // 加锁
cout << "[等待]线程" << number << "获取锁,准备等待信号!" << endl;

// 这里隐含了一个判断:"是否收到了主线程的信号?"
// 如果没有收到信号,临界资源状态就是 "不就绪",所以线程选择等待

// 2. 等待条件满足
cout << "[阻塞]线程" << number << "调用pthread_cond_wait,即将释放锁并进入等待!" << endl;
pthread_cond_wait(&cond, &mutex); // 等待信号(临界资源就绪)
// pthread_cond_wait 的内部工作原理:
// a. 释放 mutex 锁(让其他线程可以获取锁)
// b. 将当前线程加入等待队列并阻塞
// c. 等待被 pthread_cond_signal 或 pthread_cond_broadcast 唤醒
// d. 被唤醒后自动重新获取 mutex 锁
// e. 返回(此时线程重新持有锁)

cout << "[唤醒]线程" << number << "被唤醒并重新获得锁!开始处理任务!" << endl;

// 3. 被唤醒后执行的代码,此时已经重新获得了锁,可以安全地访问临界资源 cnt
cout << "[执行]线程" << number << "收到信号,执行任务!cnt = " << cnt++ << endl;

// 4. 解锁 - 离开临界区
pthread_mutex_unlock(&mutex);
cout << "[完成]线程" << number << "释放锁,任务完成,重新进入等待循环中!" << endl;
}

pthread_exit(nullptr); // 线程结束,释放资源(显式)
// return nullptr; // 线程结束,释放资源(隐式)
}

void Demo_cond_init() // 这个函数没有实际意义,只是为了演示条件变量的初始化
{
pthread_cond_t cond1;

// 方式 1: 静态初始化(推荐用于全局变量)
// pthread_cond_t cond = PTHREAD_COND_INITIALIZER;

// 方式 2: 动态初始化(推荐用于局部变量)
int result = pthread_cond_init(&cond1, nullptr);
if (result != 0)
{
cerr << "初始化条件变量失败!" << strerror(result) << endl; // cerr: 标准错误输出流,用于输出错误信息
}

pthread_cond_destroy(&cond1); // 使用完后需要销毁
}

int main()
{
Demo_cond_init();

for(uint64_t i = 0; i < tid_cnt; i++)
{
pthread_t tid;
int result = pthread_create(&tid, nullptr, Count, (void*)(uintptr_t)i);
if(result != 0)
{
cerr << "创建线程失败!" << strerror(result) << endl;
}

usleep(1000);
}

sleep(3); // 等待所有线程进入等待状态
cout << "主线程控制开始!" << endl;
cout << "控制规则:每1秒唤醒一个线程,每3秒唤醒全部线程!" << endl;

int count = 0;
// while(count < 自定义 value) // 限制循环次数,避免无限运行
while(true)
{
sleep(1);
count++;

if (count % 3 == 0)
{
// 使用 pthread_cond_broadcast 唤醒所有等待的线程
cout << "[广播]唤醒所有线程!" << endl;
pthread_cond_broadcast(&cond);
}
else
{
// 使用 pthread_cond_signal 唤醒一个等待的线程
cout << "[单播]唤醒一个线程!" << endl;
pthread_cond_signal(&cond);
}

cout << "信号已经发出!count = " << count << endl;

// 显示当前计数器值
pthread_mutex_lock(&mutex);
cout << "当前计数值:" << cnt << endl;
pthread_mutex_unlock(&mutex);
}

cout << "主线程结束!" << endl << "最终计数值:" << cnt << endl;

return 0;
}

注:上面代码模拟的是一个“多个工作线程等待任务信号,由主线程定时唤醒执行”的场景,用来演示条件变量如何控制线程的等待与唤醒、线程同步、临界资源保护、条件变量解耦协作等内容。