2.22 什么是死锁
在多道程序环境下,多个进程可以竞争有限数量的资源。当一个进程申请资源时,如果这时没有可用资源,那么这个进程进入等待状态。有时,如果所申请的资源被其他等待进程占有,那么该等待进程有可能再也无法改变状态。这种情况称为 死锁。
2.23 死锁的四个必要条件
如果系统中以下四个条件同时成立,那么就能引起死锁:
- 互斥:资源必须处于非共享模式,即一次只有一个进程可以使用。如果另一进程申请该资源,那么必须等待直到该资源被释放为止。
- 请求与等待:一个进程至少应该占有一个资源,并等待另一资源,而该资源被其他进程所占有。
- 不可剥夺:资源不能被剥夺。只能在持有资源的进程完成任务后,该资源才会被释放。
- 环路等待:有一组等待进程 {P0, P1,…, Pn}, P0 等待的资源被 P1 占有,P1 等待的资源被 P2 占有,…,Pn-1 等待的资源被 Pn 占有,Pn 等待的资源被 P0 占有。
注意,只有四个条件同时成立时,死锁才会出现。
2.24 解决死锁的方法
解决死锁的方法可以从多个角度去分析,一般的情况下,有预防,避免,检测和解除四种。
- 预防 是采用某种策略,限制并发进程对资源的请求,从而使得死锁的必要条件在系统执行的任何时间上都不满足。
- 避免则是系统在分配资源时,根据资源的使用情况提前做出预测,从而避免死锁的发生。
- 检测是指系统设有专门的机构,当死锁发生时,该机构能够检测死锁的发生,并精确地确定与死锁有关的进程和资源。
- 解除 是与检测相配套的一种措施,用于将进程从死锁状态下解脱出来。
死锁的预防
只要破坏四个必要条件中的任何一个就能够预防死锁的发生。
- 破坏第一个条件 **互斥条件:**使得资源是可以同时访问的,这是种简单的方法,磁盘就可以用这种方法管理,但是我们要知道,有很多资源 往往是不能同时访问的 ,所以这种做法在大多数的场合是行不通的。
- 破坏第三个条件 非抢占:也就是说可以采用 剥夺式调度算法,但剥夺式调度方法目前一般仅适用于 主存资源 和 处理器资源 的分配,并不适用于所有的资源,会导致 资源利用率下降。
所以一般比较实用的 预防死锁的方法,是通过考虑破坏占有并等待和循环等待。
1、静态分配策略
静态分配策略可以破坏死锁产生的第二个条件(占有并等待)。
- 所谓静态分配策略,就是指一个进程必须在执行前就申请到它所需要的全部资源,并且知道它所要的资源都得到满足之后才开始执行。进程要么占有所有的资源然后开始执行,要么不占有资源,不会出现占有一些资源等待一些资源的情况。
- 静态分配策略逻辑简单,实现也很容易,但这种策略 严重地降低了资源利用率,因为在每个进程所占有的资源中,有些资源是在比较靠后的执行时间里采用的,甚至有些资源``是在额外的情况下才是用的,这样就可能造成了一个进程占有了一些 几乎不用的资源而使其他需要该资源的进程产生等待 的情况。
2、层次分配策略
层次分配策略破坏了产生死锁的第四个条件(循环等待)。
- 在层次分配策略下,所有的资源被分成了多个层次,一个进程得到某一层次的一个资源后,它只能再申请较高一层的资源。
- 当一个进程要释放某层的一个资源时,必须先释放所占用的较高层的资源,按这种策略,是不可能出现循环等待链的,因为那样的话,就出现了已经申请了较高层的资源,反而去申请了较低层的资源,不符合层次分配策略。
死锁的避免
1、预防死锁与避免死锁的区别
破坏 死锁产生的四个必要条件之一就可以成功 预防系统发生死锁 ,但是会导致 低效的进程运行 和 资源使用率 。
死锁的避免相反,它的角度是允许系统中同时存在四个必要条件 ,只要掌握并发进程中与每个进程有关的资源动态申请情况,做出 明智和合理的选择 ,仍然可以避免死锁,因为四大条件仅仅是产生死锁的必要条件。
2、如何避免死锁(银行家算法-典型)
我们将系统的状态分为 安全状态 和 不安全状态 ,每当在未申请者分配资源前先测试系统状态,若把系统资源分配给申请者会产生死锁,则拒绝分配,否则接受申请,并为它分配资源。
- 如果操作系统能够保证所有的进程在有限的时间内得到需要的全部资源,则称系统处于安全状态,否则说系统是不安全的。很显然,系统处于安全状态则不会发生死锁,系统若处于不安全状态则可能发生死锁。
那么如何保证系统保持在安全状态呢?通过算法,其中最具有代表性的 避免死锁算法 就是 Dijkstra 的银行家算法。
- 银行家算法用一句话表达就是:当一个进程申请使用资源的时候,银行家算法 通过先 试探 分配给该进程资源,然后通过 安全性算法 判断分配后系统是否处于安全状态,若不安全则试探分配作废,让该进程继续等待,若能够进入到安全的状态,则就 真的分配资源给该进程。
- 银行家算法详情可见:《一句话+一张图说清楚——银行家算法》open in new window 。
死锁的避免(银行家算法)改善解决了 资源使用率低的问题 ,但是它要不断地检测每个进程对各类资源的占用和申请情况,以及做 安全性检查 ,需要花费较多的时间。
死锁的检测
对资源的分配加以限制可以 预防和避免 死锁的发生,但是都不利于各进程对系统资源的充分共享。
解决死锁问题的另一条途径是 死锁检测和解除 :
- 感觉死锁的检测和解除就像是 乐观锁 ,分配资源时不去提前管会不会发生死锁了,等到真的死锁出现了再来解决嘛,而 死锁的预防和避免 更像是悲观锁,总是觉得死锁会出现,所以在分配资源的时候就很谨慎。
- 这种方法对资源的分配不加以任何限制,也不采取死锁避免措施,但系统 定时地运行一个 “死锁检测” 的程序,判断系统内是否出现死锁,如果检测到系统发生了死锁,再采取措施去解除它。
1、进程—资源分配图
操作系统中的每一刻时刻的系统状态都可以用进程-资源分配图来表示,进程-资源分配图是描述进程和资源申请及分配关系的一种有向图,可用于检测系统是否处于死锁状态。
- 用一个方框表示每一个资源类。
- 方框中的黑点表示该资源类中的各个资源。
- 每个键进程用一个圆圈表示。
- 用 有向边 来表示进程申请资源和资源被分配的情况。
图中 2-21 是进程-资源分配图的一个例子。
- 其中共有三个资源类,每个进程的资源占有和申请情况已清楚地表示在图中。
- 在这个例子中,由于存在 占有和等待资源的环路 ,导致一组进程永远处于等待资源的状态,发生了 死锁。
进程-资源分配图中存在环路并不一定是发生了死锁。因为循环等待资源仅仅是死锁发生的必要条件,而不是充分条件。
图 2-22 便是一个有环路而无死锁的例子。
- 虽然进程 P1 和进程 P3 分别占用了一个资源 R1 和一个资源 R2,并且因为等待另一个资源 R2 和另一个资源 R1 形成了环路,但进程 P2 和进程 P4 分别占有了一个资源 R1 和一个资源 R2,它们申请的资源得到了满足,在有限的时间里会归还资源,于是进程 P1 或 P3 都能获得另一个所需的资源,环路自动解除,系统也就不存在死锁状态了。
2、死锁监测步骤
1. 如果进程-资源分配图中无环路,则此时系统没有发生死锁。2. 如果进程-资源分配图中有环路,且每个资源类仅有一个资源,则系统中已经发生了死锁。3. 如果进程-资源分配图中有环路,且涉及到的资源类有多个资源,此时系统未必会发生死锁。如果能在进程-资源分配图中找出一个 **既不阻塞又非独立的进程** ,该进程能够在有限的时间内归还占有的资源,也就是把边给消除掉了,重复此过程,直到能在有限的时间内 **消除所有的边** ,则不会发生死锁,否则会发生死锁。(消除边的过程类似于 **拓扑排序**)
死锁的解除
当死锁检测程序检测到存在死锁发生时,应设法让其解除,让系统从死锁状态中恢复过来,常用的解除死锁的方法有以下四种:
- 立即结束所有进程的执行,重新启动操作系统 :这种方法简单,但以前所在的工作全部作废,损失很大。
- 撤销涉及死锁的所有进程,解除死锁后继续运行 :这种方法能彻底打破死锁的循环等待条件,但将付出很大代价,例如有些进程可能已经计算了很长时间,由于被撤销而使产生的部分结果也被消除了,再重新执行时还要再次进行计算。
- 逐个撤销涉及死锁的进程,回收其资源直至死锁解除。
- 抢占资源 :从涉及死锁的一个或几个进程中抢占资源,把夺得的资源再分配给涉及死锁的进程直至死锁解除。
2.25 协程是怎么实现的
1、协程执行
每个线程拥有自己的线程函数,相应,每个协程都拥有一个自己的协程函数。线程函数每次从函数的第一句开始执行,参数,局部变量等都保留在线程栈上,函数返回,清空栈信息。但是协程函数所有信息保留在堆中,协程函数第一次执行,在堆中动态分配一个协程上下文,其中包含,局部变量,参数,以及交出控制权的位置,交出控制权后,堆中信息不会被删除,下一次被执行会从堆中恢复上下文信息。协程函数结束,清空对应堆中的信息。
之前提过协程是基于线程的,在用户层面上维护一个数据结构与多个线程(线程池)。协程函数在一个队列中维护,多个线程(线程池)从队列中取出协程函数执行。
2、协程调度
协程没有cpu权限, 无法使用cpu去完成调度。那么协程如何实现调度的呢?
2.1、有栈协程
创建大量协程,这些协程绑定在一个线程上(主协程)。并且每一个协程保留一个私有栈。协程执行到异步IO处,交出控制权给主协程,由主协程进行调度分配。
- 处于同一线程,协程间不存在竞态关系。
- 带有协程栈,所以可以再任意节点交出控制权
有栈协程保存调用栈信息空间消耗太大,空间使用共享栈来解决这种问题,但是也会带来相应的copy问题。
2.2、无栈协程
封装系统异步IO函数作为协程函数,协程一旦发起异步IO操作后,保留当前信息,控制权交付当前执行函数,线程去队列中拉取另一个协程函数执行。异步IO完成后重新获取控制权,恢复上下文环境继续执行之前协程函数。
- 只有在异步IO操作是才能交出控制权。
- 无需住协程调度
- 线程A执行异步协程B的IO操作,协程B交出控制权,线程C执行,此时协程B恢复操作。因为B不在线程C中。所以可能存在竞态关系。
无栈协程需要标准语言与编译器支持。
由于协程必须支持 挂起/恢复,因此对于挂起点的状态保存就显得极其关键。我们知道,线程在切换时,它的中断状态会保存在调用栈中。事实上,协程的中断状态也可以通过开辟相应的调用栈来保存。因此,按照是否开辟相应的调用栈,我们可以将协程分为两类:
- 有栈协程(Stackful Coroutine):每个协程都有自己的调用栈,类似于线程的调用栈。
- 无栈协程(Stackless Coroutine):协程没有自己的调用栈,挂起点的状态通过状态机或闭包等语法来实现。
类似微信的 libco、阿里的 cooobjc、Golang 中的 goroutine、Lua 中的协程都是有栈协程;类似 ES6、Dart 中的 await/async、Python 的 Generator、Kotlin 中的协程、C++20 中的 cooroutine 都是无栈协程。
初识协程
2.26 多线程冲突了怎么办?
多线程冲突,也就是线程安全问题。线程安全,也就是要确保在多线程访问的时候,我们的程序还能按照我们预期的行为去执行,或者说在多线程执行时可以得到我们预期的结果。而导致线程不安全的原因主要有原子性和可见性。
如果是原子性导致的,可以通过加锁或者使用互斥信号量来解决。总之,就是对临界区代码进行互斥访问,确保同一时间只有一个线程在执行临界区代码。
那什么是可见性导致呢?比如说,比如说有一个共享变量 i ,开两个线程去处理 i++ ,另外开两个进程去读取 i ,这个时候就会产生意想不到的结果,也就是线程不安全。
那可见性导致的线程不安全怎么去解决呢?
可见性导致的线程不安全的例子
// The following code demonstrates a thread safety issue caused by visibility
// 可见性导致的线程安全问题public class VisibilityIssue {private static boolean stop = false;public static void main(String[] args) throws InterruptedException {Thread backgroundThread = new Thread(() -> {int i = 0;while (!stop) {i++;}System.out.println("Background thread stopped. Counted up to " + i);});backgroundThread.start();Thread.sleep(1000);stop = true; // 更改了缓存的值,但是由于写时复制,导致线程不可见更改后的值System.out.println("Main thread stopped background thread.");}
}// One solution to the visibility issue causing thread safety problems is to use the volatile keyword
// 使用volatile关键字解决可见性导致的线程安全问题public class VisibilityIssue {// 使用 volatile 当值发生变化时直接写回地址private static volatile boolean stop = false;public static void main(String[] args) throws InterruptedException {Thread backgroundThread = new Thread(() -> {int i = 0;while (!stop) {i++;}System.out.println("Background thread stopped. Counted up to " + i);});backgroundThread.start();Thread.sleep(1000);stop = true;System.out.println("Main thread stopped background thread.");}
}
这其实是线程同步问题。
2.27 孤儿进程与僵尸进程的区别
我们知道在unix/linux中,正常情况下,子进程是通过父进程创建的,子进程在创建新的进程。子进程的结束和父进程的运行是一个异步过程,即父进程永远无法预测子进程 到底什么时候结束。 当一个进程完成它的工作终止之后,它的父进程需要调用wait()或者waitpid()系统调用取得子进程的终止状态。
孤儿进程:一个父进程退出,而它的一个或多个子进程还在运行,那么这些子进程将成为孤儿进程。孤儿进程将被 init进程(进程号为1) 所收养,并由 init 进程对它们完成状态收集工作。
僵尸进程:一个进程使用fork创建子进程,如果子进程退出,而父进程并没有调用wait或waitpid获取子进程的状态信息,那么子进程的进程描述符仍然保存在系统中。这种进程称之为僵尸进程。
2.28 后台进程和守护进程有什么区别
1、守护进程已经完全脱离终端控制台了,而后台程序并未完全脱离终端(在终端未关闭前还是会往终端输出结果);
2、守护进程在关闭终端控制台时不会受影响,而后台程序会随用户退出而停止,需要在以 nohup command &
格式运行才能避免影响;
3、守护进程的会话组和当前目录,文件描述符都是独立的。后台运行只是终端进行了一次fork,让程序在后台执行,这些都没改变;
2.29 守护进程的特点
守护进程(Daemon)是在后台运行的一种特殊进程,它脱离于终端,从而可避免进程被任何终端所产生的信号打断,它在执行进程中的产生信息也不在任何终端上显示。守护进程周期性地执行某种任务或等待处理某些发生的事件,Linux的大多数服务器就是用守护进程实现的。
2.30 操作系统有哪几种锁
在Linux中常见的锁有:互斥锁、读写锁、自旋锁、RCU。
1. 互斥锁:mutex
互斥锁:mutex,用于保证在任何时刻,都只能有一个线程访问该对象。当获取锁操作失败时,线程会进入睡眠,等待锁释放时被唤醒。
2. 读写锁 rwlock
读写锁:rwlock,分为读锁和写锁。处于读操作时,可以允许多个线程同时获得读操作。但是同一时刻只能有一个线程可以获得写锁。其它获取写锁失败的线程都会进入睡眠状态,直到写锁释放时被唤醒。
注意:写锁会阻塞其它读写锁。当有一个线程获得写锁在写时,读锁也不能被其它线程获取;写者优先于读者(一旦有写者,则后续读者必须等待,唤醒时优先考虑写者)。
- 适用于读取数据的频率远远大于写数据的频率的场合。
3. 自旋锁:spinlock
自旋锁:spinlock,在任何时刻同样只能有一个线程访问对象。但是当获取锁操作失败时,不会进入睡眠,而是会在原地自旋,直到锁被释放。这样节省了线程从睡眠状态到被唤醒期间的消耗,在加锁时间短暂的环境下会极大的提高效率。但如果加锁时间过长,则会非常浪费CPU资源。
4. RCU
RCU:即read-copy-update,在修改数据时,首先需要读取数据,然后生成一个副本,对副本进行修改。修改完成后,再将老数据update成新的数据。
使用RCU时,读者几乎不需要同步开销,既不需要获得锁,也不使用原子指令,不会导致锁竞争,因此就不用考虑死锁问题了。而对于写者的同步开销较大,它需要复制被修改的数据,还必须使用锁机制同步并行其它写者的修改操作。在有大量读操作,少量写操作的情况下效率非常高。