专栏:操作系统复习之路
这份学习笔记,能俘获你的一个收藏嘛?
如果能得到三连,将更是博主持续更新的最大动力!
目录
一、前驱图和程序执行
前趋图是一个有向无循环图(DAG),用于描述进程之间执行的前后关系。
程序顺序执行时的特征:
(1) 顺序性:处理机的操作严格按照程序所规定的顺序执行。
(2) 封闭性:程序一旦开始执行,其计算结果不受外界因素的影响。
(3) 可再现性:程序执行的结果与它的执行速度无关(即与时间无关),而只与初始条件有关。
程序并发执行时的特征:
(1) 间断性:相互制约导致并发程序具有“执行—暂停—执行”这种间断性的活动规律。
(2) 失去封闭性:程序在并发执行时,系统的资源状态由多道程序来改变,程序运行失去封闭性。一程序的运行受到其他程序的影响。
(3) 不可再现性:程序在并发执行时,多次运行初始条件相同的同一程序会得出不同的运行结果。
二、进程的描述
在多道程序设计的环境下,为了描述程序在计算机系统内的执行情况,必须引入新的概念--进程。
程序:是静态的,就是个存放在磁盘里的可执行文件,就是一系列的指令集合。
进程:是动态的,是程序的一次执行过程。
进程的组成:
PCB理解:
PCB(即进程控制块)是进程存在的唯一标志,当进程被创建时,操作系统为其创建PCB,当进程结束时,会回收其PCB。操作系统对进程进行管理工作所需的信息都存在PCB。
PCB是给操作系统用的,而数据段和程序段是给进程自己用的。
进程实体(进程映像):
书上说,进程实体简称为进程。如果你真想区分它们,可以这么理解:
进程实体是进程某时的一个快照,反应了进程在某一时刻的状态。也就是说,进程是进程实体的运行过程。
进程特征:
程序是静态的,进程是动态的。除了进程具有程序所没有的PCB结构外,进程还具有如下特征:
- 动态性:进程是程序的一次执行过程, 是动态地产生、变化和消亡的。
- 并发性:内存中有多个进程实体, 各进程可并发执行。
- 独立性:进程是能独立运行、独立获得资源、独立接受调度的基本单位。
- 异步性:各进程按各自独立的、不可预知的速度向前推进,即产生结果的不可再现性,因此操作系统要提供“进程同步机制”来解决异步问题。
在传统OS中,进程的两个基本属性:
- 进程是一个可拥有资源的独立单位
- 进程同时又是一个可独立调度和分派的基本单位
在下文引入线程后,进程的属性将改变!
2.1 进程的基本状态及转换
进程的三种基本状态
(1)就绪状态(Ready):进程已获得除CPU之外的所有必需的资源,一旦得到CPU控制权,立即可以运行。
(2)运行状态(Running):进程已获得运行所必需的资源,它的程序正在处理机上执行。
(3)阻塞状态(Blocked):正在执行的进程由于发生某事件(如等待某种系统资源的分配,或者等待其他进程的响应)而暂时无法执行时,便放弃处理机而处于暂停状态,称该进程处于阻塞状态或等待状态。
进程的三种基本状态以及各状态之间的转换关系:
另外两种状态:
创建态:进程正在被创建,操作系统为进程分配资源、初始化PCB。
终止态:进程正在从系统中撤销,操作系统会回收进程拥有的资源和PCB。
那么五态的转化关系如下:
注意:
● 运行态是可以直接转换到就绪态的
● 运行态 -> 阻塞态是进程自身做出的主动行为(不能直接由阻塞态到运行态)
● 阻塞态 -> 就绪态不是进程自身能控制的,是一种被动行为(不能直接由就绪态到阻塞态)
2.2 挂起状态
挂起:由于系统和用户的需要引入了挂起的操作,进程被挂起意味着该进程处于静止状态。如果进程正在执行,它将暂停执行,若原本处于就绪状态,则该进程此时暂不接受调度。
对于挂起状态(静止状态)到非挂起状态(活动状态)的转换,书上P42写的已经很详细了,自己去看
当你看完书上的介绍后,你可能会疑惑挂起和阻塞的区别:
共同点:
- 进程都暂停执行。
- 进程都释放CPU(当处于执行态时)
不同点:
- 对系统资源占用不同:虽然都释放了CPU,但阻塞的进程仍处于内存中,而挂起的进程通过“对换”技术被换出到外存(磁盘)中。
- 发生时机不同:阻塞一般在进程等待资源(IO资源、信号量等)时发生;而挂起是由于用户和系统的需要。
- 恢复时机不同:阻塞要在等待的资源得到满足(例如获得了锁)后,才会进入就绪状态,等待被调度而执行;被挂起的进程由将其挂起的对象(如用户、系统)在时机符合时(调试结束、被调度进程选中需要重新执行)将其主动激活并调回内存。
简而言之,挂起是进程的一种非运行状态,而阻塞是进程在等待某些事件或资源时的一种暂时停止运行的状态 。
2.3 PCB
PCB作用:
- 作为独立运行基本单位的标志
- 能实现间断性运行方式
- 提供进程管理所需要的信息
- 提供进程调度所需要的信息
- 实现与其他进程的同步与通信
PCB组织方式:
1、 线性方式
2、 链接方式(常用)
- 按照进程状态将PCB分为多个队列
- 操作系统持有指向各个队列的指针
3、 索引方式
- 根据进程状态的不同,建立几张索引表
- 操作系统持有指向各个索引表的指针
三、进程控制
进程控制的主要功能是对系统中的所有进程实施有效的管理,它具有创建新进程、撤销己有进程、实现进程状态转换等功能。
简化理解:反正进程控制就是要实现进程状态转换。
操作系统要实现进程控制,离不开其三种最基本的支撑功能:
- 中断处理
- 时钟管理
- 原语操作
其中原语操作在进程控制中十分重要! 因为进程控制(状态转换)的过程必须要“一气呵成”。而原语的执行具有原子性,即执行过程只能一气呵成,期间不允许被中断(可以用“关中断指令”和“开中断指令”这两个特权指令实现原子性)。
3.1 进程的创建
操作系统发现要求创建新进程的事件后,调用进程创建原语Creat()创建新进程。
进程的创建过程:
- 申请空白PCB
- 为新进程分配资源
- 初始化进程控制块
- 将新进程插入就绪队列
引起创建进程的事件:
用户登录:分时系统中,用户登录成功,系统会为该用户建立一个新的进程。。
作业调度:多道批处理系统中,有新的作业放入内存时,会为其建立一个新的进程
提供服务:用户向操作系统提出某些请求时,会新建一个进程处理该请求
应用请求:由用户进程主动请求创建一个子进程
3.2 进程终止
进程终止的过程:
- 找出被终止进程的PCB
- 若进程状态为运行态,置CPU调度标志为真(剥夺CPU)
- 若其有子孙进程,终止其子孙进程并回收其资源
- 将该进程拥有的所有资源归还给父进程或操作系统
- 回收终止进程的PCB
引起进程终止的事件:
1、正常结束
2、异常结束
- 越界错误、非法指令等
3、外界干预
- 操作员或操作系统干预
- 父进程请求
- 父进程终止
3.3 进程的阻塞和唤醒
进程阻塞的过程:
- 找到要阻塞的进程对应的PCB
- 保护进程运行现场,将PCB状态信息设置为“阻塞态”,暂时停止进程运行
- 将PCB插入相应事件的阻塞队列
- 转调度程序进行重新调度
引起进程阻塞的事件:
1、需要等待系统分配某种资源
2、需要等待相互合作的其他进程完成工作
进程唤醒的过程:
- 在事件阻塞队列中找到PCB
- 将PCB从阻塞队列中移除,设置其状态信息为“就绪态”
- 将PCB插入就绪队列,等待被调度
引起进程唤醒的事件:等待的事件发生(因何事阻塞,就应由何事唤醒)。
3.4 进程的挂起与激活
当出现引起进程挂起的事件时,系统利用挂起原语将指定进程或处于阻塞的进程挂起。
进程挂起过程:
- 检查被挂起进程的状态:
- 若处于活动就绪,则改为静止就绪;
- 若处于活动阻塞,则改为静止阻塞;
- 若挂起的进程正在执行,则改为静止就绪,并重新进行进程调度。
当发生激活进程的事件时,系统利用激活原语将指定进程激活。
进程的激活过程:
- 激活原语先将进程从外存调入内存;
- 然后检查该进程的状态:
- 若为静止就绪,则改为活动就绪;
- 若处于静止阻塞,则改为活动阻塞。
四、进程同步
进程同步的主要任务:使并发执行的诸进程之间能有效地共享资源和相互合作,从而使程序的执行具有可再现性。
进程间两种形式的制约关系
(1) 间接相互制约关系 --- 源于资源共享(互斥共享)
(2) 直接相互制约关系 --- 源于进程合作(同时访问)
我们把一个时间段内只允许一个进程使用的资源称为临界资源。许多物理设备(比如摄像头、打印机)都属于临界资源。此外还有许多变量、数据、内存缓冲区等都属于临界资源。对临界资源的访问,必须互斥地进行。互斥,亦称间接制约关系。进程互斥指当一个进程访问某临界资源时,另一个想要访问该临界资源的进程必须等待。当前访问临界资源的进程访问结束,释放该资源之后,另一个进程才能去访问临界资源。
对临界资源的互斥访问,可以在逻辑上分为如下四个部分:
进程同步机制应遵循的规则:
1、空闲让进。临界区空闲时,可以允许一个请求进入临界区的进程立即进入临界区。
2、忙则等待。当己有进程进入临界区时,其他试图进入临界区的进程必须等待。
3、有限等待。对请求访问的进程,应保证能在有限时间内进入临界区(保证不会饥饿)。
4、让权等待。当进程不能进入临界区时,应立即释放处理机,防止进程忙等待。
4.1 信号量机制
信号量其实就是一个变量(可以是一个整数,也可以是更复杂的记录型变量),表示系统中某种资源的可用数量。
比如:系统中只有一台打印机,就可以设置一个初值为1的信号量。
用户进程可以通过使用操作系统提供的一对原语来对信号量进行操作,从而很方便的实现了进程互斥、进程同步。
一对原语:wait(S)原语 和 signal(S)原语,可以把原语理解为我们自己写的函数,函数名分别为wait和signal,括号里的信号量S其实就是函数调用时传入的一个参数。wait、signal原语常简称为P、V操作(来自荷兰语proberen和verhogen)。因此,做题的时候常把wait(S)、signal(S)两个操作分别写为P(S)、V(S)
4.1.1 整型信号量
用一个整数型变量作为信号量。
例如:某系统中只有一台打印机,有多个并发进程访问。
先来看wait 和 signal 原语内部实现细节(很简单,就不逐句阐述了),考试时直接写wait(S) 和 signal(S) ,或者P(S)、V(S)就行了。
然后把使用打印机资源这个互斥共享资源 放在wait(s) 和 signal(s)之间就行了。格式如下:
只有P0进程释放了资源S后,P1才能使用打印机……
4.1.2 记录型信号量
在整型信号量机制中的wait操作,只要是信号量S <= 0,就会不断地测试。因此,该机制没有遵循“让权等待”的准则,而是使进程处于“忙等”的状态。因此人们又提出了“记录型信号量”。
先来看wait 和 signal 原语内部实现细节:
对比整型信号量,其实就是多加了一个进程链表指针L,用于链接所有等待进程。
- 因此当value < 0 时,其绝对值就是阻塞队列中等待的进程数。
- value > 0,代表可用资源的数量
- 注意在什么条件下,执行block和wakeup原语!
- 记录型信号量遵循让权等待原则,不会“忙等”
记录型信号量是很重要的,当考试中出现P(S)、V(S)的操作,除非特别指明,否则默认S为记录型信号量。
4.1.3 AND型信号量
前面所述的进程互斥问题针对的是多个并发进程仅共享一种临界资源的情况。在有些应用场合, 是一个进程往往需要获得两个或更多的共享资源后方能执行其任务,因此需要引入“AND型信号量”。
AND 同步机制的基本思想是: 将进程在整个运行过程中需要的所有资源, 一次性全部地分配给进程,待进程使用完后再一起释放。只要尚有一个资源未能分配给进程, 其它所有可能为之分配的资源也不分配给它。亦即,对若干个临界资源的分配采取原子操作方式:要么把它所请求的资源全部分配到进程, 要么一个也不分配(有效避免死锁)。
那么wait操作就需要改变成如下:
Swait(S1, S2, …, Sn)
{
while(TRUE)
{
if (S1 >=1 and … and Sn>=1 ){
for( i=1 ;i<=n; i++) Si--;
break;
}
else{
Place the process in the waiting queue associated with the first Si
found with Si < 1,and set the progress count of this process to the
beginning of Swait operation
}
}
}
S1到Sn都表示所需资源,资源数都大于1,对每个资源进行(Si--)表示此类资源数被占用一个,分配好资源之后跳出循环,wait操作结束。如果其中某个资源Si得不到满足,会执行else中的内容:将进程放入第一个Si<ti的信号量对应的阻塞队列,然后程序计数器把指针移向wait操作开始。(wait操作是原语,遵循要执行都执行,执行不了就从头重新执行)。
Ssignal(S1, S2, …, Sn){
while(TRUE){
for (i=1; i<=n; i++) {
Si++ ;
Remove all the process waiting in the queue associated with Si into
the ready queue
}
}
}
signal操作表示的是释放资源,将与Si关联的队列中等待的所有进程移到就绪队列中。
4.1.4 信号量集
在前面的操作中,wait(S)或signal(S)操作仅能对信号量施以加1或者减1操作,意味着每次只能获得或释放一个单位的临界资源。而当一次需要N个某类临界资源时,便要进行N次wait(S)操作,显然这是低效的。此外,在有些情况下,当资源数量低于某一下限值时,便不予分配。因而,在每次分配前,都必须测试该资源数量,看其是否大于其下限值。基于上述两点,可以对AND信号量机制加以扩充,形成一般化的“信号量集”机制。
进程对信号量Sᵢ的测试值不再是 1, 而是该资源的分配下限值 , 即要求Sᵢ≥tᵢ , 否则不予分配。一旦允许分配, 进程对该资源的需求值为
, 即表示资源占用量, 进行 Si=Si-di 操作, 而不是简单的Si=Si-1。由此形成一般化的“信号量集”机制。对应的 Swait 和 Ssignal 格式为:
Swait(S1, t1, d1; …; Sn, tn, dn)
Ssignal(S1, d1, …, Sn, dn)
一般“信号量集”可以用于各种情况的资源分配和释放。下面是几种特殊的情况:
- Swait(S,d,d)表示每次申请d个资源,当资源数量少于d个时,便不予分配。
- Swait(S,1,1)表示记录型信号量。
- Swait(S,1,0)可作为一个可控开关(当S≥1时,允许多个进程进入临界区;当S=0时禁止任何进程进入临界区)。
4.2 信号量的应用
4.2.1 实现进程互斥
为使多个进程互斥的访问某临界资源,须为该资源设置一互斥信号量mutex,并设其初始值为1,然后将各进程访问资源的临界区置于wait(mutex)和signal(mutex)之间即可。
例:用记录型信号量实现两个进程互斥使用一台打印机。
semaphore mutex =1; //表示打印机(不需要写出其数据结构,这么表示就OK啦)
bigin
parbegin
p1: begin
repeat
… …
wait(mutex);
使用打印机
signal(mutex);
… …
until false;
end
p2: begin
repeat
… …
wait(mutex);
使用打印机
signal(mutex);
… …
until false;
end
parend
end
注意:
- 对不同的临界资源需要设置不同的互斥信号量
- P、V操作必须成对出现
4.2.2 实现前驱关系
要掌握进程同步,关键是要实现前驱关系。
用信号量实现前驱的关键步骤:
- 分析什么地方需要实现“同步关系”,即必须保证“一前一后”执行的两个操作。
- 设置同步信号量S,初始值为0
- 在“前操作”之后执行V(S)
- 在“后操作”之前执行P(S)
上面都是总结,还不理解没关系,往下看就明白了。
现在假设有某种资源,它一开始是没有的,只能由P1进程执行完“代码2”后才能产生,之后P2进程才能利用这个产生的资源执行“代码4”,那么要实现 P1 -> P2 这种同步关系,代码如下:
代码理解:
【1】若先执行到V(S)操作, 则S++后S=1。。之后当执行到P(S)操作时,由于S=1,,表示有可用资源, 会执行S--, S的值变回0,P2进程不会执行block原语,而是继续往下执行代码4。
【2】若先执行到P(S)操作, 由于S=0,S-后S=-1,表示此时没有可用资源, 因此P操作中会执行 block原语,主动请求阻塞。之后当执行完代码2, 继而执行V(S) 操作,S++,使S变回0,由于此时有进程在该信号量对应的阻塞队列中,因此会在V操作中执行 wakeup原语, 唤醒 P2 进程。这样 P2就可以继续执行代码4了
当然这种只是单一的前驱关系,但是只有搞懂这个,才能理解下面的多级前驱关系。
但无论它多么复杂,我们只需要记住:为每一对前驱关系各设置一个同步信号量,然后正确使用P、V操作就OK啦。
例如某前驱图如下:
其中a、b、c……e、f、g表示每对前驱关系对于的同步信号量。那么代码实现如下:
考试建议按书上P062那样书写,简洁明了。例如:
semaphore a=0, b=0, c=0, d=0, e=0, f=0, g=0;
begin
parbegin
begin S1; signal(a); signal(b); end;
begin wait(a); S2; signal(c); signal(d); end;
begin wait(b); S3; signal(e); end;
begin wait(c); S4; signal(f); end;
begin wait(d); S5; signal(g); end;
begin wait(e); wait(f); wait(g); S6; end;
parend
end
4.3 经典进程同步问题
4.3.1 生产者-消费者问题
系统中有一组生产者进程和一组消费者进程,生产者进程每次生产一个产品放入缓冲区, 消费者进程每次从缓冲区中取出一个产品并使用。(注: 这里的“产品”理解为某种数据)
生产者、消费者共享一个初始为空、大小为n的缓冲区Buffer
基于上述描述,我们可以分析出两类前驱关系和一种互斥资源
- 只有缓冲区没满时,生产者才能把产品放入缓冲区,否则必须等待( 缓冲区没满→生产者生产)
- 只有缓冲区不空时, 消费者才能从中取出产品,否则必须等待( 缓冲区没空→消费者消费)
- 缓冲区是临界资源, 各进程必须互斥地访问。
可利用互斥信号量mutex实现诸进程对缓冲池的互斥使用;利用信号量empty和full分别表示缓冲池中空缓冲池和满缓冲池的数量。
这里的代码在书上P066。
Int in=0,out=0;
Item buffer[n];
Semaphore mutex=1,empty=n,full=0;
void producer(){
do{
....;//生产一个产品nextp;
wait(empty);
wait(mutex);
buffer[in]=nextp;//把产品放入缓冲区
in=(in+1) % n;
signal(mutex);
signal(full);
}while(TRUE)
}
void consumer(){
do{
wait(full);
wait(mutex);
nextc =buffer[out];//从缓冲区取出一个产品
out =(out+1) mod n;
signal(mutex);
signal(empty);
...;//消费nextc产品;
}while(TRUE)
}
void main(){
cobegin
proceducer();
consumer();
coend
}
注意:
【1】实现互斥的P操作一定要在实现同步的P操作之后。不然容易死锁!
解释:若此时缓冲区内已经放满产品, 则empty = 0 , full = n。
则生产者进程执行①使mutex变为0, 再执行②,由于已没有空闲缓冲区, 因此生产者被阻塞。由于生产者阻塞,因此切换回消费者进程。消费者进程执行③,由于mutex为0, 即生产者还没释放对临界资源的“锁”, 因此消费者也被阻塞。
这就造成了生产者等待消费者释放空闲缓冲区,而消费者又等待生产者释放临界区的情况,生产者和消费者循环等待被对方唤醒,出现“死锁”。
同样的, 若缓冲区中没有产品, 即fu‖=0,emow=n。按③④①的顺序执行就会发生死锁。
因此,实现互斥的P操作一定要在实现同步的P操作之后。
【2】V操作不会导致进程阻塞, 因此两个V操作顺序可以交换。
【3】能否把生产一个产品和使用产品放到各自的P、V操作中呢?
解释:可以,但是性能大大降低,因为这会延长锁的时间。生产和使用又不是互斥资源不用把这些东西都放入临界区里!
上述问题也可以用AND型信号量解决,在书上P067,自己去看,不是很难。
4.3.2 多生产者-多消费者问题
直接看例子吧。
桌子上有一只盘子,每次只能向其中放入一个水果。爸爸专向盘子中放苹果,妈妈专向盘子中放橘子,儿子专等着吃盘子中的橘子,女儿专等着吃盘子中的苹果。只有盘子空时,爸爸或妈妈才可向盘子中放一个水果。仅当盘子中有自己需要的水果时,儿子或女儿可以从盘子中取出水果。
分析:
互斥关系: 对缓冲区(盘子)的访问要互斥地进行
同步关系(一前一后):
- 父亲将苹果放入盘子后,女儿才能取苹果
- 母亲将橘子放入盘子后,儿子才能取橘子
- 只有盘子为空时,父亲或母亲才能放入水果
对于同步关系,“ 只有盘子中有水果,儿子或女儿才能取走水果 ”,等价于上述的 1 和 2,所有这里没有列出来。因此可以画出如下的P、V关系
semaphore mutex = 1; //实现互斥访问盘子(缓冲区) semaphore apple = 0; //盘子中有几个苹果 semaphore orange = 0; //盘子中有几个橘子 semaphore plate = 1; //盘子中还可以放多少个水果 //从传统的生产者-消费者问题出发: apple 和 orange 相当于full ; plate 相当于 empty
代码实现如下:
注意:本题中的缓冲区大小为1,在任何时刻,apple、orange、plate三个同步信号量中最多只有一个是1。因此在任何时刻, 最多只有一个进程的P操作不会被阻塞,并顺利地进入临界区…
因此可以不用再加上mutex。
但是如果缓冲区大小大于1,就必须专门设置一个互斥信号量mutex来保证互斥访问缓冲区。
当然还要单生产者-多消费者问题,比如:【吸烟者问题】,这里就不做阐述了。
4.3.3 读者写者问题
长话短说,就是:读、读共享;写、写互斥;写、读互斥。
直接从代码角度分析:
- 互斥信号量wmutex: 实现Reader与Writer进程间在读或写时的互斥
- 整型变量readcount: 表示正在读的进程数目
- 互斥信号量rmutex在下文揭秘
semaphore rmutex=1,wmutex=1;
int readcount=0;
void Reader()
{
while(1)
{
wait(rmutex);
if(readcount==0)
wait(wmutex);
readcount++;
signal(rmutex);
读文件;
wait(rmutex);
readcount--;
if(readcount==0)
signal(wmutex);
signal(rmutex);
}
}
void Writer()
{
while(1)
{
wait(wmutex);
写文件;
signal(wmutex);
}
}
分析:
【1】首先对于互斥信号量wmutex。因为在同一文件上,读和写不能共存,写和写不能共存,需要互斥访问。
【2】然后对于整型变量readcount。我把重要的话说在前面:这是实现读、读共享的关键。因为readcount用来表示正在读进程的数目,一开始第一个读进程进来,因为readcound == 0,所以执行wait(wmutex),然后再执行readcount++,当第二个读进程进来时,因为readcount == 1 ,所以不用再执行wait(wmutex),这样就实现了多个读进程共享同一文件。最后当readcount == 0时,表示已无读进程在读文件,然后执行signal(wmutex),那么写进程才能获得资源进行写操作。
【3】互斥信号量rmutex的解释:若此时有两个读进程并发执行,则 readcount=0 时两个进程也许都能满足if条件, 都会执行wait(wmutex), 从而使第二个读进程阻塞的情况(比如第一个读进程还没来的急执行readcount++,第二个进程就已经通过if (readcount == 0)的判断,然后被阻塞了)。而出现上述问题的原因在于对readcount变量的检查和赋值无法一气呵成, 因此可以设置另一个互斥信号量rmutex来保证各读进程对readcount的访问是互斥的。
看到这里,聪明的你可能已经发现问题了,如果一直有读进程在读,那么写进程就要一直等待下去,这样可能会导致写进程”饿死“ 。而上述这种写法正是只满足了读进程优先原则的。
而对于 写进程优先 和 读写公平 的实现可以参考其他博文的讲解,感兴趣可以去了解一下,很有意思。
4.3.4 哲学家进餐问题
一张圆桌上坐着5名哲学家,每两个哲学家之间的桌上摆一根筷子, 桌子的中间是一碗米饭。哲学家们倾注毕生的精力用于思考和进餐,哲学家在思考时, 并不影响他人。只有当哲学家饥饿时,才试图拿起左、右两根筷子(一根一根地拿起)。如果筷子已在他人手上,则需等待。饥饿的哲学家只有同时拿起两根筷子才可以开始进餐,当进餐完毕后, 放下筷子继续思考。
信号量设置:定义互斥信号量数组chopstick[5]={1,1,1,1,1}用于实现对5个筷子的互斥访问。并对哲学家按0~4编号, 哲学家i左边的筷子编号为i, 右边的筷子编号为(i+1)%5。
先来看一个错误例子,当哲学家饿了时,总是先拿其左边的筷子,再去拿右边的筷子。那么:
semaphore chopstick[5] = {1, 1, 1, 1, 1}; //初始化信号量
void philosophers(int i){
do{
// thinking
P(chopstick[i]) // 判断左边的筷子是否可用
p(chopstick[(i+1)%5]); // 判断右边的筷子是否可用
// ...
// eat
// ...
V(chopstick[i]); //退出临界区,放下左边的筷子
V(chopstick[(i+1)%5]); //退出临界区,放下右边的筷子
//thinking
}while(true);
}
我们来分析下上面的代码,首先我们从一个哲学家的角度来看问题,程序似乎是没有问题的,申请到左右两支筷子后,然后开始进餐。
但是如果考虑到并发问题,五个哲学家同时拿起了左边的筷子,此时,五只筷子立刻都被占用了,没有可用的筷子了,当所有的哲学家再想拿起右边筷子的时候,因为临界资源不足,只能将自身阻塞,而所有的哲学家都应等待右边的筷子而全被阻塞,并且不会释放自己手中拿着的左边的筷子,因此就会一直处于阻塞状态,无法进行进餐并思考。
就以书上那三种方式实现哲学家进餐问题:
【1】方法一
可以对哲学家进程施加一些限制条件, 比如最多允许四个哲学家同时进餐。这样可以保证至少有一个哲学家是可以拿到左右两只筷子的。
semaphore chopstick[5] = {1, 1, 1, 1, 1};
semaphore count = 4; // 最多允许四位哲学家同时进餐
void philosophers(int i){
do{
//thinging
P(count); // 判断是否超过四个
P(chopstick[i]); // 判断缓冲池是否仍有空闲的缓冲区
P(chopstick[(i+1)%5]); // 判断缓冲池是否仍有空闲的缓冲区
// ...
// eat
// ...
V(chopstick[i]); // 释放空闲的缓冲区
V(chopstick[(i+1)%5]); // 释放空闲的缓冲区
V(count);
//thinking
}while(true);
}
【2】方法二
仅当哲学家的左、右两只筷子均可用时,才允许他拿起筷子进餐。
很明显可以通过AND型信号量解决问题。
semaphore chopstick[5] = {1, 1, 1, 1, 1}; // 初始化信号量
void philosophers(int i){
do{
// thinking
Swait(chopstick[i], chopstick[(i+1)%5]); // 进餐,同时判断左右的筷子
// ...
// eat
// ...
Ssignal(chopstick[i] , chopstick[(i+1)%5]); // 进餐完毕,释放筷子
//thinking
}while(true);
}
【3】方法三
要求奇数号哲学家先拿左边的筷子,然后再拿右边的筷子,而偶数号哲学家刚好相反。用这种方法可以保证如果相邻的两个奇偶号哲学家都想吃饭,那么只会有其中一个可以拿起第一只筷子,另一个会直接阻塞。这就避免了占有一支后再等待另一只的情况。
semaphore chopstick[5] = {1, 1, 1, 1, 1}; // 初始化信号量
void philosophers(int i){
do{
// thinking
if(i%2 == 1){
P(chopstick[i]); // 左
P(chopstick[(i+1)%5]); // 右
}else{
P(chopstick[(i+1)%5]); // 右
P(chopstick[i]); // 左
}
// ...
// eat
// ...
V(chopstick[i]); // 释放空闲的缓冲区
V(chopstick[(i+1)%5]); // 释放空闲的缓冲区
//thinking
}while(true);
}
五、进程通信
进程通信就是指两个进程之间产生数据交互。
为了确保安全,一个进程不能直接访问另一个进程的地址空间(每个进程都拥有自己独立的地址空间)。
目前高级通信机制有如下几种方式:
5.1 共享存储器系统
相互通信的进程间共享某些数据结构或共享存储区,通过这些空间进行通信。
两种方式:
-
基于共享数据结构的通信方式: 比如共享空间里只能放一个长度为10的数组。这种共享方式速度慢、限制多, 是一种 低级通信方式
-
基于共享存储区的通信方式:操作系统在内存中划出一块共享存储区, 数据的形式、存放位置都由通信进程控制, 而不是操作系统。这种共享方式速度很快, 是一种 高级通信方式。
原理:设置一个共享内存区域,并映射到进程的虚拟地址空间。
注意:需要互斥地访问共享空间(由通信进程自己负责实现互斥)
5.2 管道通信系统
管道:指用于连接一个读进程和一个写进程以实现他们之间通信的一个打开的共享文件,又名pipe文件。
共享存储区域可以理解为双端队列,可以实现双向通信。而管道这种共享文件像普通队列,只能实现单向通信。要想实现双向,只能建立两个管道。
注意:
- 一个管道只能实现半双工通信
- 各进程要互斥访问管道(由操作系统负责实现互斥)
5.3 消息传递系统
进程间的数据交换以格式化的消息(Message)为单位,进程通过操作系统提供的“发送消息 / 接收消息 ” 两个原语进行数据交换。
两种方式:
- 直接通信方式:直接把消息发送给目标进程。
- 间接通信方式:消息先发到中间体(信箱),然后目标进程用接收原语从信箱读出。
信箱这种方式,可以实现一对一; 多对一; 一对多; 多对多的进程通信。
七、线程
现在有一个QQ进程,如果不引入线程,那么聊天处理程序和文件传送处理程序就不能并发运行,只能串行执行,那么引入线程将进一步提升系统的并发性。
因此引入线程的目的:减少程序在并发执行时所付出的时空开销,使OS具有更好的并发性。
引入线程后带来的变化:
【1】资源和调度
- 传统进程机制中, 进程具备那两个基本属性
- 引入线程后, 进程是拥有系统资源的基本单位, 线程作为调度和分派的基本单位(独立运行的基本单位)
【2】并发性
- 传统进程机制中, 只能进程间并发
- 引入线程后, 各线程间也能并发, 提升了并发度
【3】系统开销
- 传统的进程间并发, 需要切换进程的运行环境, 系统开销很大
- 线程间并发, 如果是同一进程内的线程切换, 则不需要切换进程环境, 系统开销小
- 引入线程后, 并发所带来的系统开销减小
线程和进程的关系是:线程是属于进程的,线程运行在进程空间内,同一进程所产生的线程共享同一物理内存空间,当进程退出时该进程所产生的线程都会被强制退出并清除。
7.1 线程的属性
在多线程OS中,通常一个进程包括多个线程,每个线程是利用CPU的基本单位,是花费最小开销的实体。
7.2 进程的属性
在多线程OS中,进程是作为拥有系统资源的基本单位,通常进程都包含多个线程并为它们提供资源,但进程不再作为一个执行的实体。
此时进程的属性:
- 作为系统资源分配的单位
- 可包括多个线程
- 进程不是一个执行的实体
7.3 线程的实现(略)
需要的同学直接点下面链接去看吧,博主乏了,不想写了。
八、结尾
最后,非常感谢大家的阅读。我接下来还会更新第三章:【调度和死锁】 ,如果本文有错误或者不足的地方请在评论区(或者私信)留言,一定尽量满足大家,如果对大家有帮助,还望三连一下啦!
Reference
【1】 汤子瀛, 哲凤屏, 汤小丹,梁红兵. 计算机操作系统[第四版]. 西安电子科技大学出版社