【编程基础】锁机制与死锁

Linux 锁机制

Linux 的内核锁主要是自旋锁信号量

互斥锁 Mutex Lock

互斥锁(Mutual-Exclude Lock)是最容易理解、使用最广泛的一种同步机制。使用互斥锁保护的临界区只允许一个线程进入,其他线程如果没有获取锁权限,就只能等候。

int pthread_mutex_init(pthread_mutex_t *restrict mutex, const pthread_mutexattr_t *restrict attr); 
int pthread_mutex_destroy(pthread_mutex_t *mutex);
int pthread_mutex_lock(pthread_mutex_t *mutex);
int pthread_mutex_unlock(pthread_mutex_t *mutex); 

基本上所有问题能可以用互斥锁来解决,但并不代表所有情况都适合采用这个方案。像是一些多资源分配、线程步调通知等场合,使用互斥锁就会由于等待互斥锁释放的时间太多而影响系统处理效率。所以,像一些读多写少的场合,就比较适合读写锁;临界区较短的场合,就比较适合空转锁。

使用互斥锁需要考虑死锁的问题。单个互斥锁是不会引发死锁的,但进入一段临界区需要多个互斥锁时,就很容易导致死锁。解决的方法通常是,申请锁的时候按照固定顺序,及时释放不需要的互斥锁

读写锁 Reader-Writer Lock

读写锁,有时候也称共享锁(shared-Exclusive Lock)。在现实中,读取数据并不影响数据内容本身,而写操作则会对数据内容进行修改。因此使用读写锁可以减少互斥锁导致的阻塞延迟。

当一个线程加了读锁访问临界区,另一个线程也想访问的时候,也可以加一个读锁,然后进行读操作。当第三个贤臣需要进行写操作时,它需要加一个写锁,这个写锁只有在读锁的拥有者为 0 的时候才有效,也就是等前两个读锁都被释放之后,该线程才能开始进行写操作。

int pthread_rwlock_init(pthread_rwlock_t *restrict rwlock, const pthread_rwlockattr_t *restrict attr);
int pthread_rwlock_destroy(pthread_rwlock_t *rwlock);
// 阻塞请求。
int pthread_rwlock_rdlock(pthread_rwlock_t *rwlock);
int pthread_rwlock_wrlock(pthread_rwlock_t *rwlock);
int pthread_rwlock_unlock(pthread_rwlock_t *rwlock);
// 非阻塞请求
int pthread_rwlock_tryrdlock(pthread_rwlock_t *rwlock);
int pthread_rwlock_trywrlock(pthread_rwlock_t *rwlock);

实际上由于大部分系统默认以读进程优先,所以很容易就会出现写进程饥饿的情况,也就是它必须等到所有的读锁都释放之后,才能进行申请写锁。不同的系统的实现版本对读写的优先级实现不同,有的是写进程优先,有的是读进程优先。因此,最好的策略是考虑实际情况,设置适当的优先级。

自旋锁 Spin Lock

自旋锁是互斥锁、读写锁的基础。在互斥锁和读写锁申请加锁的时候,线程会被阻塞。阻塞的过程分为两个阶段,第一阶段会进行空转,相当于一个 while 循环,不断地去申请锁。在空转一定时间之后,线程就会进入 waiting 状态,此时线程就不占用 CPU 资源了。等到锁可用的时候,这个线程就会被重新唤醒。结合这两个阶段是为了考虑效率因素:

  • 如果在申请锁失败以后,立刻将线程状态挂起,那么会带来上下文切换的开销。但如果锁在第一次申请失败之后就可用了,那么短时间内进行上下文切换就会显得很没效率。
  • 如果在申请锁失败之后,依然不断地轮询申请加锁,那么可以避免上下文切换的开销,但浪费的宝贵的 CPU 时间。如果需要等待很长时间之后,锁才能申请成功,那么 CPU 长时间进行轮询就显得效率很低。
int pthread_spin_init (__pthread_spinlock_t *__lock, int __pshared);
int pthread_spin_destroy (__pthread_spinlock_t *__lock);
int pthread_spin_trylock (__pthread_spinlock_t *__lock);
int pthread_spin_unlock (__pthread_spinlock_t *__lock);
int pthread_spin_lock (__pthread_spinlock_t *__lock);

从自旋锁的特性来看,自旋锁非常适合临界区非常短的场合,或者实时性要求比较高的场合;如果临界区需要在中断上下文访问,则必须使用自旋锁。由于临界区短,线程需要等待的时间也短,即便轮询浪费 CPU 资源,也浪费不了多少,还省了上下文切换的开销。 由于实时性要求比较高,来不及等待上下文切换的时间,那就只能浪费 CPU 资源在那儿轮询了。

自旋锁是一种保护数据结构或者代码片段的原始方式,主要用于 SMP 中,用于 CPU 同步,在某个时刻只允许一个进程访问临界区内的代码。它的实现是基于 CPU 锁定数据总线的指令。

不过说实话,大部分情况都不会直接用到自旋锁,其他锁在申请不到加锁时也是会空转一定时间的。如果连这段时间都无法满足请求,那要么就是线程太多,或者临界区并没有想象的那么短。

信号量

信号量广泛用于进程或者线程间的同步与互斥,本质上是一个非负的整数计数器,它被用来控制对公共资源的访问。编程时可根据操作信号量值的结果判断是否对公共资源具有访问的权限,当信号量值大于 0 时,则可以访问,否则将阻塞。PV 原语是对信号量的操作,一次 P 操作使信号量减1,一次 V 操作使信号量加1

信号量适合于保持时间较长的场景,且共享的内存只能在进程上下文使用。信号量是进程级的,用于多个进程之间对资源的互斥。竞争不上的进程,会有上下文切换,进程可以去睡眠,但此时 CPU 不会停止,会接着运行其他的执行路径。这里跟单核 CPU 或者多核 CPU 没有直接的关系,只是在信号量的实现上,为了保证信号量结构存取的原子性,在多核 CPU 中需要自旋锁来实现互斥

内核信号量

由内核控制路径使用,只有可以睡眠的函数才能获取内核信号量。中断处理程序和可延迟函数都不能使用内核信号量。

struct semaphore {
   atomic_t count;
   int sleepers;
   wait_queue_head_t wait;
  }
POSIX信号量
无名信号量

使用方法跟使用一般的变量相同,直接声明即可。无名信号量常用于多线程间的同步,同时也用于相关进程间的同步。无名信号量直接保存在内存中。

int sem_init(sem_t *sem, int pshared, unsigned int value);
int sem_wait(sem_t *sem);
int sem_trywait(sem_t *sem);
int sem_post(sem_t *sem);
int sem_destroy(sem_t *sem);
有名信号量

有名信号量要求创建一个文件。因此有名信号量既可以用于线程之间,也可以用于相关进程间,甚至是不想管的进程。一般来说,有名信号量用于进程间同步或者互斥。

无名信号量和有名信号量两者的区别与管道及命名管道的区别类似。

sem_t *sem_open(const char *name, int oflag, mode_t mode , int value);
二进制信号量与互斥信号量

二进制信号量是技术信号量中的一种特殊情况。二进制只能取 0 或者 1。两者实现互斥的方式不一样。对于互斥信号量,谁申请的锁谁释放,二进制信号量则不一定,可以由其他任务释放锁。

顺序锁 Sequence Lock

用于能够区分读与写的场合,并且是读操作很多、写操作很少,写操作的优先权大于读操作

  • seqlock 的实现思路是,用一个递增的整型数表示 sequence。
  • 写操作进入临界区时,sequence++;退出临界区时,sequence再++。写操作还需要获得一个锁(比如 mutex),这个锁仅用于写写互斥,以保证同一时间最多只有一个正在进行的写操作。
  • 当 sequence 为奇数时,表示有写操作正在进行,这时读操作要进入临界区需要等待,直到 sequence 变为偶数。读操作进入临界区时,需要记录下当前 equence 的值,等它退出临界区的时候用记录的 sequence 与当前 sequence 做比较,不相等则表示在读操作进入临界区期间发生了写操作,这时候读操作读到的东西是无效的,需要返回重试

RCU 锁

当读操作要调用 rcu_dereference 访问对象之前,需要先调用 rcu_read_lock;当不再需要访问对象时,调用rcu_read_unlock。

当写操作调用 rcu_assign_pointer 完成对对象的更新之后,需要调用 synchronize_rc u或 call_rcu。其中 synchronize_rcu会 阻塞等待在此之前所有调用了 rcu_read_lock 的读操作都已经调用 rcu_read_unlock, synchronize_rcu 返回后写操作一方就可以将被它替换掉的旧对象释放了;而 call_rcu 则是通过注册回调函数的方式,由回调函数来释放旧对象,写操作一方将不需要阻塞等待。同样,等到在此之前所有调用了 rcu_read_lock 的读操作都调用 rcu_read_unlock 之后,回调函数将被调用。

死锁

死锁是指两个或两个以上进程由于竞争有限资源或者彼此通信所造成的一种阻塞现象。如果没有外力作用,那么死锁涉及到的各个进程都将永远处于封锁状态,称为死锁进程。

产生原因

产生死锁的四个必要条件:

  1. 互斥条件:一个资源每次只能被一个进程使用,被占用时,别的进程不能使用。
  2. 请求与保持条件:一个进程因请求资源而阻塞时,对已获得的资源保持不放。
  3. 不剥夺条件:进程已获得的资源,在末使用完之前,不能强行剥夺,只能等待其主动释放。
  4. 循环等待条件:若干进程之间形成一种头尾相接的循环等待资源关系(P2 等待 P1,P3 等待 P2,P1 等待 P3)。

因此,产生死锁的原因就包括:(资源竞争 + 进程间推进顺序非法)

  1. 资源数量不足导致进程竞争,引起进程死锁。
  2. 多方分别占用其他进程需要的不可剥夺资源,相互阻塞,造成死锁。
  3. 多个进程竞争临时资源,形成循环等待条件。
  4. 进程推进顺序非法(可能由上述操作产生)

如何避免(预防)死锁

针对产生死锁的四个必要条件,只要打破其中一个条件,就能有效预防死锁的发生,如:

  1. 打破互斥条件:改造独占性资源为虚拟资源
  2. 打破请求与保持条件:采用资源预先分配策略,即进程运行前申请全部资源,满足则运行,不然就等待,这样就不会占有且申请。
  3. 打破不剥夺条件:当一进程占有一独占性资源后又申请一独占性资源而无法满足,则退出原占有的资源。
  4. 打破循环等待条件:实现资源有序分配策略,对所有设备实现分类编号,所有进程只能采用按序号递增的形式申请资源。

具体的操作可以有:

  1. 以某种规则对所有资源统一编号。设备按顺序依次申请,每次必须依次申请完所有所需资源。
  2. 确定获得锁的顺序。在设计时就充分考虑不同进程之间获得锁的顺序。
  3. 超时放弃。在获取锁超时时,主动释放之前已经获得的所有的锁。
  4. 银行家算法
    • 在银行中,客户申请贷款的数量是有限的,每个客户在第一次申请贷款时要声明完成该项目所需的最大资金量,在满足所有贷款要求时,客户应及时归还。银行家在客户申请的贷款数量不超过自己拥有的最大值时,都应尽量满足客户的需要。在这样的描述中,银行家就好比操作系统,资金就是资源,客户就相当于要申请资源的进程。
    • 银行家算法是一种最有代表性的避免死锁的算法。在避免死锁方法中允许进程动态地申请资源,但系统在进行资源分配之前,应先计算此次分配资源的安全性,若分配不会导致系统进入不安全状态,则分配,否则等待。为实现银行家算法,系统必须设置若干数据结构。

消除死锁的方法

在死锁检查时发现了死锁情况,那么就要努力消除死锁,使系统从死锁状态中恢复过来。消除死锁包括以下几种方式:

  1. 系统重新启动。这是最简单、最常用、最直接的方法,不过这种方法代价很大,它意味着在这之前所有的进程已经完成的计算工作都将付之东流,包括参与死锁的那些进程,以及未参与死锁的进程。

  2. 撤消进程,剥夺资源。终止参与死锁的进程,收回它们占有的资源,从而解除死锁。

    • 一次性撤消参与死锁的全部进程,剥夺全部资源;

    • 逐步撤消参与死锁的进程,逐步收回死锁进程占有的资源。

  3. 进程回退策略,即让参与死锁的进程回退到没有发生死锁前某一点处,并由此点处继续执行,以求再次执行时不再发生死锁。虽然这是个较理想的办法,但是操作起来系统开销极大,要有堆栈这样的机构记录进程的每一步变化,以便今后的回退,有时这是无法做到的。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值