前言
当谈到多线程编程时,线程互斥是一个至关重要的概念。在多线程环境下,确保共享资源的安全访问是至关重要的,而线程互斥正是为此而设计的。通过线程互斥,我们能够确保在任意给定时间内,只有一个线程能够访问共享资源,从而避免竞态条件和数据损坏。
在本篇博客中,我们将探讨线程互斥的重要性、实现线程互斥的方法以及在实际编程中如何应用线程互斥来确保多线程程序的正确性和稳定性。通过深入了解线程互斥,我们可以更好地理解多线程编程中的关键概念,提高程序的可靠性和性能。
希望本篇博客能够帮助你更好地理解线程互斥,并为你在多线程编程中遇到的挑战提供一些思路和解决方案。让我们一起深入探讨线程互斥,为构建高效、稳定的多线程程序打下坚实的基础。
1 进程线程间的互斥相关背景概念
- 临界资源:多线程执行流共享的资源就叫做临界资源
- 临界区:每个线程内部,访问临界资源的代码就叫做临界区
- 互斥:任何时刻,互斥保证有且只有一个执行流进入临界区,访问临界资源,通常对临界资源起保护作用
- 原子性:不会被任何调度机制打断的操作,该操作只有两态,要么完成,要么未完成
2 线程安全
多个线程并发同一段代码时不会出现不同的结果,多个执行流访问临界资源不会导致程序产生二义性。
- 执行流:可以理解为线程
- 访问:指的是对临界资源进行操作
- 临界资源:指的是多个线程都可以访问到的资源
- 临界区:代码操作临界资源的代码区域称之为临界区
- 二义性:相同的代码,结果不唯一
3 线程不安全
不安全、和上面就相反喽
- 假设一个场景:
假设有一个CPU,两个线程,线程A和线程B,线程A和线程B都要对全局变量i(10)进行++操作- 假设线程A先运行,但是线程A将i的值读取到寄存器之后,就被线程切换了。(操作系统会保存线程A的程序计数器和上下文信息)
- 假设B线程运行,正常继续++操作,那么i的值在内存中就被修改增加1了
- 此时线程A切换回来了,怎么计算?内存中i的值是多少?
- 结论:此时的i最终结果还是11,明明加了两次,但是却不符合逻辑,这就是不安全
- 怎么解决?这就需要用到互斥了,每次只允许一个线程进入修改,这样就不会有这种情况了
3.1 线程不安全(看看猪跑
在我们想买票的时候,如果有两个人同时下单,它会发生什么呢?票是怎么发放的,会不会有两个人买到同一张票的情况?会不会有票数为负的情况?
我们通过代码来模拟一下,如果线程不安全时,抢票的情况
代码如下:
我们让4个线程来循环获取ticket,模拟抢票
#include<iostream> #include<pthread.h> #include<unistd.h> using namespace std; int ticket=1000; void* get_ticket(void* arg) { while(1) { if(ticket>0) { cout<<"i am "<<pthread_self()<<" get a ticket,no:"<<ticket<<endl; ticket--; } else { break; } } return NULL; } int main() { pthread_t tid[4]; for(int i=0;i<4;i++) { int ret=pthread_create(&tid[i],NULL,get_ticket,NULL); if(ret!=0) { cout<<"线程创建失败!"<<endl; } } for(int i=0;i<4;i++) { pthread_join(tid[i],NULL); } cout<<"pthread_join end!"<<endl; return 0; }
结果如下:
可以看到出现了负数的情况
甚至出现了两个线程抢到同一张票的情况
这就是所谓的线程不安全的情况
4 互斥量mutex
- 大部分情况,线程使用的数据包都是局部变量,变量的地址空间在线程栈空间内,这种情况,变量归属于单个线程,其他线程无法获得这种变量。
- 但是很多时候,很多变量需要在线程间共享,这样的变量称为共享变量。通过数据的共享完成线程之间的交互。
- 多个线程并发的操作共享变量会带来一些问题(就像前面代码中那样)
之前的抢票模拟,可以看到出现了负数的票,但是我们的条件中清楚的要求>0,这是为什么?
4.1 为什么可能无法获得争取结果?
- if语句判断条件为真后,代码可以并发的切换到其他线程
- –ticket操作本身就不是一个原子操作
取出ticket--部分的汇编代码 objdump -d a.out > test.objdump 152 40064b: 8b 05 e3 04 20 00 mov 0x2004e3(%rip),%eax # 600b34 <ticket> 153 400651: 83 e8 01 sub $0x1,%eax 154 400654: 89 05 da 04 20 00 mov %eax,0x2004da(%rip) # 600b34 <ticket>
- --ticket操作本身就不是一个原子操作,而是对应三条汇编指令:
- load:将共享变量ticket从内存加载到寄存器中
- update:更新寄存器里面的值,执行-1操作
- store:将新值,从寄存器写回共享变量ticket的内存地址
4.2 怎么解决?
要解决上面的问题需要做到三点
- 代码必须要有互斥行为,当代码进入到临界区执行时,不允许其他线程进入该临界区
- 如果多个线程同时要求执行临界区代码,并且临界区没有其他线程在执行,那么只允许一个线程进入临界区
- 如果线程不在临界区中执行,那么该线程不能阻止其他线程进入临界区
要做到这三点,只需要一把锁就行了,这把锁叫做互斥锁
5 互斥锁
5.1 什么是互斥锁
- 互斥锁的底层就是一个互斥量,而互斥量的本质就是一个计数器,这个计数器的本质就只有两种情况 0 和 1
- 1 表示当前临界资源可以被访问
- 0 表示当前临界资源不可以被访问
5.2 逻辑梳理
拿之前的抢票的情况来说,如果加上这个锁,当一个线程想去访问临界资源,他得先获取互斥锁,如果此时互斥锁的值为1,则说明它可以访问,反之则不能,如果它正在访问临界资源,此时有第二个线程想来访问临界资源,发现互斥锁为0,它就不能进入,只能等待互斥锁为1时才能进入访问。这就保证当前的临界资源在同一时刻只能被一个执行流访问了。
但是需要注意的是,如果多个线程访问临界资源的时候是互斥访问的属性,一定要在多个线程中进行同一把锁的加锁操作,这样每个线程在访问临界资源之前都要获取这把锁,若锁的值为1就可以访问,反之则不能访问;如果给线程A加锁,但是不给线程B加锁,就会导致线程不安全的情况。
5.3 加锁逻辑
加锁的时候会提前在寄存器的计数器中保存的一个值 0,而不管内存的计数器中保存的值为多少,都会将寄存器中保存到值 0 和内存计数器中保存的值进行交换,然后对寄存器中的值进行判断是否为 1 ,如果为 1 ,则能加锁,如果不为 1 ,则不能加锁。
为了实现互斥锁操作,大多数体系结构都提供了swap或exchange指令
该指令的作用是把寄存器和内存单元的数据相交换
由于只有一条指令,保证了原子性
即使是多处理器平台,访问内存的总线周期也有先后
一个处理器上的交换指令执行时另一个处理器的交换指令只能等待总线周期。
现在我们把lock和unlock的伪代码给一下。
6 互斥锁的接口
6.1 初始化互斥锁
初始化互斥量有两种方法:
- 静态分配
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER
- 动态分配
int pthread_mutex_init(pthread_mutex_t *restrict mutex, const pthread_mutexattr_t *restrictattr);
返回值及参数说明:
- 返回值:如果初始化成功则返回0 失败则返回错误码
- 参数 mutex:需要初始化的互斥量
- 参数 attr:初始化互斥量的属性一般设置为NULL即可
6.2 销毁互斥锁
需要注意的是:
- 使用静态初始化的互斥量不需要销毁
- 不要销毁一个已经加锁的互斥量
- 已经销毁的互斥量要确保后面不会有线程再尝试加锁
int pthread_mutex_destroy(pthread_mutex_t *mutex);
6.3 互斥量的加锁和解锁
- 阻塞加锁接口
int pthread_mutex_lock(pthread_mutex_t *mutex);
- 如果互斥锁变量当中的计数器的值为1,调用该接口,则加锁成功,该接口调用完毕,函数返回
- 如果互斥锁变量当中的计数器的值为0,调用该接口,则调用该接口的执行流阻塞在当前接口内部
- 非阻塞加锁接口
int pthread_mutex_trylock(pthread_mutex_t *mutex);
- 不管有没有加锁成功,都会返回,所以需要对加锁返回的结果进行判断,判断是否加锁,如果加锁成功,则操作临界资源,反之则需要循环获取互斥锁,直到拿到互斥锁
- 带有超时时间的加锁接口
int pthread_mutex_timedlock(pthread_mutex_t *restrict mutex,const struct timespec *restrict abstime); struct timespec{ time_t tv_sec;//秒 long tv_nsec;//纳秒 };
- 需要搭配循环来使用,并且判断返回值
- 解锁接口
int pthread_mutex_unlock(pthread_mutex_t *mutex);
- 不管是阻塞加锁/非阻塞加锁/带有超时时间的加锁,加锁成功的互斥锁都可以使用该接口进行加锁。
6.4 互斥锁的使用
- 什么时候使用初始化互斥锁?
先初始化互斥锁,再创建线程
- 什么时候使用销毁互斥锁?
在所有使用互斥锁的线程全部退出之后就可以销毁互斥锁
- 什么时候使用加锁?
线程访问临界资源之前进行加锁操作
- 什么时候使用解锁?
线程所有退出的地方进行解锁
- 加锁之后不解锁会发生什么?
以之前的抢票为例
#include<iostream> #include<pthread.h> #include<unistd.h> using namespace std; int ticket=1000; pthread_mutex_t g_lock; //全局变量的互斥锁 void* get_ticket(void* arg) { while(1)//1位置加锁还是在2位置加锁 { pthread_mutex_lock(&g_lock); //pos1 if(ticket>0) { cout<<"i am "<<pthread_self()<<" get a ticket,no:"<<ticket<<endl; //pos2 ticket--; } else { //将下面解锁的注释去掉就是正确的代码 //pthread_mutex_unlock(&g_lock); break; } //pthread_mutex_unlock(&g_lock); } return NULL; } int main() { pthread_mutex_init(&g_lock,NULL);//初始化互斥锁 pthread_t tid[4]; for(int i=0;i<4;i++) { int ret=pthread_create(&tid[i],NULL,get_ticket,NULL); if(ret!=0) { cout<<"线程创建失败!"<<endl; } } for(int i=0;i<4;i++) { pthread_join(tid[i],NULL); } cout<<"pthread_join end!"<<endl; pthread_mutex_destroy(&g_lock); return 0; }
结果:
我们可以看到它只获取一张票就不再往下执行了,陷入了死锁中
这是因为有一个工作线程加锁之后没有进行解锁,其他线程再次去获取锁时,互斥锁中计数器中的值还是0,就要被阻塞等待,所以加锁之后一定要记得解锁
7 死锁
前面我们讲如果不进行解锁会造成死锁现象,但是死锁是什么?现在我们就来讲讲
7.1 死锁的定义
死锁是指在一组进程中的各个进程均占有不会释放的资源,但因互相申请被其他进程所占用不会释放的资源而处于一种永久等待状态。
就像下图
线程A获取到互斥锁1,线程B获取到互斥锁2时,线程A和线程B同时还想获取对方手里的锁(线程A还想获取互斥锁2,线程B还想获取互斥锁1),此时就会导致死锁
代码实现看一下
#include<iostream> #include<unistd.h> #include<pthread.h> using namespace std; pthread_mutex_t lock1; pthread_mutex_t lock2; void* ThreadNum1(void* args) { (void*)args; pthread_mutex_lock(&lock1); sleep(3); pthread_mutex_lock(&lock2); return NULL; } void* ThreadNum2(void* args) { (void*)args; pthread_mutex_lock(&lock2); sleep(3); pthread_mutex_lock(&lock1); return NULL; } int main() { pthread_mutex_init(&lock1,NULL); pthread_mutex_init(&lock2,NULL); pthread_t tid; int ret = pthread_create(&tid,NULL,ThreadNum1,NULL); if(ret < 0) { cout<<"thread1 create failed"<<endl; return 0; } ret = pthread_create(&tid,NULL,ThreadNum2,NULL); if(ret < 0) { cout<<"thread2 create failed"<<endl; return 0; } while(1) { ; } pthread_mutex_destroy(&lock1); pthread_mutex_destroy(&lock2); return 0; }
结果:
可以看到发生了死锁的现象
7.2 死锁产生的条件
死锁的生成有四个必要的条件:
- 互斥条件:一个资源只能被一个执行流使用。(一个互斥锁只能被一个执行流在同一时刻拥有)
- 请求与保持条件:一个执行流因请求资源而阻塞时,对已获得的资源保持不放。(就像前面的例子那样,各自都是各自所需要的资源,但是都不放手)
- 不剥夺条件:一个执行流已获得的资源在未使用完之前不能强行剥夺
- 循环等待条件:若干执行流之间形成一种头尾相接的循环等待资源的关系。(互相等待,无限循环)
7.3 预防死锁
想要预防死锁只要破坏死锁4个条件中的一个即可
- 破坏死锁的四个必要条件
- 加锁顺序一致
- 避免锁未释放的场景
- 资源一次性分配
7.4 避免死锁
它和死锁预防的差别很小,可以把它理解为死锁预防的一种特例。
死锁避免策略在允许三个必要条件存在的条件下,来确保永远不会达到死锁点。
死锁避免方法有:
- 若一个进程的请求会导致死锁,那么不启动该进程。
- 若一个进程增加的资源请求会导致死锁,则不允许这个资源的分配。
相比死锁预防策略,死锁避免策略并发性更强。但是在使用中也有诸多限制:
- 必须事先声明每个进程请求的最大资源
- 分配的资源数量必须是固定的
- 在占有资源时,进程不能够退出
- 所讨论的进程的执行顺序必须没有任何同步要求的限制
7.5 死锁预防和死锁避免之间的区别
编号 | 比较项 | 预防死锁 | 避免死锁 |
1 | 概念 | 预防死锁至少阻止了发生死锁的必要条件之一。 | 避免死锁确保系统不会进入不安全状态 |
2 | 资源请求 | 预防死锁所有的资源都是一起请求的。 | 资源请求是根据可用的安全路径完成的。 |
3 | 所需信息 | 预防死锁不需要关于现有资源、可用资源和资源请求的信息 | 避免死锁需要关于现有资源、可用资源和资源请求的信息 |
4 | 过程 | 通过限制资源请求过程和资源处理来防止死锁。 | 避免死锁会自动考虑请求并检查它是否对系统安全。 |
5 | 抢占 | 有时,抢占会更频繁地发生。 | 避免死锁在死锁避免中没有抢占。 |
6 | 资源分配策略 | 用于防止死锁的资源分配策略是保守的。 | 防止死锁的资源分配策略并不保守。 |
7 | 未来的资源请求 | 预防死锁不需要知道未来的进程资源请求。 | 避免死锁需要了解未来的进程资源请求。 |
8 | 优点 | 预防死锁不涉及任何成本,因为它只需使条件之一为假,这样就不会发生死锁。 | 由于此方法动态工作以分配资源,因此没有系统未充分利用。 |
9 | 缺点 | 死锁预防设备利用率低。 | 避免死锁会使进程阻塞太久。 |
10 | 使用示例 | 假脱机和非阻塞同步算法。 | 使用银行家和安全算法。 |
7.6 避免死锁的算法
- 死锁检测算法:推荐博客《死锁的处理策略——检测和解除》
- 银行家算法:推荐博客《银行家算法及其代码实现》