2.1.1 进程的概念、组成、特征
1.进程的概念
:kissing:程序:是静态的,,就是一系列的指令集合。
:kissing_cat:进程:是动态的,是程序的一次执行过程,同一个程序多次执行会对应多个进程。比如打开了好几个QQ,会在任务管理器种发现有好几个QQ正在运行。
2.进程的组成
PCB:
:question:操作系统是这些进程的管理者,它要怎么区分这些进程?
当进程被创建时,操作系统会为该进程分配一个唯一的,不重复的“身份证号”---PID(Process ID,进程ID)
在任务管理器中,还记录了每个进程使用了多少CPU,内存,硬盘,网络流量等,这些都被记录了下来。
这些信息都被保存在了一个数据结构PCB(进程控制块)中。
操作系统需要对各个并发进程就行管理,但凡管理时所需要的信息,都被放在PCB中。
:avocado:程序段:程序要执行的代码
:apple:数据段:运行过程中产生的各种数据(如程序中定义的变量)
程序段和数据段是给进程自己使用的,而PCB是给操作系统使用的,它俩各干自己的事情。
程序是如何运行的?
一个C语言程序经过编译后,会存入到硬盘中变成一个可执行文件,要运行这个程序的话,需要先把这个程序放入内存中,操作系统会为这个进程创建一个PCB和程序段以及数据段。程序段放入要执行的代码,也就是一条条指令,执行指令中会有变量被定义,所以要放入到数据段中,数据段包含所有的数据信息。最后打印输出。
一个进程实体(进程映像)由PCB、程序段、数据段组成,进程是动态的,但进程实体是静态的,进程实体就相当于一个视频截了一张图,显示某个状态的所有信息,而不是整个过程。
进程:是进程实体的运行过程,是系统进行资源分配和调度的一个独立单位,
调度:一个进程被调度,就是指操作系统绝对让这个进程上CPU运行
3.进程的特征
- 动态性:进程是程序的一次执行,是动态地参数、变化和消亡的过程。
- 并发性:内存中有多个进程实体,各进程可以并发执行
- 独立性:进程是能独立运行、独立获得资源、独立接受调度的基本单位。
- 异步性:各个进程按各自独立的,不可预知的速度向前推进,操作系统要提供“进程同步机制”来解决异步问题。
- 结构性:每个进程都会配置一个PCB,结构上看,进程由程序段、数据段、PCB组成。
4.总结
2.1.2 进程的状态与转换、进程的组织
1进程的状态
:ice_cream:创建态:进程正在被创建时,它的状态是“创建态”,在这个阶段操作系统会为进程分配资源,初始化PCB
:jack_o_lantern:就绪态:当进程创建完成之后,便进入“就绪态”,处于就绪态的进程以及具备运行条件,但如果没有空闲CPU的话,就暂时不能运行。
:bread:运行态:当CPU空闲时,操作系统就回选择一个就绪进程,让它上CPU运行。在CPU运行的进程就处于运行态。CPU会执行该进程对应的程序。
:apple: 阻塞态:如果正在运行的程序想要请求某个时间的发生(如等待系统资源的分配,或者等待其他进程的响应),就不会在进行下去,而是转为阻塞状态。此时CPU就可以选择一个就绪态的进程上CPU执行。如果阻塞态想要请求的那个时间发生了,那么操作系统就会把这个进程转回就绪态。
:banana:终止态:一个进程可以执行exit系统调用,请求操作系统终止该进程,此时该进程会进入”终止态“,操作系统会让该进程下CPU,并回收内存空间等资源,最后还要回收进程的PCB,当终止进程的工作完成之后,这个进程就彻底消失了。(我挥一挥衣袖,不带走一片云彩)
2.进程状态的转换
如下图:
进程PCB中,会有一个变量state
来表示进程的当前状态,如1表示创建态,2表示就绪态...(这让了想起了算法里的状态机)为了对同一个状态下的各个进程进行一个统一的管理,操作系统会将各个进程的PCB组织起来。那怎么组织呢?(数据结构)
3.进程的组织
:leaves:链表方式
:christmas_tree:索引方式
总结
2.1.3 进程控制
:triangular_flag_on_post: 争取用最简洁的话语去解释概念性的东西 -- 费曼学习法
1.什么是进程控制
:eggplant:进程控制的主要功能是对系统中的所有进程实施有效的管理,它具有创建新进程、撤销已有进程、实现进程状态转换等功能。总的来说就是实现进程状态转换。创建新进程也是进程从无到有,撤销进程也是从有到无。
2.如何实现进程控制
:seedling: 用原语来实现,原语是一种特殊的程序,它具有原子性,这段程序的运行必须是一气呵成,不可中断。
:question:为什么进程控制要“一气呵成”?
假设现在有一个进程是阻塞态,被存储到阻塞队列中,然后它的等待事件发生了,操作系统为让它转为就绪态,在这个过程中,需要做两件事
- 将PCB中的
state
改为1,表示已经变为就绪态 - 将进程从阻塞队列放到就绪队列
如果在执行完步骤1之后,外面突然有中断信号,此时CPU不执行步骤2而是去处理中断的话,那么步骤2就没有执行,进程还留在阻塞队列中。可能存在的后果:阻塞队列的下一个等待事件发生,却发现队列指针指的第一个进程的状态却是1,并且PCB中的信息和等待时间不同,这样就会造成其他的后果,不便于管理。所以必须要“一气呵成”。
3.如何实现原语的“原子性”?
:pineapple:原语的执行具有原子性,即执行过程只能一气呵成。期间不允许被中断。可以用关中断指令和开中断指令这两个特权指令来实现原子性
:champagne:当执行到关中断指令后,无论怎么外部中断信号都不去处理,也不检查,知道执行完开中断指令后才会恢复检查。这样,关中断和开中断之间的指令序列都是不会被中断的,这样就实现了原子性。
:thinking:如果这两个特权指令允许用户程序使用的话,会发什么什么?
程序可能会在运行开始前就执行关中断指令,执行结束后才执行开中断指令,这样它就可以一直执行下去。
4.进程控制相关的原语
:star:进程的创建
创建原语:
- 申请空白PCB(申请一个职位)
- 为新进程分配所需资源(提供工作条件)
- 初始化PCB(整出一个工位)
- 将PCB插入就绪队列(上岗)
引起进程创建的条件:
- 用户登录:分时系统中,用户登录成功,系统会为其建立一个新的进程
- 作业调度:多道批处理系统中,有新的作业放入内存时,会为其建立一个新的进程
- 提供服务:用户向操作系统提出请求,系统会创建一个进程处理这个请求
- 应用请求:用户进程主动请求创建一个子进程
:star:进程的终止
撤销原语:(这里我引用《西红柿首富》里的剧情)
- 从PCB集合中找到终止进程的PCB(老板要炒王多鱼)
- 若进程正在运行,立即剥夺CPU,将CPU分配给其他进程(你不干,有的是人干,狗也能当守门员)
- 终止其所有子进程(双喜临门,一言为定)
- 将该进程拥有的所有资源都归还给父进程或操作系统(人走了,东西留下)
- 删除PCB(从此再也不见)
系统中所有进程其实是一个树形结构,系统会有一个最大的祖先进程,管理着它的所有子进程。
引起进程终止的事件:
- 正常结束:进程自己请求终止(exit系统调用)
- 异常结束:整数除于0,非法使用特权指令,然后被操作系统强行关掉
- 外界干预:任务管理器强制终止进程
:star:进程的阻塞
阻塞原语:
- 找到要阻塞的进程对应的PCB
- 保护进程运行现场,将PCB状态信息设置为”阻塞态“,暂时停止进程运行(保护好现场是为了再次运行时环境不会有太大的变化,不然可能出现一些不可预知的麻烦)
- 将PCB插入相应事件的等待序列
引起阻塞的事件:
- 需要等待系统分配某资源
- 需要等待相互合作的其他进程完成工作
:star:进程的唤醒:
唤醒原语:
- 在等待事件中找到相应的PCB
- 将PCB从阻塞队列移除,改为就绪态
- 将PCB插入就绪队列中,等待被调度
引起进程唤醒的条件:
- 等待的事件发生,因为什么事情被阻塞,就应该由什么事情唤醒
:star:进程的切换
切换原语:
- 将运行环境信息存入PCB
- PCB移入相应队列
- CPU选择另一个进程运行,并更新它的PCB信息
- 根据PCB恢复新进程所需的运行环境
引起进程切换的条件:
- 事件片到
- 有更高优先级的进程到达
- 当前进程主动阻塞
- 当前进程终止
为什么要保存环境信息(保护现场)
:chestnut:举个例子:用C语言写1+2+3+..100的程序,当加到50的时候,程序突然被阻塞了,那当程序再次运行的时候,如果当前什么都没有,还是要从1加到100,那之前做的努力不久白费了吗,所以要保留1加到50的结果和已经加到了哪个数,保留了这些信息,那么在后续过程中就不用在重新计算了。这也就是为什么要保护现场。
总结
无论哪个进程控制原语,无非要做三件事
- 更新PCB
- 将PCB插入合适的队列
- 分配/回收资源
2.1.4 进程通信
1. 什么是进程通信?
:star:进程通信:进程通信就是指进程之间的信息交流。进程时分配系统资源的单位(包括内存地址空间),因此各进程拥有的内存地址空间相互独立。
:microphone:每个进程都是独立的,不可能让进程之间随意就可以读取信息(乱闯民宅是犯法的),如果随机读取了那还得了,你的信息都要被泄露。所以为了保证安全,一个进程不能直接访问另一个进程的内存地址空间。但是进程直接交换信息也是必要的(比如用QQ音乐的登录时,如果你已经登录了qq,那么就会获取到你的QQ信息,就可以直接点击登录了)。
2.共享存储
:star: 共享空间:为了实现两个进程之间的通信,操作系统会在内存中开辟一块内存空间供两个进程共同使用。但是两个进程对共享空间的访问必须是互斥的(我这边还在更改信息,你那边就要读信息,拿到的肯定是错误的信息啊,所以为了正确性,必须要保证互斥)
- 基于数据结构的共享:比如共享空间里只能放一个长度为10的数字,这种共享方式速度慢,限制多,是一种低级通信方式。
- 基于存储区的共享:在内存中开辟一块共享存储区,数据的形式、存放位置都由进程控制,而不是操作系统,这种方式速度快,是一种高级通信方式。(没有了限制,当然快啦)
3.管道通信
:star: 管道:是指用于连接读写进程的一个共享文件,其实就是在内存重开辟了一个大小固定的缓冲区。
- 管道只能采用半双工通信,某一时间段内只能实现单向的传输,如果要实现双向同时通信,则需要设置两个管道。
- 各进程要互斥地访问管道
- 数据以字符流地形式写入管道,当管道写满时,写进程地
write()
系统调用就被阻塞,等待读进程将数据取走,当取完时,管道变空,此时都进程的read()
系统调用将被阻塞。(写完了才能读,读完了才能写) - 管道中的数据一旦被读出就无法挽回,所以读进程只能有一个,否则可能会有读错数据的情况。
4. 消息传递
:star: 消息:消息包括消息头和消息体(信件一样),消息头包括了发送进程ID(发送方),接受线程ID(接收方),消息类型,消息长度等格式化信息。消息体包含要发送的信息。
- 直接通信方式:消息直接挂到接收进程的消息缓冲队列(每个进程都有一个接收消息的消息缓冲队列,每次取消息就从缓冲队列中取)当中去。
- 间接通信方式:消息发到邮箱中,接收方道邮箱中取消息。
总结
2.1.5 线程和多线程
1.什么是线程,为什么要引入线程?
:microphone: 最开始的操作系统,程序只能串行的访问,无法多个程序同时使用,而引入了进程之后,可以实现多个程序之间的并发。但是一个程序里包含很多事务要做,比如用QQ的时候,可能既要发短信,又要视频聊天还要发文件,进程是一次执行的过程,不可能让进程里的好几个功能同时实现,每次只能执行一个进程中的一个功能。所以为了让一个程序能够实现同时执行好几个功能,引入了线程。
:sailboat: 有的进程可能需要“同时”做很多事,而传统的进程只能串行地执行一系列程序,为此,加入了线程,来增加并发度。之前进程是程序执行流地最小单位。现在线程是CPU执行执行单元,也是程序执行流的最小单元。CPU执行的时候执行的是每个进程中的线程。
:smiley: 有了线程之后,不仅是进程之间可以并发了,进程内的各线程之间也可以并发,从而进一步提升了系统的并发度,使得一个进程内也可以并发处理各种任务(如QQ视频,聊天,传文件)
:happy: 引入线程后,进程只作为除CPU之外的系统资源的分配单元(如打印机、内存地址空间都是分配给进程的,进程内的线程共享这些资源)
2.引入线程机制后,有什变化?
:star2: 资源分配、调度:
- 传统进程资源中,进程是资源分配、调度的基本单位
- 引入线程后,进程是资源分配的基本单位,线程是调度的基本单位
:stars: 并发性:
- 传统进程机制中,只能进程间并发
- 引入线程后,各线程间也能并发,提升了并发度
:eight_pointed_black_star: 系统开销:
- 传统的进程间并发,需要切换进程的运行环境、系统开销大。(上网课和教室中上课,同样是上课,这变化可就太大了)
- 线程间并发,如果是同一个进程内的线程切换,则不需要切换进程环境,系统开销小
- 引入线程后,并发所带来的系统开销小
3. 线程的属性
- 线程是处理机调度的基本单位
- 多个CPU计算机中,各个线程可占用不同的CPU
- 每个线程都有一个线程ID,线程控制块(TCB)----> 和进程差不多,该有的都有
- 线程也有就绪、阻塞、运行三种基本状态
- 线程几乎不用有系统资源
- 同一进程的不同进程间共享进程的资源
- 由于共享内存地址空间,所以同一进程中的线程间通信基本无需系统干预,可以互用
- 同一进程中的线程切换、不会引起进程切换
- 不同进程中的线程切换,会引起进程切换
- 切换同进程中的线程,系统开销很小
- 切换进程,系统开销较大
4.线程的实现方式
:star: 用户级线程:早期的操作系统只支持进程,不支持线程。当时的“线程”是由线程库实现的(逻辑上的线程)。
void solve()
{
int i = 0;
while (true)
{
if (i == 0)
cout << "处理视频聊天的代码";
else if (i == 1)
cout << "处理文字聊天的代码";
else
cout << "处理文件传输的代码";
i = (i + 1) % 3;
}
}
这就相当于一个线程库,线程库里由三个线程,分别属于三个功能。线程库完成了对线程的管理工作(如调度)。
很多编程语言提供了强大的线程库,可以实现线程的创建、销毁、调度的过程。
:fish: 特点:
- 线程的管理工作是由应用程序中的线程库完成的,而不是操作系统
- 线程切换也不需要CPU变态,线程切换是由
while
循环管理的,不需要操作系统的干涉。 - 操作系统不知道用户级线程的存在,只知道包含这几个线程的进程,这也就是为什么叫用户级线程,因为只有用户才知道自己有哪些线程。
:beer: 优点:线程的切换在用户空间即可完成,不需要切换到核心态,线程管理的系统开销小,效率高。
:hamburger: 缺点:当一个用户级线程被阻塞后,整个进程都会被阻塞,虽然他们被分为三个线程,但还是属于一个整体进程,这个进程才是CPU调度的基本单位,一个阻塞了,全都阻塞了,这就导致并发度不搞了。而且如果有多个处理机的话,这个线程库里的线程也只能在一个处理机上执行,大大浪费了CPU资源。
:star: 内核级线程
:banana: 特点
- 线程是被操作系统内核管理的
- 线程调度、切换等工作都由内核负责,因此内核级线程切换的话就需要变态后才能完成。
- 操作系统会为每个内核级线程建立相应的TCB,通过TCB对线程进行管理,”内核级线程“就是从操作系统内核视角看到的线程。
:pig: 优点:当一个线程被阻塞时,别的线程还可以继续上CPU执行,并发能力强,多线程可以在多核处理机上并行执行。
:monkey: 缺点:一个用户进程会占用多个内核级线程,线程切换由OS内核来完成,需要切换到核心态,因此线程管理的成本高、开销大。
5.多线程模型
:star: 一对一模型:一个用户及线程映射到一个内核级线程,每个用户进程有于用户级线程同数量的内核级线程。
:pig: 优点:当一个线程被阻塞时,别的线程还可以继续上执行,并发能力强,多线程可以在多核处理机上并行执行。
:monkey: 缺点:一个用户进程会占用多个内核级线程,线程切换由OS内核来完成,需要切换到核心态,因此线程管理的成本高、开销大。
:star: 多对一模型:多个用户及线程映射到一个内核级线程,每个用户进程有于用户级线程同数量的内核级线程。
:hotdog: 优点:线程的切换在用户空间即可完成,不需要切换到核心态,线程管理的系统开销小,效率高。
:hamburger: 缺点:当一个用户级线程被阻塞后,整个进程都会被阻塞,并发度不高,多个线程不可在多喝处理机上并发运行。
:imp: 重点:操作系统只负责管理内核级线程,因此只有内核级线程才是处理机调度的基本单位。
:star: 多对多模型:多个用户及线程映射到多个内核级线程,每个用户进程有于用户级线程同数量的内核级线程。
:hotdog: 优点:克服了多对一模型并发度不高的缺点(一个阻塞全体阻塞),又克服了一对一模型中一个用户进程占用了太多的内核级现场,开销太大的缺点。一对一(浪费太多资源),多对一(无法实现并发)。多对多既能实现并发,又节省资源。
:bento: 划重点
用户级线程是”代码逻辑“的载体
内核级线程是”运行机会“的载体
一段”代码逻辑“只有获得了”运行机会“才能被CPU执行。内核级线程中可以运行一个有映像关系的用户级线程代码,只有一个进程的全部内核级线程中正在运行的代码逻辑都阻塞时,这个线程才会阻塞。
总结
2.2.1 调度
1.调度的概念
:star: 调度:在同一时刻,有很多任务需要处理,由于资源有限,这些事情没法同时处理。处理机会以某种规则来决定处理这些任务的顺序。
2.调度的三个层次
:star: 高级调度:由于内存优先,有时无法将用户提交的作业全部放入内存,所以需要调度,高级调度就是按照一定的规则从外村的作业后备队列种挑选一个作业调入内存,并创建进程。每个作业只调入一次,调出一次,所以发送的频率很低。作业调入时会建立PCB
,调出时才撤销PCB
。
:star2: 低级调度:内存中存在很多进程,但CPU资源有限,所以不可能同时运行这么多进程。低级调度(处理机调度)就是按照某种策略从就绪队列种选取一个进程,将处理及分配给它。进程调度时操作系统种最基本的一种调度,在一般的操作系统中都必须配置进程调度。进程调度的频率很高,一半几十毫秒一次。
:star2: 中级调度:当内存不足时,可将某些进程的数据调出外存,等内存空闲或者进程需要运行时在重新调入内存。展示调到外存等待的状态为挂起状态,被挂机的进程PCB会被组织成挂起队列。当内存充足时,中级调度(内存调度)就会按照某种策略绝对将哪个处于挂起状态的进程重新调入内存。一个进程可能会被多次调出,调入内存。因为中级调度发送的频率要比高级调度高。
:chestnut: 我们使用手机时经常会出现这样一种情况:有时候切换程序切换的很快,有时候很慢。很快可能是因为程序就在内存当中,很慢可能是因为进程已经被调出内存了,调入回来还需要时间。
3.七状态模型
:hourglass_flowing_sand: 就绪态--就绪挂起:一个处于就绪态的进程,如果此时内存不够用了,那么可能就会被操作系统给转移到外存去,加入到就绪挂起队列中。如果内存够了,或者这个进程急需处理,那么又会被调入内存中,变为就绪态。
:sweat_drops: 阻塞态--阻塞挂起 :阻塞态的进程是仍然在内存中的,如果此时内存不够用了,可能就把它调入外存,加入到阻塞挂起队列中,内存够了可能就会被调回内存中,变为阻塞态。
:tent: 阻塞挂起--就绪挂起 :如果处于阻塞挂起的进程已经获得了它想要的资源或服务,那么就会直接调入就绪挂起队列中,等待内存资源。
:rabbit: 运行态--就绪挂起 :一个正准备下处理机的进程,由于资源不够,可能会直接调入到外存的就绪挂起队列中,不用在经过就绪态。
4.三层调度的联系和对比
做什么 | 调入发送在哪 | 发送频率 | 对进程状态的影响 | |
---|---|---|---|---|
高级调度(作业调度) | 按照某种规则,从后备队列中选择核实的作业将其调入内存,并为其创建进程 | 外存-> 内存 | 很低 | 无-创建态--就绪态 |
中级调度(内存调度) | 按照某种规则,从挂起队列中选择合适的进程将其数据调入内存 | 外存--内存 | 中等 | 挂起态--就绪态 |
低级调度(进程调度) | 按照某种规则,从就绪队列中选择一个进程为其分配处理机 | 内存--CPU | 很高 | 就绪态--运行态 |
5. 进程调度的时机
:swimming_man: 进程调度:就是按照某种规则从就绪队列中选择一个进程为其分配处理机。
:ice_cream: 需要进程调度与切换的情况
主动放弃处理机
- 进程正常终止
- 运行过程中遇到了一次而终止
- 进程主动请求阻塞(如等待 I/O)
被动放弃处理机
- 分配进程的时间片用完了
- 有更紧急的事需要处理(如 I/O 中断)
- 有更高优先级的进程进入就绪队列
:dog: 不能进程调度与切换的情况
- 在处理中断的过程中,中断处理过程很复杂,与硬件密切相关,很难做到在中断处理过程中进行进程切换。(家里正在修热水器呢,你却非要把电闸开开要做其他事?)
- 进程在操作系统的内核程序临界区中。
- 在原子操作过程中,原子操作不可中断,要一气呵成。(此时如果你非要中断去做其他事不就坏事了吗)
:ear_of_rice: 操作系统的临界区
:grey_question: 进程在操作系统内核程序临界区中不能进行调度与切换,但程序在临界区中能进行处理机调度。这是为啥呢?
:star: 临界资源:一个时间段内只允许一个进程使用的资源,各进程需要互斥地访问临界资源。实现对这种资源的共享。
:star: 临界区:访问临界资源的那段代码,每次只允许一个进程进入临界区,进入后就不允许其他进程进入。使用临界区时,一般不允许其允许时间过长,只有临界区中有进程,那么其他进入此临界区的进程都会被挂起而进入等待状态,并在一定程度上影响程序的运行性能。
:star: 内核程序临界区:用来访问某种内核数据结构的,比如进程的就绪队列。
:sailboat: 进程访问内核程序临界区:进程如果访问就绪队列这种临界区的话,为了实现互斥访问,就会将就绪队列上锁,不允许其他进程访问,如果此时进行进程调度,那么临界区还没有解锁,切换的进程也要访问临界区就没有办法访问,所以也没有办法进行进程调度。内核程序临界区访问你的临界资源如果不尽快释放的话,既有可能影响到操作系统内核的其他管理工作,因此在访问内核程序临界区期间不能进行调度与切换。(操作系统内核程序都非常重要,耽误不得,一个进程切换和调度都要花费时间,其他程序都要等着,所以每个进程都要快速处理)
:lantern: 进程访问普通程序临界区:比如一个进程想要使用打印机资源,那么就会给打印机上锁,不允许其他进程使用,但是打印机这种设备速度很慢,为了让CPU不要闲着,就可以进程调度和切换。因为普通临界区访问的临界资源不会直接影响操作系统内核的管理工作。
6.进程调度的方式
:star2: 非剥夺调度方式 :又称非抢占式,只允许进程主动放弃处理机,在运行过程中即便有更紧迫的任务到达,当前进程依然会继续使用处理机,直到该进程终止或主动要求进入阻塞态。特点:实现简单,系统开销小,但是无法及时处理紧急任务,适合于早期的批处理系统。
:star2: 剥夺式调度方式 :又称抢占式,当一个进程正在处理机上执行时,如果有一个更重要或更紧迫的进程需要使用处理机,则立即暂停正在执行的进程,将处理机分配给更重要紧迫的进程。特点:可以优先处理更紧急的进程,也可实现让各进程按时间片轮流执行的功能(通过时钟中断),适合分时操作系统、实时操作系统。
7.进程的切换与过程
:star: 狭义的进程调度:是指从就绪队列中选中一个要运行的进程(这个进程可以时刚刚被暂停执行的进程,也可以是另一个进程),如果切换另一个进程就需要进程切换
:star: 进程切换:指一个进程让出处理机,由另一个进程占用处理机的过程。
- 对原来运行进程各种数据的保存(保护现场)
- 对新的进程各种数据的恢复(恢复现场),包括程序计数器,程序状态字,各种数据寄存器等出理解现场信息,这些信息一半都保存在进程控制块)
:star: 广义的进程调度:包含了选中一个进程和进程切换两个步骤。
:notebook: 注意:进程切换是有代价的,因此如果过于频繁的执行进程调度、切换,必然会使整个系统的效率较低,是系统大部分时间都花在了进程切换上,而真正用于执行进程的时间减少。
8.调度算法的评价指标
:star: CPU利用率 :指CPU工作的时间占总时间的比例
:star: 系统吞吐量 :单位时间内完成作业的数量
:star2: 周转时间 :是指作业被提交给系统开始,到作业完成为止的这段时间间隔。它包括四个部分:作业在外存后备队列上等待作业调度的时间,进程在就绪队列上等待进程调度的时候,进程在CPU上的执行时间,进程等待I/O操作完成的时间。后三项在一个作业的整个处理过程中,可能发送多次。
:writing_hand: 带权周转时间 :作业周转时间/作业实际运行的时间。值越大,表示等待时间时间越多,用户满意度越低。
:sweet_potato: 等待时间 :指作业/进程处于等待处理机状态时间之和,等待时间越长,用户满意度越低。
- 对于进程来说,等待时间就是指进程建立后等待被服务的时间之和,在等待I/O完成的期间其实进程也是被服务的,所以不计入等待时间。
- 对于作业来说,除了考虑建立进程后的等待时间,还要加上作业在外存后被队列中的等待时间。
一个作业总共被CPU服务多久,被I/O设备服务多久一般是确定不变的,因此调度算法其实只会影响等待时间,也有平均等待时间来衡量标准。
:taco: 响应时间 :指从用户提出请求到首次产生响应所用的时间。对于计算机用户来说,会希望自己的提交的请求(比如通过键盘输入了一个调试命令)尽早地开始被系统服务,回应。
2.2.2 调度算法
学习过程中思考的问题
- 算法思想
- 算法规则
- 用于作业调度还是进程调度
- 抢占式/非抢占式
- 是否可能饥饿(很久得不到响应)
先来先服务(FCFS)
- 算法思想:公平
- 算法规则:按照作业/进程到达的先后顺序进行服务,每次从就绪队列中选择最先进入队列的进程,然后一直允许,知道进程完成退出或被阻塞,才会继续从队列中学组第一个进程接着允许。
- 用于作业调度还是进程调度:用于作业调度时,考虑的是哪个作业先到后备队列中,用于进程调度时,考虑的时哪个进程先到就绪队列中
- 抢占式/非抢占式:非抢占式
- 优缺点:优点:公平,算法实现简单。 缺点:排在后面的短作业长时间得不到响应,带权周转时间很大,对短作业来说用户体验不好。
- 是否可能饥饿(很久得不到响应):可能,假设一个长作业先运行了,那么后面的短作业等待的时间就会很长,不利于短作业。FCFS对长作业有利,适用于CPU繁忙型作业的系统,而不是用于I/O繁忙型的系统。如果I/O次数多的话,CPU就会浪费大量的时间。
短作业优先(SJF)
- 算法思想:优先选择运行时间最短的进程来运行,有助于提高系统的吞吐量。追求最少的平均等待时间,最少的平均周转时间,最少的平均带权周转时间
- 算法规则:最短的作业/进程优先得到服务
- 用于作业调度还是进程调度:即可用于作业调度也可以用于进程调度。
- 抢占式/非抢占式:非抢占式
- 优缺点:优点:”最短的“平均等待时间、平均周转时间 ; 缺点:不公平,对都安作业有利,对长作业不利。另外,作业/进程的运行时间是由用户提供的,并不一定真实,不一定能做到真正的短作业优先
- 是否可能饥饿(很久得不到响应):长作业可能会饥饿,加入一个长作业在就绪队列等待运行,而这个就绪队列有非常多的短作业,那么就会使得长作业不断地往后推,周转时间变长,致使长作业长时间不会被运行。
最短剩余时间优先(SRNT)
思想:每当有进程进入就绪队列改变时就需要调度,如果新到达的进程剩余时间比当前运行的进程剩余时间更短,则由新进程抢占处理机,当前运行经常重新回到就绪队列中,另外,当一个经常完成时也需要调度。
高响应比优先算法(HRRN)
前面的先来先服务算法和短作业优先算法都没有很好的权衡短作业和长作业,此时就有了高效应比优先算法,权衡了短作业和长作业
- 算法思想:要综合考虑作业/进程的等待时间和要求服务的时间
- 算法规则:在每次调度时先计算各个作业/进程的响应比,选择响应比,选择响应比最高的的作业/进程为其服务。
- 用于作业调度还是进程调度:即可用于作业调度也可以用于进程调度。
- 抢占式/非抢占式:非抢占式,只有当当前运行的作业/进程主动放弃处理机时,才需要调度,才需要计算响应比
- 是否可能饥饿(很久得不到响应):不会
- 如果两个进程的「等待时间」相同时,「要求的服务时间」越短,「响应比」就越高,这样短作业的进程容易被选中运行;
- 如果两个进程「要求的服务时间」相同时,「等待时间」越长,「响应比」就越高,这就兼顾到了长作业进程,因为进程的响应比可以随时间等待的增加而提高,当其等待时间足够长时,其响应比便可以升到很高,从而获得运行的机会;
时间片轮转调度算法(RR)
- 算法思想:公平地、轮流地为各个进程服务,让每个进程在一定时间段内都可以得到响应。
- 算法规则:按照各进程到达就绪队列的顺序,轮流让各个经常执行一个时间片,若进程在一个时间片内执行完,则剥夺处理机,将进程重新放到就绪队列队尾重新排列。
- 用于作业调度还是进程调度:用于进程调度(只有作业放入内存建立了相应的进程后,才能被分配处理机时间片)
- 抢占式/非抢占式:抢占式,若进程未能在时间片内运行完,将被强行剥夺处理机使用权,因此时间片轮转调度算法属于抢占式算法,由时钟装置发出时钟中断来通知CPU时间片已到。
- 是否可能饥饿(很久得不到响应):不会
时间片的长度是一个很关键的点:
- 如果时间片设的太短会导致过多的进程上下文切换,降低了CPU效率
- 如果设的太长有可能引起短作业进程的相应时间边长。
优先级调度算法
- 算法思想:随着计算机的发展,特别是实时操作系统的出现,越来越多的应用场景需要根据任务的紧急程度来决定处理顺序
- 算法规则:每个作业/进程有各自的优先级,调度时选择优先级最高的作业/进程
- 用于作业调度还是进程调度:用于进程调度和作业调度
- 抢占式/非抢占式:都有,非抢占式只需在进程主动放弃CPU时进行调度,而抢占式还需在就绪队列变化时,检查是否会发生抢占。
- 是否可能饥饿(很久得不到响应):不会
进程的优先级可以分为,静态优先级或动态优先级:
- 静态优先级:创建进程时候,就已经确定了优先级了,然后整个运行时间优先级都不会变化;
- 动态优先级:根据进程的动态变化调整优先级,比如如果进程运行时间增加,则降低其优先级,如果进程等待时间(就绪队列的等待时间)增加,则升高其优先级,也就是随着时间的推移增加等待进程的优先级。
该算法也有两种处理优先级高的方法,非抢占式和抢占式:
- 非抢占式:当就绪队列中出现优先级高的进程,运行完当前进程,再选择优先级高的进程。
- 抢占式:当就绪队列中出现优先级高的进程,当前进程挂起,调度优先级高的进程运行。
但是依然有缺点,可能会导致低优先级的进程永远不会运行。
多级反馈调度算法
- 算法思想:对其他调度算法的折中权衡
- 算法规则:
- 设置了多个队列,赋予每个队列不同的优先级,每个队列优先级从高到低,同时优先级越高时间片越短;
- 新的进程会被放入到第一级队列的末尾,按先来先服务的原则排队等待被调度,如果在第一级队列规定的时间片没运行完成,则将其转入到第二级队列的末尾,以此类推,直至完成;
- 当较高优先级的队列为空,才调度较低优先级的队列中的进程运行。如果进程运行时,有新进程进入较高优先级的队列,则停止当前运行的进程并将其移入到原队列末尾,接着让较高优先级的进程运行;
- 用于作业调度还是进程调度:用于进程调度
- 抢占式/非抢占式:抢占式
- 是否可能饥饿(很久得不到响应):不会
- 「多级」表示有多个队列,每个队列优先级从高到低,同时优先级越高时间片越短。
- 「反馈」表示如果有新的进程加入优先级高的队列时,立刻停止当前正在运行的进程,转而去运行优先级高的队列;
对于短作业,很可能在第一级队列很快被处理完。对于长作业,如果在第一级队列处理不完,可以移入下次队列等待被执行,虽然等待的时间变长了,但是运行时间也会更长了,所以该算法很好的兼顾了长短作业,同时有较好的响应时间。
2.3.1 进程同步、进程互斥
什么是进程同步?
我们都知道在多线程里,进程具有异步性,每个线程并不一定是顺序执行的,它们基本是以各自独立的、不可预知的速度向前推进,但有时候我们又希望多个线程能密切合作,以实现一个共同的任务。
例子,线程 1 是负责读入数据的,而线程 2 是负责处理数据的,这两个线程是相互合作、相互依赖的。线程 2 在没有收到线程 1 的唤醒通知时,就会一直阻塞等待,当线程 1 读完数据需要把数据传给线程 2 时,线程 1 会唤醒线程 2,并把数据交给线程 2 处理。
所谓同步,就是并发进程/线程在一些关键点上可能需要互相等待与互通消息,这种相互制约的等待与互通信息称为进程/线程同步。
什么是进程互斥?
进程的并发和共享的支持。各个并发执行的进程不可避免地需要共享一些系统资源(比如内存、打印机等)
我们把一个时间段内只允许一个进程使用地资源称为临界资源。许多资源都属于临界资源。
对临界区地访问,必须互斥地进行,亦称间接制约关系,进程胡策指当一个进程访问某临界资源时,另一个想要访问该临界资源地进程必须等待,当前访问临界资源地进程访问结束,释放该资源之后,另一个进程才可以访临界资源。
对临界资源地互斥访问,可以在逻辑上分为几个部分:
do{
entry section; // 进入区
critical section; // 临界区
exit section; // 退出区
remainder section; // 剩余区
}
- 进入区:负责检查是否可进入临界区,若可进入,则应设置正在访问临界资源地标志(上锁),以组织其他进程同时进入临界区
- 临界区:访问临界资源地那部分代码
- 退出区:解锁
- 剩余区:进行其他处理
为了实现对临界资源地互斥访问,同时保证系统整体性能,需要遵循以下原则:
- 空闲让进:临界区空闲时,可以允许一个请求进入临界区进程立即进入临界区
- 忙则等待:当已有进程进入临界区时,其他试图进入临界区地进程必须等待;
- 有限等待:对请求访问的进程,应保证能在有限时间内进入临界区(保证不会饥饿)
- 让权等待:当进程不等进入临界区时,应立即释放处理机,防止进程忙等待(一直占用CPU)
2.3.2 进程互斥地软件实现方法
单标志法
算法思想:两个进程在访问完临界区后会把使用临界区地权限转交给另一个进程。也就是说每个进程进入临界区地权限只能被另一个进程赋予
单标志会先指定一个进程允许,此时只有当指定地那个进程运行完后才会让另外一个进程运行。
但是这样很容易带来一个问题,假设让A先执行,但CPU此时在执行B,但是B进不来临界区,A也没有进临界区,所以就违反了闲则让进原则。
双标志先检查法
算法思想:如果发现其他人不想进临界区地话那就我来进。
但是这样会存在一个问题:假如A先发现没有人想进入临界区,那么A进去了,但是突然时钟中断导致A还没有表达自己想进入临界区的标志,此时CPU执行B,B发现也没有人想进入临界区,所以B就进了,那么此时两个进程都进入了临界区,这样就会出现大问题。违反了忙则等待的原则。
双标志后检查法
算法思想:双标志先检查法是检查后上锁,但是这两个动作无法一气呵成,导致了两个进程同时进入临界区的问题,因此,双标志后检查法是先上锁,后检查
但是这样又有问题,比如A上了锁之后时钟中断到了B执行,B也上了锁,那么此时无论是A还是B都会发现有人上了锁,那么都不能进入临界区。违反了空闲让进和有限等待原则。
Peterson算法
算法思想:结合双标志法、单标志法的思想,如果双方都想进入临界区,那么就让进程尝试“谦让”
每个进程都会先表达自己的意愿,然后自己不进入临界区,而是先让别人进临界区,当对方想要进入临界区并且也谦让的话那我就进入临界区。这里很有意思,双方先客套一下,第二次客套就变成了真的。
这个算法解决了互斥问题并且遵守了空闲让进、忙则等待,有限等待原则。
2.3.3 进程互斥的硬件实现方法
中断屏蔽方法
利用“开/关中断”指令实现(与原语的实现思想相同,即在某进程开始访问临界区到结束访问位置都不允许被中断,也就不能发生进程切换,因为也不可能发生两个同时访问临界区的情况)
优点:虽然简单、高效。
缺点:不适用于多处理机,虽然实现了中断,一个CPU执行临界区代码,但是其他CPU仍然可以进入临界区。(我觉得开中断和关中断只能对一个CPU有效果,对其他CPU没影响,其他CPU也可以执行开/关中断指令)而且开/关中断只能在内核态下执行,所以只适用于内核级进程。
TestAndSet指令
TSL指令是用硬件实现的,执行的过程不允许被中断,只能一气呵成,
bool TestAndSet(bool *lock)
{
bool old;
old = *lock; //存放lock原来的值
*lock = true; //无论之前是否已加锁,都给他锁上
// 1.如果之前是false,现在变成true,表示上锁
// 2.如果之前是true,设置称true,还是已上锁
return old; //返回之前lock的值
}
//使用TSL指令实现互斥的算法逻辑
while (TestAndSet(&lock)); //只有当lock之前为false时才会退出循环并且上锁,实现了原子操作
临界区代码...;
lock = false;
剩余区代码...;
Swap指令
Swap指令是用硬件实现,执行的过程不允许被中断,只能一气呵成。
// 交换两个变量的值
void Swap(bool *a, bool *b)
{
bool temp;
temp = *a;
*a = *b;
*b = temp;
}
//使用Swap指令实现互斥的算法逻辑
// lock表示当前临界区是否被加锁
bool old = true;
while (old == true)
Swap(&old, &lock); //当lock解锁了之后才会退出while循环
临界区代码...;
lock = false; //解锁
剩余区代码...;
2.3.4 信号量机制
信号量
用户进程可以通过使用操作系统提供的一对原语来对信号量进行操作,从而很方便的实现了进程互斥、进程同步。
信号量其实就是一个变量(可以是一个整数,也可以是更复杂的记录型变量),可以用一个信号量来表示系统中某种资源的数量。比如:系统中只有一台打印机,那就可以设置一个处置为1的信号量。
信号量是操作系统提供的一种协调共享资源访问的方法。
通常信号量表示资源的数量,对应的变量是一个整型(sem
)变量。
另外,还有两个原子操作的系统调用函数来控制信号量的,分别是:
- P 操作:将
sem
减1
,相减后,如果sem < 0
,则进程/线程进入阻塞等待,否则继续,表明 P 操作可能会阻塞; - V 操作:将
sem
加1
,相加后,如果sem <= 0
,唤醒一个等待中的进程/线程,表明 V 操作不会阻塞;
P 操作是用在进入临界区之前,V 操作是用在离开临界区之后,这两个操作是必须成对出现的。
原语是一种特殊的程序段,由开/关中断指令实现,其执行只能一气呵成,
整型信号量
避免了并发、异步问题,单无法解决忙等问题
记录型信号量
当一个进程发现没有资源可用时,会主动的让出处理机,并进入阻塞队列。由一个进程用完资源后,会唤醒阻塞队列中等待的进程。
S.value的初值表示某种资源的数量
对信号量S的一次P操作意味着进程请求一个单位的该类资源,如果资源数小于0,表示没有资源可用,那么该进程就调用block原语进行自我阻塞,主动放弃处理机,并插入到等待队列中,该机制遵循了让权等待原则。
对信号量S的一次V操作一位着释放一个单位的资源,资源数就加1,如果加1后发现S.value<=0
表明由进程在等待该类资源,那么调用wakeup
原语,唤醒等待队列中的第一个进程。
2.3.5 用信号量实现进程互斥、同步、前驱关系
信号量实现进程互斥
信号量实现进程同步
进程同步:要让个并发进程按要求有序地推进。
比如必须先做好饭之后才能吃,又是事务必须有一定顺序,让异步并发的进程相互配合,有序推进。
先执行的操作执行完后执行V操作,让资源数从0到1,后执行的进程发现有资源后(P操作)才能继续执行。
类比:先把饭做好之后,才能吃上饭。如果饭没做好那就只能干等着。
信号量实现前驱关系
拓扑排序:一个有向图,每个点的前序必须遍历完后才能允许。
2.3.6 管程
为什么要引入管程
解决信号量机制编程麻烦、易出错的问题
看下图中的内容:假如执行了1、2、3会发生什么
- 生成者进程先拿到了互斥信号量
- 检查当前是不是空的,如果不是就等待
- 消费者进程检查互斥信号量发现已经被占用,所以也要等待
这样就导致两个进程都在等待,发生了死锁。这里主要的问题是信号量实现的互斥,导致消费者无法取出数据而陷入了死锁。
这里怎么改为正确的呢?
我们可以先检查后上锁,我们把1和2掉下顺序,3和4掉下顺序。
这样生产者会先检查是否空了,消费者会检查是否满了,它俩总会有一个满足条件从而进入临界区。从而不会导致死锁。
管程的定义和基本特征
管程是一种特殊的软件模块,由这些部分组成:
- 局部于管程的共享数据结构说明
- 对该数据结构进行操作的一组过程
- 对局部于管程的共享数据设置初始值的语句
- 管程的变量名
管程的基本特征:
- 局部于管程中的数据只能被局部于管程的过程所访问
- 一个进程只有通过调用管程内的过程才能进入管程访问数据
- 每次仅允许一个进程在管程内执行某个内部过程(实现了互斥)
用管程解决生产者消费者问题
每次仅允许一个进程进入管程,生产者发现如果缓冲区已满的话就进入full
的等待队列中,如果生产了第一个产品,可能会有消费者在等待产品,所以需要唤醒empty
中等待的消费者进程。消费者发现缓冲区中没有产品的话就进入empty
等待队列中,如果发现消费的是最后一个产品,那么可能由生产者线程因为没有空闲位置而阻塞在full
队列中,需要唤醒它。
Java中类似于管程的机制
2.3.7 生产者-消费者问题
问题描述
生产者-消费者问题描述:
- 生产者在生成数据后,放在一个缓冲区中;
- 消费者从缓冲区取出数据处理;
- 任何时刻,只能有一个生产者或消费者可以访问缓冲区;(如果不互斥的话会出现冲突,两个写进程同时写同一片区域)
问题分析
PV操作题目分析步骤:
- 关系分析,找出题目中描述的各个进程,分析他们之间的同步、互斥关系
- 分析各进程的操作流程缺点P、V操作的大致顺序
- 设置信号量,并根据题目条件确定信号量初值(互斥信号量一般初始值为1,同步信号量初始值为0)
我们对问题分析可以得出:
- 任何时刻只能有一个线程操作缓冲区,说明操作缓冲区是临界代码,需要互斥;
- 缓冲区空时,消费者必须等待生产者生成数据;缓冲区满时,生产者必须等待消费者取出数据。说明生产者和消费者需要同步。
生产者:先生产一个产品,后检查是否有空闲的缓冲区,如果没有就等待。有的话就上锁后把产品放入缓冲区,生产完了解锁并将产品的数量加1,唤醒消费者进程。
消费者:先检查是否有满的缓冲区,如果没有就等待,有的话就上锁后取出产品,后解锁并将空的缓冲区数量减1,唤醒生产者进程。
能否改变相邻P、V操作的顺序?
如上图所示,假设当前缓冲区已满,empty=0,full = n
若此时生产者执行①,在执行②,那么就会先上锁后检查没有缓冲区,那么就会等待。由于生产者阻塞,因此切换回消费者仅,消费者进程执行③,由于互斥信号量已经被上锁,所以消费者也被阻塞。
这就造成了生产者等待消费者释放空闲缓冲区,而消费者又等待生产者释放临界区的情况。陷入了死锁。
2.3.8 多生产者-多消费者问题
问题描述
问题分析
父亲:P检查盘子是不是空,不是的话就等待,是的话就进入临界区放入一个苹果,放完后退出临界区执行V操作,让苹果数加1,并唤醒儿子进程
母亲:P检查盘子是不是空,不是的话就等待,是的话就进入临界区放入一个橘子,放完后退出临界区执行V操作,让橘子数加1,并唤醒儿子进程
儿子:P检查是不是有苹果,没有就等待,有就去临界区拿走苹果,后退出临界区执行V操作,唤醒父亲和母亲进程
女儿:P检查是不是有橘子,没有就等待,有就去如临界区拿走苹果,后退出临界区执行V操作,唤醒父亲和目前进程
如何实现
问题1:可不可以不用互斥信号量
只有当缓冲区大小最多为1时才可能不用互斥量。在任何时刻,只会有一个进程不会被阻塞,并顺利的进入临界区
问题2:如果缓冲区容量大于1的时候能不能不用互斥信号量
不行,会导致资源覆盖重写
总结回顾
2.3.9 吸烟者问题
问题描述
问题分析
各种信息:
- 生产者每次生产的不是一个物品,而是几个物品的组合
- 生产者生产完后对应的消费者才能抽烟
- 抽烟者抽完之后会提醒生产者继续生产
- 生产者轮流为三个抽烟者生产
- 只有一个桌子
解决方案:
- 生产者每次生产的都是多个物品的组合
- 同步关系,先V后P,初始值为0
- 同步关系,先V后P,初始值也为0
- 用一个while循环和一个变量
i
,每次操作i=(i+1)%3
- 互斥关系,需要添加一个互斥信号量,初始值为1,由于这里只有一个缓冲区,每次只能由一个不会被阻塞,所以不需要添加信号量
2.3.10 读者写者问题
问题描述
读者只会读取数据,不会修改数据,而写者即可以读也可以修改数据。
读者-写者的问题描述:
- 「读-读」允许:同一时刻,允许多个读者同时读
- 「读-写」互斥:没有写者时读者才能读,没有读者时写者才能写
- 「写-写」互斥:没有其他写者时,写者才能写
问题分析
先解决读写互斥和写写互斥问题,只需要加一个信号量,初始值为1,写的时候需要加锁防止被其他读或者写,读的时候也需要加锁。防止被其他进程写。
但是这样没办法实现多个进程同时读文件的情况。我们可以用一个count
来记录正在读的进程个数,只有当第一个进程在读,那就上锁,当所有进程都读完了之后才解锁。这样就是实现了多个读进程同时进行。
但是这样写又存在一个问题,比如当前count = 0
,一个读进程上锁之后时钟中断切换到另一个读进程,它发现此时count=0
,但是锁已经被占用,所以就一直陷入了等待,导致无法实现多个读进程同时执行的情况。
这个问题的主要原因就是没有实现加锁和修改count
的原子化操作,我们可以使用信号量机制给这一部分上锁,实现互斥访问。
这里还有一个潜在的问题:只要有读进程在读,写进程就一直阻塞,可能会“饿死”,因此,在这种算法中,读进程是优先的。
这就好比在生活中,一家银行优先处理VIP客户,这样普通用户可能就一直在等待。
那如何优化呢?
多有一个读进程在读文件的时候,其他读进程就可以直接去读,不受约束。我们希望接下来不让后来的读进程继续读下去的话可以在加一个互斥信号量,让读进程和写进程公平竞争,先来的先执行。
2.3.11 哲学家进餐问题
问题描述
- 问题1:每个筷子是互斥关系,只能被一个人获得
- 问题2:每个人需要拿到两只筷子才能吃饭
方案一
我们用信号量的方式,也就是 PV 操作来尝试解决它,代码如下:
不过,这种解法存在一个极端的问题:假设五位哲学家同时拿起左边的叉子,桌面上就没有叉子了, 这样就没有人能够拿到他们右边的叉子,也就说每一位哲学家都会在 P(fork[(i + 1) % N ])
这条语句阻塞了,很明显这发生了死锁的现象。
方案二
既然「方案一」会发生同时竞争左边叉子导致死锁的现象,那么我们就在拿叉子前,加个互斥信号量,让拿左右筷子实现原子操作,代码如下:
上面程序中的互斥信号量的作用就在于,只要有一个哲学家进入了「临界区」,也就是准备要拿叉子时,其他哲学家都不能动,只有这位哲学家用完叉子了,才能轮到下一个哲学家进餐。
方案二虽然能让哲学家们按顺序吃饭,但是每次进餐只能有一位哲学家,而桌面上是有 5 把叉子,按道理是能可以有两个哲学家同时进餐的,所以从效率角度上,这不是最好的解决方案。
方案三
可以对哲学家进程施加一些限制条件,最多允许四个哲学家同时进餐,这样可以保证至少有一个哲学家是可以拿到左右两只筷子的。
方案四
那既然方案二使用互斥信号量,会导致只能允许一个哲学家就餐,那么我们就不用它。
另外,方案一的问题在于,会出现所有哲学家同时拿左边刀叉的可能性,那我们就避免哲学家可以同时拿左边的刀叉,采用分支结构,根据哲学家的编号的不同,而采取不同的动作。
即让偶数编号的哲学家「先拿左边的叉子后拿右边的叉子」,奇数编号的哲学家「先拿右边的叉子后拿左边的叉子」。
奇数的哲学家会先拿左边的,如果此时进程切换到了他的右边的哲学家,那右边的哲学家会先拿他右边的,加入此时又发生进程切换,奇数的哲学家也会拿到右边的筷子,他右边的哲学家就拿不到左边的筷子只能等待。
2.4.1 死锁的概念
什么是死锁
在多线程编程中,我们为了防止多线程竞争共享资源而导致数据错乱,都会在操作共享资源之前加上互斥锁,只有成功获得到锁的线程,才能操作共享资源,获取不到锁的线程就只能等待,直到锁被释放。
那么,当两个线程为了保护两个不同的共享资源而使用了两个互斥锁,那么这两个互斥锁应用不当的时候,可能会造成两个线程都在等待对方释放锁,在没有外力的作用下,这些线程会一直相互等待,就没办法继续运行,这种情况就是发生了死锁。
举个例子:A和B都想要吃饭,A只有碗没有筷子,B有筷子却没有碗,两个人都希望对方的东西给我,都不肯让步,这样就导致了死锁。
死锁、饥饿、死循环的区别
死锁产生的条件
死锁只有同时满足以下四个条件才会发生:
- 互斥条件;
- 持有并等待条件;
- 不可剥夺条件;
- 环路等待条件;
互斥条件
互斥条件是指多个线程不能同时使用同一个资源。
比如下图,如果线程 A 已经持有的资源,不能再同时被线程 B 持有,如果线程 B 请求获取线程 A 已经占用的资源,那线程 B 只能等待,直到线程 A 释放了资源。
持有并等待条件
持有并等待条件是指,当线程 A 已经持有了资源 1,又想申请资源 2,而资源 2 已经被线程 C 持有了,所以线程 A 就会处于等待状态,但是线程 A 在等待资源 2 的同时并不会释放自己已经持有的资源 1。
不可剥夺条件
不可剥夺条件是指,当线程已经持有了资源 ,在自己使用完之前不能被其他线程获取,线程 B 如果也想使用此资源,则只能在线程 A 使用完并释放后才能获取。
环路等待条件
环路等待条件指的是,在死锁发生的时候,两个线程获取资源的顺序构成了环形链。
比如,线程 A 已经持有资源 2,而想请求资源 1, 线程 B 已经获取了资源 1,而想请求资源 2,这就形成资源请求等待的环形图。
注意
发生死锁时一定有循环等待,但是发生循环等待时未必死锁。
如果同类资源大于1,则即使有循环等待,也未必发生死锁。如果系统中每类资源都只有一个,那循环等待就是死锁的充分必要条件了。
什么时候会发生死锁
- 对资源的竞争,个进程对不可剥夺的资源(如打印机)的竞争可能引起死锁,对可剥夺的资源(CPU)的竞争时不会引起死锁的。
- 进程推进顺序非法,请求和释放资源的顺序不当,也同样导致死锁。例如,并发执行的进程P1,P2分别申请并占有了资源R1,R2,之后进程P1有紧接着申请资源R2,二进程P2又申请资源R1,两者会因为申请的资源被对方占有二阻塞,从而发生死锁。(环路等待条件)
- 信号量的使用不当,也会造成死锁,如生产者-消费者问题中,如果实现互斥的P操作在实现同步的P操作之前,就有可能导致死锁。(可以把互斥信号量、同步信号量也看成时一种抽象的系统资源)
死锁的处理策略
- 预防死锁:破坏死锁产生的四个必要条件中的一个或几个
- 避免死锁:用某种方法防止系统进入不安全状态,从而避免死锁(银行家算法)
- 死锁的检测和接触:允许死锁的发生,不过操作系统会扶着检查除死锁的发生,然后采取某种措施交界处死锁。
2.4.2 预防死锁
破坏互斥条件
把互斥使用的资源改造为允许共享使用,则系统不会进入死锁状态
我们可以把要访问互斥的资源的进程通过一个中介来管理,那么进程就可以继续下面代码的执行,而不用管这部分代码逻辑。
但这里有一个缺点:并不是所有的资源都可以改造程可共享使用的资源,并且为了系统安全,很多地方还要保留互斥性,所以很多时候都无法破坏互斥条件。
破坏不剥夺条件
- 当某个进程请求新的资源得不到满足时,它必须立即释放保持的所有资源,待以后需要时在重新申请,也就是说,及时某些资源尚未使用完,也需要主动释放,从而破坏了不可剥夺条件。(离了婚我净身出户)
- 当某个进程需要的资源被其他进程所占有的时候,可以由操作系统协助,将想要的资源强行剥夺。这种方式一般需要考虑各进程的优先级(比如:剥夺调度方式,就是将处理及资源强行剥夺给优先级更高的进程使用) (得不到就抢过来)
该策略的缺点:
- 实现起来比较复杂
- 释放已获得的资源可能造成前一阶段工作的失效,因此这种方法一般只适用于易保存和恢复状态的资源,如CPU。
- 反复地申请和释放资源会增加系统开销,降低系统吞吐量
- 若采用方案1,意味着只要展示得不到某个资源,之前获得地那些资源就需要放弃,以后在重新申请,如果那个资源一直获取不到,就可能产生饥饿现象
破坏保持和请求条件(持有并等待条件)
可以采用静态分配法,即进程在允许前一次申请完它所需要地全部资源,在他的资源为满足前,不让它投入允许,一旦投入允许后,这些资源就一直归它所有,该进程就不会再请求别的任何资源了。
缺点:有些资源可能只需要用很短地时间,因此如果进程的整个运行期间都一直保持着所有资源,就会造成严重的资源浪费,资源利用率很低,也有可能导致某些进程饥饿(想用的某个资源一直被分配给其他资源用了,总是凑不齐全部资源)。(占着茅坑不用)
破坏循环等待条件
顺序资源分配法,每个进程获取的资源都是按顺序排列的。
一个进程只有占有小编号的资源时,才由资格申请更大编号的资源。所以不可能出现一个拥有大编号的进程去申请小编好的资源。
线程 A 和 线程 B 获取资源的顺序要一样,当线程 A 是先尝试获取资源 A,然后尝试获取资源 B 的时候,线程 B 同样也是先尝试获取资源 A,然后尝试获取资源 B。也就是说,线程 A 和 线程 B 总是以相同的顺序申请自己想要的资源。
缺点:不方便增加新的设备,因为可能需要重新分配所有的编号。进程实际使用资源的顺序可能和编号递增顺序不一致,会导致资源浪费(有了资源但暂时不用)。必须按规定次序申请资源,用户编程麻烦。
2.4.3 避免死锁(银行家算法)
什么是安全序列
通过银行家借款这个问题我们可以发现,如果执行某一个操作之后,可能会引发死锁,那么就是这个操作就是不安全的。那就可以阻止这个操作的执行。
安全序列:如果系统按照这种序列分配资源,则每个进程都能顺利完成,只要找出一个安全序列,系统就是安全状态。安全序列有多个。
如果分配了资源之后,系统中找不出任何一个安全序列,系统就进入了不安全状态。着就意味着之后可能所有进程都无法顺利的执行下去,当然,如果有进程提前归还了一些资源,那系统也有可能重新回到安全序列,不过我们再分配资源之前总是要考虑到最坏的情况。
如果系统处于安全状态,就一定不会发生死锁。如果系统进入不安全状态,就有可能发生死锁。
银行家算法
银行家算法:再资源分配之前预先预测这次分配是否会导致系统进入不安全状态,以此决定是否答应资源分配请求。
我们可以用矩阵的形式来表示一些进程所需要的多个资源
如何判断当前是否处于安全状态?
尝试找出一个安全序列,一个一个判断,看满不满足某些进程的需求,已加入安全序列的就不再看了。这样一直执行下去,检查是否所有的进程都能进入安全序列中,
安全序列的例子
不安全序列的例子
算法流程
银行家算法步骤
- 检查此次申请是否超过了之前声明的最大需求数
- 检查此时系统剩余的可用资源是否还能满足此次请求
- 试探着分配,更改各数据结构
- 用安全性算法检查此次分配是否会导致系统进入不安全状态
安全性算法步骤
检查当前的剩余可用资源是否满足某个进程的最大需求,如果可以,就把该进程加入安全序列,并把该进程持有的资源全部回收。
不断重复上述过程,看最终是否能让所有进程都加入安全序列中。
2.4.4 死锁的检测和解除
死锁的检测
为了能对系统是否已发生了死锁进行检测,必须:
- 用某种数据结构来保存资源的请求和分配信息
- 提供一种算法,利用上述信息来检测系统是否已进入了死锁状态
数据结构资源分配图
死锁的检测方法
先找到能够满足所有需求的进程,把这个进程和与它相关联的边都去除。然后再继续往下找,一直找到不能找到为止。如果此时分配图中还有其他边,说明发生了死锁,否则表示没有发生死锁。
对于上图中,P1已经得到了所有需求,那就可以顺利的执行下去,等P1执行完后归还了所需的资源,P2就能够获得足够的资源而执行,这样就等到了一个安全序列。
对于下图中,就发生了死锁。由于R2资源不足,导致P1等待R2的资源,但P2拥有R2的资源不放,这样就发生了死锁。
检测死锁的算法
死锁的解除
- 资源剥夺法:挂起某些死锁进程,并抢占他们的资源,将这些资源分配给其他的死锁进程,但是应防止被挂起的进程长时间得不到资源而饥饿。
- 撤销进程法:强制撤销部分,甚至全部死锁进程,并剥夺这些进程的资源,这种方式的优点时实现简单,但付出的代价可能会很大。因为有些进程可能已经运行了很长时间,一旦被终止可谓功亏一篑,从头再来。效率很低。
- 进程回想法:让一个或多个进程回退到足以避免死锁的地步,这就要求系统要记录进程的历史信息,设置还原点。
死锁的解除无非就是谁放弃资源给别人。可以放弃一部分,也可以放弃全部。