死锁问题以及读写锁和自旋锁介绍【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对共享资源的访问
相关推荐
HainesFreeman1 小时前
Linux、Ubuntu和CentOS的关系与区别
linux·ubuntu·centos
yuanManGan1 小时前
Linux基本指令(一)
linux·运维·服务器
珹洺2 小时前
Linux操作系统从入门到实战(十)Linux开发工具(下)make/Makefile的推导过程与扩展语法
linux·运维·服务器
星哥说事2 小时前
Linux管理不用记命令!Linux安装可视化管理工具Cockpit安装使用
linux
不会敲代码的XW4 小时前
LVS(Linux Virtual Server)详细笔记(理论篇)
linux·笔记·lvs
路飞雪吖~4 小时前
【Linux】线程创建&&等待&&终止&&分离
linux·开发语言
♛暮辞5 小时前
centos 安装java 环境
java·linux·centos
不脱发的程序猿5 小时前
嵌入式Linux:进程间通信机制
linux
喧星Aries6 小时前
进程的内存映像,只读区,可读写区,堆,共享库,栈详解
linux·操作系统·计算机组成原理
vortex56 小时前
dockerfile 最佳实践
linux·docker·云技术