在OS中引入进程后,一方面使系统的吞吐量和资源的利用率得到提升,另一方面也使得系统变得复杂,如果没有合理的方式对进程进行妥善的管理,必然会引起进程对系统资源的无序竞争,使系统变得混乱;为了实现对并发进程的有效管理,在多道程序系统中引入了同步机制,常见的同步机制有:硬件同步机制、信号量机制、管程机制等,利用它们确保程序执行的可再现性;
这里所说的制约关系,其实是一种相互影响的关系,即某一个进程的运行除了受到系统影响外还会受到其他进程运行情况的影响;而上面两种分类方式的依据是产生影响的原因;
在操作系统引论中介绍操作系统的四大特性之一“共享”时曾提到,对于资源的共享有两种方式:互斥共享和同时访问。其中需要互斥访问的资源就是临界资源;所谓互斥访问,就是指在进程A对资源X的处理结束前,其他进程不允许对X进行处理;这里的临界资源既包括硬件资源也包括软件资源;
对于临界资源进行访问的代码,即为临界区;在进入临界区之前,需要检查是否可以访问互斥资源,这一部分代码即为进入区;在退出临界区时,需要释放对临界资源的占有,这一部分代码即为退出区;然后我们把剩下的其余代码成为剩余区
利用软件方法可以解决进程互斥进入临界区的问题,但是有一定的难度和局限性,现已很少使用。通常计算机会提供一些特殊的硬件指令,允许对一个字中的内容进行检测和修正,或者对两个字的内容进行交换;对临界区的管理,可以视为对“锁”的管理:当“锁”开的时候,就允许进入,然后把“锁”关上;当“锁”关上的时候,就只能在外面等待;显然,对“锁”的检测(相当于进入区代码)和打开“锁”(相当于临界区)的操作必须是连续的;常见的硬件同步机制有:
关中断
是实现互斥的最简单方法之一。在进入锁检测之前,关闭中断,知道完成锁检测并上锁之后才打开中断。这样,进程在临界区执行期间,计算机系统不响应中断,从而不会引发调度,自然不会发生进程或者线程切换。但是关中断的方法有许多缺点:1。滥用关中断权利,可能会造成严重后果;2. 关中断时间过长,会影响系统效率,限制处理器交叉执行程序的能力;3.关中断的方法不适合多CPU系统;
利用Test-and-Set 指令实现互斥
TS指令的一般描述如下:
boolean TS(boolean *lock){
boolean old;
old=*lock;
*lock=true;
return old;
}
相应的进入区代码为:
while(TS(&lock));
TS指令中,当lock为false时,就将其设置为true,然后返回false;当lock为true时,就返回true;
返回false表示资源可用;返回true表示资源不可用;
上面这段代码实现的功能:如果lock为false,那么设置它为true,但是要返回false;如果lock为true,不做改变,那么仍旧返回true;但实际上,它是这么做的:不论lock是什么,都把它设置为true。而返回它原来的值;
利用Swap指令实现互斥
void Swap(boolean* lock,boolean* key){
boolean temp=*lock;
*lock=*key;
*key=temp;
}
相应的进入区代码为:
key=true;
do{
Swap(&lock,&key);
}while(key!=false);
//进入临界区
Swap指令中,do-while循环中的退出条件是key为false ;而key为false 意味着lock为false,表示资源可用;当lock为true的时候,key就为true;那么循环就会一直进行下去;感觉还是蛮绕的,分类看看:
进入Swap之前 | 进入Swap之后 | 是否再次进入Swap |
---|---|---|
lockfalse,keytrue(资源可用) | locktrue,keyfalse(允许进入临界区) | 否 |
locktrue,keytrue(资源不可用) | locktrue,keytrue(不允许进入临界区) | 是 |
这里,我们可以看出,当资源不可用是,进入Swap并没有任何改变,这是符合功能的:资源不可用,当然检查多少遍都不可用嘛;
由于资源是共享的,所以这里的lock作为资源的标记之一,必然会被多个进程访问,所以,当一个拥有资源的进程使用完该资源的时候,需要将lock设置为false,以便让其他进程使用,这就是退出区代码的任务啦;
利用TS机制和Swap机制,都会让进程处于忙等状态,并不符合同步机制的要求;(准确的说,不是实现不了同步,而是效率不高,不太高效~)
信号量同步机制由Dijkstra(很厉害的大神,单源最短路劲算法就是他提出的);信号量机制已被广泛应用到单处理机和多处理机系统以及计算机网络中;
整型信号量
整型信号量S表示资源数目,除初始化外,仅能通过两个标准的原子操作进行修改:wait(S)和signal(S);这两个操作长期以来也别称为P、V操作;
wait(S){
while(S<=0);
S--;
}
signal(S){
S++;
}
其实问题就是,wait和signal两个原子操作仍旧会产生“忙等”——进程不断测试,一直问,你说烦不烦?
记录型信号量
记录型信号量机制是一种不存在忙等现象的进程同步机制;但是采取了让权等待策略后,就会有多个进程等待访问统一资源的情况,于是还需要把这些进城组织起来,于是除了S用来表示资源的数量外,还需要一个指针;这也是记录型信号量的名称来源:使用了记录型的数据结构;
typedef struct{
int value;
sturct process_control_block *list;
}semaphore;
wait(semaphore *S){
S->value--;
if(S->value<0){
block(S->list);
}
}
signal(semaphore *S){
S->value++;
if(S->value<=0){
wakeup(S->list);
}
}
记录型信号量中,value不仅指示资源的数量,由于每次wait操作value都会递减,所以value的值会反映出等待资源的进程有多少个。在signal中,value经过自增后,如果还<=0,说明还有进程在等待该资源,所以需要wakeup一个进程;
AND型信号量
前面所述的进程互斥问题针对的是多个并发进程共享一个临界资源的情况,但是如果多个进程共享多个资源时仍旧采取这样单个的分配方法,就有可能发生死锁现象;为了避免这样的现象,提出来AND型信号量:将进程在整个运行过程中需要的所有资源,要么一次性全部分配给进程,然后使用完后再一起释放。要么一个都不分配,这样便可以避免死锁现象。wait和signal操作要做出相应改变。
Swait(S1,S2,S3,S4,S5....){
while(true){
if(S1>=1&&S2>=1...){
for(i=1;i<=n;i++){
Si--;
}
break;
}else{
//找到第一个小于等于0的Si,然后将进程放置到与其相关的等待队列中
}
}
}
Ssignal(S1,S2...Sn){
while(true){
for(i=0;i<=n;i++){
Si++;
//唤醒一个等待Si资源的进程——该进程将进入Swait中的while循环里继续判断其他资源是否可用。
}
}
}
信号量集
前面介绍的几种信号量同步机制都是对某一资源进行一个单位的申请和释放。当一次需要N个的时候,就需要进行N次请求,这不但低效而且容易发生死锁情况;还有些情况下,为了保证系统的安全性,当所申请的资源低于某个值时,就需要停止对该类资源的分配。解决办法就是当进程申请某类临界资源时,都必须测试资源的数量,判断是否大于可分配的下限值,然后决定是否分配;
基于上述提到的两点问题,需要对AND信号量机制加以扩充,对进程所申请的所有资源以及每类资源不同的资源需求量,再一次PV原语操作中完成申请和释放。对信号量Si的测试值不再是1,而是ti。当Si<=ti时就不再分配;同时,进程需要传递给wait方法每类资源所需要的数目,由此形成一般化的“信号量集”机制;
Swait(S1,t1,d1....Sn,tn,dn);表示对Si类资源的需求是di个,当Si的数量小于ti时就不再分配;
Ssignal(S1,d1....Sn,dn);表示归还Si类资源的数目是di个;
特殊的,Swait(S,d,d)表示信号量集中只有一个信号量;它允许每次申请d个资源,当资源数量小于d时不予分配;
Swait(S,1,1)表示普通的一般记录型信号量;
信号量的应用:
信号量机制虽然是一种既方便又实用的进程同步机制,但是要访问临界资源的进程需要自备同步操作wait(S)和signal(S),这就使得对共享资源进行访问的代码遍布各个进程,不利于系统管理,还增加系统死锁的风险;管程机制是一种解决该问题的方法;
操作系统的作用之一就是实现对计算机系统资源的抽象,管程机制使用少量的信息和对该资源所执行的操作来表征该资源,所以共享系统资源就变为了共享数据结构,并将对这些共享数据结构的操作定义为一组过程。进程对共享资源的申请、释放和其他操作必须通过这组过程。代表共享资源的数据结构以及由对该共享数据结构实施操作的一组过程所组成的资源管理程序共同构成了一个操作系统的资源管理模块,我们称之为管程;
管程由四部分组成:名称、局部于管程的共享数据结构说明、对该数据结构进行操作的一组过程、对局部于管程的共享数据结构设置初始值的语句;
所有进程访问临界资源时,都只能通过管程间接访问,而管程每次只准许一个进程进入管程,从而实现互斥。管程体现了面向对象程序设计的思想;具有:模块化,即管程是一个独立的基本单位,可单独编译;抽象数据类型,不仅有数据还有对数据的操作;信息隐蔽,管程中的数据结构只能被管程中的过程访问,这些过程也是在管程内部定义的,而管程之外的进程只需调用而无需了解其内部的具体实现细节。(这样,原来遍布系统的共享资源的访问代码,就集中到管程中啦);
管程和进程的对比(两个截然不同的概念,有什么好对比的呢?大概是名字相似吧)
管程中还有一个比较重要的概念就是条件变量。当一个进程进入了管程但在管程中被阻塞或者挂起,此时该进程需要释放对管程的占有,并且根据阻塞或者挂起的原因,也就是条件变量,进入相应的等待队列,等待其他进程的唤醒。条件变量x具有两种操作:x.wait()和x.signal();
x.wait():正在调用管程的进程因x条件而需要被挂起或者阻塞,则调用x.wait()将自己插入到条件变量x的等待队列上并释放管程,直到x条件变化;
x.signal():正在调用管程的进程发现x条件发生了变化,重新启动一个因x而阻塞的进程,如果有多个进程因x而阻塞,也只能选择一个;
如果进程Q因为x条件而处于阻塞状态,当P调用管程时,执行了x.signal()操作后,Q重新启动,此时P和Q到底谁来继续拥有管程呢?答案是两者均可;
这里只列出问题,不做具体介绍和分析,关于进程同步,其实更多的是线程同步,有一本很棒的书《图解Java多线程设计模式》,这本书讲解了12种如何利用多线程来编程的方法,每一种方法作者将其称为一种模式,其中就有下面提到的问题的解决方案。这十二种方法讲解了到底该如何安全地使用多线程(即考虑到线程的同步问题)
在进程之间要传送大量数据时,应当利用OS提供的高级通讯工具,该工具的特点是:
高级通信机制分为四类:共享存储器、管道通信系统、消息传递系统以及客户机-服务器系统;
相互通信的进程通过共享某些数据结构或者共享存储区,进城之间通过这些空间实现通信;常见的有基于共享数据结构的通信方式和基于共享存储区的通信方式;
基于共享数据结构的通信方式
通信进程共用某些数据结构来实行进程之间的信息交换。操作系统仅提供共享存储器,由程序员负责对公用数据结构的设置以及进程间同步的处理;这种方式仅适用于传输数据量少的情况下,通信效率低,属于低级通信;
基于共享存储区的通信方式
为了传输大量数据,在内存中划出一块共享存储区域,各个进程通过对该共享区的读或者写交换信息,从而实现通信;数据的形式和位置甚至访问方式都是由进程负责。这种通信方式属于高级通信;需要通信的进程首先向系统申请获得共享存储区的一个分区,将其添加到自己的地址空间,当通信结束后,再将此区域归还系统;
管道,是指用于连接一个读进程和一个写进程以实现他们之间通讯的一个共享文件,有名pipe文件;写进程将信息以字符流的形式送入管道;而读进程将从管道中接受数据。由于通信双方通过管道文件通信,所以这种通信方式也称为管道通信;管道通信需要解决三个问题:
在该机制中,进程不必借助任何共享存储区或者数据结构即可实现通信,它以格式化的消息为单位,将信息封装在消息中,利用操作系统提供的消息发送原语,在进程之间实现消息传递,完成数据交换;
该通信方式隐藏了通信细节,使通信过程对用户透明化,降低了通信程序设计的复杂性和错误率,成为当前应用最为广泛的一种进程同信机制;由于该机制能很好支持多处理机系统、分布式系统和计算机网络,因此也成为这些领域中最主要的通信工具;
基于消息传递系统的进程间通信属于高级通信方式,按照其实现方式的不同,进一步分为:直接通信方式和间接通信方式;其中直接通信方式使用操作系统提供的原语操作,直接将信息发送到接受进程;间接通信方式通过共享中间实体(被称为邮箱)来实现进程间通信;
在直接通信方式中,存在两种寻址方式,一种是对称寻址方式,另一种是非对称寻址方式;
对称寻址方式中,接收方需要明确指出发送方的标记,发送方需要明确指出接收方的标记,问题就是一旦某个进程改变了该标记,所有与其有通信的进程都需要做出改变,不利于实现进程定义的模块化;
非对称寻址方式中,接收方不需要明确指出发送方的标记,只需填写表示原进程的标记,但是发送方需要指出接收方的标记;
发送进程和接受进程之间要能通行,就需要建立通信链路。有两种方式建立通信链路,一种是发送进程在通信前显式调用“建立连接”命令请求系统为之建立一条通信链路;链路在使用完毕后拆除,这种方式主要用于计算机网络中;另一种是,发送进程无需显式提出建立链路的请求,由系统自动为之建立一条链路;这种方式主要用于单机系统中;
而根据通行方式的不同,链路又可以分为:单向链路和双向链路;单向链路只允许发送进程向接收进程发送消息,不允许接收进程向发送进程发送消息;双向进程则可以实现双向通信;
关于信箱通信:
信箱被定义为一种数据结构,每个信箱都有一个唯一的标识符,消息在邮箱种可以安全地存取,只有被核准的目标用户才能随时读取;
信箱的结构逻辑上包含:
信箱的类型包括:
前面三种方式,共享存储区域、管道、消息传递系统也可以实现不同计算机进程之间的双向通信,但客户机-服务器系统的通信机制在网络环境中的各种应用领域已成为主流的通信实现方式;其主要有两类:套接字、远程过程调用或远程方法调用;
套接字
套接字来源于Unix操作系统,被设计用于同一台主机上的多个应用程序之间的通信,主要是为了解决多对进程同时通信时端口和物理线路的多路复用问题。
一个套接字就是一个通信标记类型的数据结构,包含通信的目的地址、通信使用的端口号、通信网络的传输层协议、进程所在的网络地址,以及针对客户或者服务器程序提供的不同系统调用等,是进程通信和网络通信的基本构件。套接字为客户机-服务器模式而设计,主要分为基于文件的和基于网络的两种;
套接字的优势在于,不但可以用于本地计算机内部的进程通信,还适用于网络环境中的不同计算机间的进程通信。每一个套接字都拥有一个唯一的套接字号,这样系统中所有的连接都持有唯一的一对套接字及其端口连接,可以方便地区分来自不同应用程序进程的连接通信,确保了通信双方之间逻辑链路的唯一性,便于实现数据传输的并发服务,还隐藏了通信设施及其实现细节;
远程过程调用或远程方法调用
远程过程调用RPC是一个通信协议,适用于通过网络连接的系统。该协议允许运行于一台主机系统(本地)上的进程调用另一台主机(远程)上的进程,对程序员表现为一般过程调用。在面向对象程序设计当中,这也称之为远程方法调用;
负责处理远程过程调用的进程有两个,一个是本地客户进程,另一个是远程服务器进程,这两个进程通常也称为网络守护进程,负责网络间的消息传递;
为了实现RPC的透明性,使得调用者感受不到此次调用的过程发生在其他主机上,RPC引入存根的概念:在本地客户端,每个能够独立运行的远程过程都对应一个客户存根,本地进程调用远程过程的实际是调用该过程所关联的存根。与此类似,每个远程进程所在的服务器端,其所对应的实际可执行进程也关联一个服务器存根。本地客户端存根于对应的服务器存根一般也是出于阻塞状态,等待消息;
客户端发起RPC的主要步骤是: