死锁问题以及读写锁和自旋锁介绍【Linux操作系统】
文章目录
- 死锁
- 死锁的简单介绍及单线程死锁
- 死锁的必要条件
- 避免死锁
- 读写者问题和读写锁
- 基本介绍
- 实现方案
- 读写锁的相关函数
- 库函数:pthread_rwlock_init
- 库函数:pthread_rwlock_destroy
- 库函数:pthread_rwlock_rdlock
- 库函数:pthread_rwlock_wrlock
- 库函数:pthread_rwlock_unlock
- 读写锁的使用策略
- 自旋锁
- 概述和存在的作用
- 原理
- 自旋锁存在的意义是什么?
- 自旋锁的优缺点及应用
- 优点
- 缺点
死锁
死锁的简单介绍及单线程死锁
死锁是指在一组进程中的各个进程均占有不会释放的资源,但因互相申请被其他进程所站用不会释放的资源而日处于的 一种永久等待状志
例如:
如果想要访问一个临界区,需要申请两把锁(a锁和b锁)才能访问
申请一把锁是原子的,但是连续申请两把锁就不一定是原子的了
所以就有可能出现:
- 线程1抢到了锁a,线程2抢到了锁b
- 而线程1还想要锁b,线程2还想要锁a
所以他们就互相进入了彼此的锁的等待队列中,而它们手上的这两把锁就永远不可能被释放了
死锁的情况不要局限于上面这一种
单线程甚至都有可能出现死锁
比如下面的3种情况
- ①程序员代码写错了,即主线程申请完锁之后,本来的解锁接口,写成了加锁接口,逐渐成就,拿着锁a又去申请锁a
- 情况①可能看着很笨逼,但是他的原因却很普遍:
即申请了一把锁a成功之后,还没有解锁,线程却又执行到了申请锁a的代码
什么情况下会发生这样的事情呢?
1.函数嵌套调用的时候最有可能出现
2.信号处理
- 情况①可能看着很笨逼,但是他的原因却很普遍:
-
②
递归/函数嵌套调用
在一个函数a的临界区中,如果有一个递归调用,就算是单线程也可能会造成死锁
主线程进入函数a,申请锁成功,进入临界区,临界区中又递归调用了函数a,但是因为第一次调用函数a的栈区,再次调用函数a时不会销毁,因为代码还没执行完
所以哪怕用RAII的锁,主线程因为递归第2次调用函数a时,也没有解锁
此时,主线程重新执行函数a的代码,拿着锁1又去申请锁1了,就永远不可能解锁了- 更加普遍的:一个函数里面调用另一个函数,也可能造成死锁:
此时如果直接调用a(),就一定会死锁
- 更加普遍的:一个函数里面调用另一个函数,也可能造成死锁:
- ③
信号处理导致死锁
和递归非常类似,如果主线程进入函数a申请锁成功
此时信号到来,信号处理方法也是调用函数a,主线程序执行信号处理方法时,拿着锁又去申请锁,就死锁了
死锁的必要条件
即,下面4个条件必须同时满足才会产生死锁
所以我们的代码只需要,让其中的任意一个条件不满足,就不会产生死锁了
避免死锁
我们知道了死锁的4个必要条件之后
我们的代码只需要,让其中的任意一个条件不满足,就不会产生死锁了
- ①破坏互斥条件:
即不加锁/少加锁,但是对于可能被修改的共享资源,基本上是必定要加锁的
所以:
本质其实是多执行流时,尽量用更少的共享资源个数去达成代码目的
- ②破坏请求与保持:
请求锁基本是不可避免的,但是保持锁可以破坏-
如何破坏?
如果一个共享资源需要两把锁才可以被访问
线程a持有锁1去申请锁2时,使用pthread_trylock进行申请,如果申请锁2失败,就证明锁2被别人拿走了
返回后线程a就把自己持有的锁1释放掉 -
但是加锁后代码效率本来就不高,这样一搞效率就更低了
所以解决这种一个共享资源需要多个锁的情况时,应该尽可能保证所有线程申请锁的顺序是一样的(即都必须先申请锁1,再申请锁2)
因为这样的话,就能做到:
申请第1把锁成功之后,申请后续的所有锁,就是一个互斥的过程了
例:
线程a最先来,申请到锁1
线程b,线程c再来,但是因为申请锁的顺序一样,即必须先申请锁1,再申请锁2,再申请锁3,所以他们不会去申请锁2和锁3
此时线程b,c再申请锁1,就会被阻塞/申请失败
此时,又因为申请锁的顺序必须一样,所以线程b,线程c,依旧不会去申请锁2和锁3
最多再去尝试申请一下锁1
所以这个时候,就只有申请锁1成功的线程a能够继续申请锁2和锁3
所以:
就能保证申请所有锁的互斥性 -
此时就算出现了特殊情况:
比如线程a成功申请锁1和锁2之后,申请锁3时失败了
[这个是有可能发生的,因为可能此时锁3保护的其他临界区也需要多把锁保护,但是申请顺序是先锁3,再锁4(锁n)])
此时再破坏保持条件:申请某锁失败,还会释放自己拥有的所有锁
这样就基本万无一失了
-
- ③破坏不剥夺条件:
做不到
-
④破坏循环等待条件:
需要同时申请多把锁才能进入某个临界区时
例如:
有两(多)个共享资源,它们分别被对应的锁保护,但是有一个临界区中,会同时修改这两(多)个共享资源
所以线程进入这个临界区,需要同时申请多把锁
可以从3个方面着手,破坏循环等待条件- 1.
尽可能保证所有线程,申请锁的顺序一致,而且申请失败还会释放自己拥有的所有锁
(即,所有线程都必须先申请锁1,再申请锁2,再申请锁3…)
- 1.
-
2.
使用C++11的接口Lock(锁1,锁2,...)
它可以支持线程原子性地一次性申请多把锁
这是怎么做到的?
其实很简单,如果我们要同时申请三把锁
就可以定义出第四把锁,把申请三把锁的代码锁住,这样就同时只有一个线程能够进去,申请3把锁了 -
3.
超时机制,在锁的等待队列中等待时间锁的超过对应时间后,就触发对应的应对机制
这通常是通过std::mutex
和std::timed_mutex
类以及它们的相关方法实现的- std::timed_mutex类提供了带有超时功能的互斥锁
在尝试锁定一个互斥锁时,可以指定一个超时时间
如果在超时时间内未能获取到锁,则互斥锁不会再阻塞当前线程,而是返回一个布尔值来表示是否成功获取了锁 - 通过这个方法,可以有效地避免死锁,因为当线程无法在指定时间内获取到锁时,它们可以选择释放资源、重试或者执行其他任务,而不是无限制地等待,这样就可以减少死锁的可能性
- std::timed_mutex类提供了带有超时功能的互斥锁
读写者问题和读写锁
基本介绍
读写者问题和生产者消费者模型一样,是实现多线程同步互斥的一种模型
我们生活中读写模型很常见,比如写博客,写博客的人就是写者,读博客的人就是读者
读者写者模型也和消费者模型一样:
-
①3种关系:
-
1.
写者和写者:互斥
因为不可能同时往里面写,容易出现数据覆盖 -
2.
读者和写者:互斥+同步
写者在写的时候,因为还没写完,读者去读可能读到乱码
读者在读的时候,写者去写,写的数据可能会把读者正在读的数据覆盖
所以必须互斥
但是如果只互斥的话,很有可能产生锁的饥饿问题,所以还需要同步来提升效率 -
3.
读者和读者:并发(没有关系)
因为读者写者模型的读者和生产者消费者模型中的消费者不一样
读者只会读取数据,但是读取数据之后,并不会把交易场所中的数据删除
-
-
②两种角色:读者和写者
-
③一个交易场所:本质就是一块内存空间,一般是用一个数据结构充当交易场所
所以
读写者模型和生产者消费者模型非常类似
只有一个比较大的区别:
就是消费者和消费者者之间是互斥关系
读者和读者之间是并发的
因为读者并不会真的把交易场所中的数据拿走,所以读者之间并没有消费者之间那样的竞争关系
实现方案
一般而言读写者模型中,读者很多,写者很少
虽然读者和读者之间没有互斥关系
但是读者和写者之间有互斥关系
即:
只要还有一个读者在读,写者就不能写
只要还有一个写者在写,读者就不能读
因为交易场所中同时可能出现多个读者
所以我们需要维护一个计数器,动态记录交易场所中读者的个数
因为写者和写者之间是互斥的,所以交易场所中最多同时有一个写者
所以不需要计数器记录写者
所以读写者模型中,至少有两个共享资源
- ①动态记录交易场所中读者个数的计数器
- ②交易场所
所以就至少有两个锁,分别保护这两个共享资源
所以读者的接口:
-
①申请计数器锁
-
②判断计数器是否为0
-
1.如果为0,就说明进来的是第一个读者,那这第一个读者就去申请交易场锁(为了防止读者读的时候,写者进来写)
此时就算申请失败(就说明有写者在写),阻塞了,其他的读者也进不来,因为被计数器锁拦住了 -
2.如果不为0,就说明进来的不是第1个读者,而且交易场锁已经加好了
-
-
③计数器++
-
③解除计数器锁
-
④访问交易场所中的数据
-
⑤访问完成,申请计数器锁
-
⑥计数器–
-
⑦如果计数器减到0,就说明这是最后一个读者了,所以解除交易场锁
-
⑧解除计数器锁
读写锁的相关函数
库函数:pthread_rwlock_init
-
头文件:pthread.h
-
参数表:
- ①pthread_rwlock_t*mu:要初始化的读写锁的地址
- ②读写锁的属性,一般为nullptr
-
作用:
初始化对应的读写锁
库函数:pthread_rwlock_destroy
-
头文件:pthread.h
-
参数表:
pthread_rwlock_t*mu:要销毁的读写锁的地址 -
作用:
销毁对应的读写锁
库函数:pthread_rwlock_rdlock
-
头文件:pthread.h
-
参数表:
pthread_rwlock_t*mu:对应的读写锁的地址 -
作用:
以读者的身份添加读锁
库函数:pthread_rwlock_wrlock
-
头文件:pthread.h
-
参数表:
pthread_rwlock_t*mu:对应的读写锁的地址 -
作用:
以写者的身份添加写锁
库函数:pthread_rwlock_unlock
-
头文件:pthread.h
-
参数表:
pthread_rwlock_t*mu:对应的读写锁的地址 -
作用:
解除读写锁(读数和写锁统一用它解锁)
读写锁的使用策略
读者优先
在这种策略中,系统会尽可能多地允许多个读者同时访问资源(比如共享文件或数据),而不会优先考虑写者
这意味着当有读者正在读取时,新到达的读者会立即被允许进入读取区,而写者则会被阻塞
,直到所有读者都离开读取区
读者优先策略可能会导致写者饥饿(即写者长时间无法获得写入权限),特别是当读者频繁到达时
写者优先
在这种策略中,系统会优先考虑写者。当写者请求写入权限时,系统会尽快地让写者进入写入区,即使此时有读者正在读取
这通常意味着一旦有写者到达,所有后续的读者都会被阻塞,直到写者完成写入并离开写入区
写者优先策略可以减少写者等待的时间,但可能会导致读者饥饿(即读者长时间无法获得读取权限),特别是当写者频繁到达时
注意:
- ①写者优先是,如果写者想要写入了,在系统接收到这个写入要求之后,就会阻挡后续读者的进入
这个时候如果有读者在读,就等到这些剩下的读者读完,再让写者写入 - ②一定是两个模式2选1
自旋锁
概述和存在的作用
原理
自旋锁通常使用一个共享的标志位(如一个布尔值)来表示锁的状态。当标志位为true时,表示锁已被某个线程占用;
当标志位为false时,表示锁可用,当一个线程尝试获取自旋锁时,它会不断检查标志位:
- 如果标志位为false,表示锁可用,线程将设置标志位为true,表示自己占用了锁,并进入临界区
- 如果标志位为true(即锁已被其他线程占用),线程会在一个循环中不断自旋等待,直到锁被释放
自旋锁存在的意义是什么?
先说它与我们常用的互斥锁的区别:
-
①线程申请互斥锁失败时,会去互斥锁的等待队列中阻塞等待
-
②线程申请自旋锁失败时,线程不会阻塞等待,而是不断轮询检测是否解锁了
此时,自旋锁在线程申请锁失败时的处理方法,相比其他锁的优点是什么?
-
①
减少切换的开销
:
因为如果线程直接阻塞了, Cpu肯定会换一个线程上来运行,即会进行线程切换
但,自旋锁并不是让线程直接阻塞,而是轮询检测 -
②消除了进入阻塞状态的开销:
因为进入线程阻塞状态,是有代价的
(例如:把线程的PCB和TCB链入等待队列,恢复的运行时候,还要把它们链会运行队列)
但,自旋锁并不是让线程直接阻塞,而是轮询检测
所以
自旋锁可以让锁的竞争过程更加快速,更加高效…吗?
举个例子:
我们去找朋友玩时,朋友说让我们等他一下,此时会有两种情况:
-
①朋友说:我要2个小时之后才有空
此时一般,我们要么不等,要等的话也会做其他的事打发时间
比如:去一家附近的网吧上网,让朋友忙完了再给我们打电话,我再从网吧回来 -
②朋友说:我几分钟后下来
此时一般,我们会直接在楼下等朋友下来,最多在等的过程中打电话,问一下他什么时候下来
不可能还去网吧打发时间,因为去网吧和从网吧回来需要时间
也就是说:
等人的时候,我们等待的时长,决定了我们等待的方式
同样的,在锁的竞争中:
- ①我去网吧等待,就是阻塞等待
- ②我从朋友家到网吧,和从网吧回朋友家的时间,就是阻塞等待所花的成本
- ③朋友在忙的时间,就是线程在临界区中运行的时间
- ④我在朋友家的楼下等待,就是自旋等待
- ⑤我在楼下等时,我给朋友打电话,问他怎么还不下来,就是在轮询检测是否解锁
所以:
线程等待锁时,线程执行临界区的时长,就决定了线程的等待方式
所以:
自旋锁让锁的竞争过程更加快速,更加高效,自旋锁要能做到这一点,是需要条件的:
条件是:
所有线程进入临界区之后,很快就能执行完临界区的代码(至多和一个线程切换+一个线程进入和恢复阻塞状态的时间差不多
)此时自旋锁才有意义
不然的话,互斥锁也可以做到这一点
然而:
现实编写代码过程中,这个条件往往不能满足
自旋锁的优缺点及应用
优点
-
低延迟
:自旋锁适用于短时间内的锁竞争情况,因为它不会让线程进入休眠状态,从而避免了线程切换的开销,提高了锁操作的效率
-
减少系统调度开销
:等待锁的线程不会被阻塞,不需要上下文切换,从而减少了系统调度的开销
缺点
-
CPU资源浪费
:如果锁的持有时间较长,等待获取锁的线程会一直循环等待,导致CPU资源的浪费
-
可能引起活锁
:当多个线程同时自旋等待同一个锁时,如果没有适当的退避策略,可能会导致所有线程都在不断检查锁状态而无法进入临界区,形成活锁
使用场景
- 短暂等待的情况:适用于锁被占用时间很短的场景,如多线程对共享数据进行简单的读写操作
- 多线程锁使用:通常用于系统底层,同步多个CPU对共享资源的访问