死锁问题以及读写锁和自旋锁介绍【Linux操作系统】

文章目录

死锁

死锁的简单介绍及单线程死锁

死锁是指在一组进程中的各个进程均占有不会释放的资源,但因互相申请被其他进程所站用不会释放的资源而日处于的 一种永久等待状志

例如:

如果想要访问一个临界区,需要申请两把锁(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...)
  • 2.使用C++11的接口Lock(锁1,锁2,...)

    它可以支持线程原子性地一次性申请多把锁

    这是怎么做到的?

    其实很简单,如果我们要同时申请三把锁

    就可以定义出第四把锁,把申请三把锁的代码锁住,这样就同时只有一个线程能够进去,申请3把锁了

  • 3.超时机制,在锁的等待队列中等待时间锁的超过对应时间后,就触发对应的应对机制

    这通常是通过std::mutexstd::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个小时之后才有空

    此时一般,我们要么不等,要等的话也会做其他的事打发时间

    比如:去一家附近的网吧上网,让朋友忙完了再给我们打电话,我再从网吧回来

  • ②朋友说:我几分钟后下来

    此时一般,我们会直接在楼下等朋友下来,最多在等的过程中打电话,问一下他什么时候下来

    不可能还去网吧打发时间,因为去网吧和从网吧回来需要时间

也就是说:
等人的时候,我们等待的时长,决定了我们等待的方式


同样的,在锁的竞争中:

  • ①我去网吧等待,就是阻塞等待
  • ②我从朋友家到网吧,和从网吧回朋友家的时间,就是阻塞等待所花的成本
  • ③朋友在忙的时间,就是线程在临界区中运行的时间
  • ④我在朋友家的楼下等待,就是自旋等待
  • ⑤我在楼下等时,我给朋友打电话,问他怎么还不下来,就是在轮询检测是否解锁

所以:
线程等待锁时,线程执行临界区的时长,就决定了线程的等待方式

所以:
自旋锁让锁的竞争过程更加快速,更加高效,自旋锁要能做到这一点,是需要条件的:

条件是:

所有线程进入临界区之后,很快就能执行完临界区的代码(至多和一个线程切换+一个线程进入和恢复阻塞状态的时间差不多)此时自旋锁才有意义

不然的话,互斥锁也可以做到这一点

然而:

现实编写代码过程中,这个条件往往不能满足



自旋锁的优缺点及应用

优点

    1. 低延迟:自旋锁适用于短时间内的锁竞争情况,因为它不会让线程进入休眠状态,从而避免了线程切换的开销,提高了锁操作的效率
    1. 减少系统调度开销:等待锁的线程不会被阻塞,不需要上下文切换,从而减少了系统调度的开销

缺点

    1. CPU资源浪费:如果锁的持有时间较长,等待获取锁的线程会一直循环等待,导致CPU资源的浪费
    1. 可能引起活锁:当多个线程同时自旋等待同一个锁时,如果没有适当的退避策略,可能会导致所有线程都在不断检查锁状态而无法进入临界区,形成活锁

使用场景

  1. 短暂等待的情况:适用于锁被占用时间很短的场景,如多线程对共享数据进行简单的读写操作
  2. 多线程锁使用:通常用于系统底层,同步多个CPU对共享资源的访问
相关推荐
Ribou7 分钟前
Ubuntu 24.04.2安装k8s 1.33.4 配置cilium
linux·ubuntu·kubernetes
tan180°1 小时前
Boost搜索引擎 网络库与前端(4)
linux·网络·c++·搜索引擎
Mr. Cao code2 小时前
Docker:颠覆传统虚拟化的轻量级革命
linux·运维·ubuntu·docker·容器
抓饼先生2 小时前
Linux control group笔记
linux·笔记·bash
挺6的还2 小时前
25.线程概念和控制(二)
linux
您的通讯录好友3 小时前
conda环境导出
linux·windows·conda
代码AC不AC4 小时前
【Linux】vim工具篇
linux·vim·工具详解
码农hbk4 小时前
Linux signal 图文详解(三)信号处理
linux·信号处理
bug攻城狮4 小时前
Skopeo 工具介绍与 CentOS 7 安装指南
linux·运维·centos
宇宙第一小趴菜4 小时前
08 修改自己的Centos的软件源
linux·运维·centos