进程同步与通信
1.进程同步和互斥
1.1 进程同步和互斥的基本概念
在多道程序环境下,操作系统必须采取相应措施,处理好进程之间的制约关系。
**进程同步主要任务:**对多个有制约关系的进程,在执行次序上进行协调,从而使并发进程间能有效地、安全地互相合作和共享系统资源。
1.进程同步与进程互斥
进程间的同步与互斥或处理不好,会造成进程执行结果不正确,甚至出现死锁。
2.临界资源与临界区
1.临界资源也叫独占资源、互斥资源,指某段时间内只充许一个进程使用的资源。比如打印机等硬件,以及只能互斥使用的变量、表格、队列等软件。
2.临界资源的使用只能采用互斥方式。当一个进程正在使用某个临界资源且尚未使用完毕时,其它进程必须阻塞等待。只有当使用该资源的进程释放该资源时,其它进程才可使用。任何进程不能在其他进程没有使用完临界资源时使用该资源,否则将会导致错误。
3.各个进程中访问临界资源、必须互斥执行的代码段,称为临界区。
3.同步机制应遵循的准则
① 空闲让进。当没有进程处于临界资源对应的临界区时,表明该临界资源处于空闲态,应充许一个进程立即进入临界区。
② 忙则等待。当有进程已进入某临界资源对应的临界区时,表明该临界资源正在被使用,则其它试图进入临界区的进程,必须在进入区代码处等待。
③ 有限等待。对要求访问临界资源的进程,应保证其在有限时间内能进入自己的临界区,以免陷入“死等”状态。
④ 让权等待。当进程不能进入自己的临界区时,应立即阻塞自己并释放处理机,以免进程陷入“忙等”状态。
4.实现临界区互斥的基本方法
(1)软件实现方法
指在编写程序时,在临界区前设置检查语句,如有其他并发执行的进程在临界区中,则不允许进程进入临界区,只能在临界区外“忙等”或阻塞。当其他进程退出临界区后,此进程才能进入临界区运行。
常见方法有:
(1)Dekker算法
有两个进程P0、P1,设置一个整型全局变量turn,turn= i, 则系统允许Pi进入临界区。在进入区中,Pi循环检查本进程可否进入。在退出区,当Pi退出时,修改turn的值,以允许另一个进程进入。
设立一个bool型标志数组 flag[2],描述进程是否申请进入临界区, flag[0]、 flag[1]初值均为false 。某进程使用该数组时,先修改自己的标识=true,表示自己要进入临界区;然后检查另一个进程的标志。
(1)Peterson算法
1.标志数组 flag,标识每个进程是否在临界区。整型变量turn==j,表示允许进程Pj进入。
2.想进入临界区的进程,在进入区先修改自己的flag=true,并修改turn。然后检查对方的flag,如果不在临界区则自己进入。
3.否则,再检查turn,turn保存的是较晚的一次赋值,较晚的进程需等待。
4.P0想进入临界区为例,说明执行过程:先设置flag[0]=true,turn=1;然后检查flag[1],若为flase则直接进入。否则,P0、P1均要求进入临界区,产生冲突。再检查turn,由于turn保存的较晚的一次赋值,如果turn=1则表示P0是要求较晚的,因此要等待。
(2)硬件实现方法
① 中断禁用
为保证多个并发进程互斥使用临界资源,只需保证一个进程在执行临界区代码时,不被中断即可。这可通过启用/禁用中断原语来实现。方法如下:
whlie(1)
{ 禁止中断;
临界区;
启动中断;
进程其余部分;
}
② 专用机器指令
编程人员利用专用机器指令来实现临界区的互斥使用。
在临界区代码前,通过硬件指令来检查某一全局变量,确定是否有其他并发进程在临界区中使用。
若没有,则可进入临界区;若有,则重复检查,处于“忙等”状态。
当进程执行完临界区代码退出时,修改该全局变量,允许其他并发执行的进程进入临界区执行
Swap指令定义如下:
void Swap( int register,int memory)
{ int temp;
temp= memory;
memory=register;
register=temp;
}
该指令交换一个寄存器和一个存储单元的内容。指令执行过程中,其它任何指令对存储单元的访问都被禁止。
1.2 信号量机制
信号量基本思想:两个或多个进程,可以利用彼此收发的简单信号来实现“正确的”并发执行,一个进程在收到一个
指定信号前,会被迫在一个确定的或者需要的地方停下来,从而保持进程间的同步或互斥。
1.常见信号量分类
(1)整型信号量
整型信号是一个表示资源数目的整型变量S,只能对它进行3种操作:
- 初始化:如,S=1,S的值表示系统中可用资源的数目。
P原语操作: P(S)或wait(S),代码如下:
P(S)
while s<=0 do no-op
s=s-1;
V原语操作: P(S)或singnal(S),代码如下:
V(S) : S=S+1;
(2)记录型信号量
记录型信号量是一个结构体:
type semaphore=record
value : integer;
L: list of process;
end
1.3 利用信号量实现进程互斥关系
1.4 利用信号量解决同步问题
利用信号量可以实现进程或语句之间的前趋关系。设有两个并发执行的进程P1、P2, P1中有语句S1,P2中有语句S2,S1必须比S2先执行。则处理方式如下:
设置一个公有信号量T,基初值=0。两个进程代码如下:
进程P1: S1;V(T);
进程P2: P(T); S2;
由于T初值=0,P2若先执行,则P(T)会使它阻塞。只有P1先执行V(T)后,才会使T=1。
举例:
2.典型进程同步问题详解
2.1 生产者-消费者问题:
1.生产者-消费者问题,是最典型的进程同步问题。实际上,计算机系统中的许多问题,都可看作生产者和消费者问题或其扩展问题。
2.生产者-消费者问题描述了一组生产者进程,来向一组消费者进程提供产品。两类进程共享一个由n个缓冲区组成的有界缓冲池,生产者进程向空缓冲区中投放产品,消费者进程从放有数据的缓冲区中取得产品并消费掉。
3.只要缓冲池未满,生产者进程就可以把产品送入缓冲池;只要缓冲池未空,消费者进程便可以从缓冲池中取走产品。
4.但禁止生产者进程,向满的缓冲池再输送产品,也禁止消费者进程从空的缓冲池中提取产品。
5.计算机系统中也存在大量类似实例:输入数据时,输入进程是生产者进程,计算进程是消费者进程;输出时,计算进程是生产者进程,打印进程是消费者进程。
6.缓冲池具有n个缓冲区,常被组织成一个数组。每个缓冲区能存入一个产品,所有缓冲区构成一个循环缓冲结构。输入指针in指向下一个空缓冲区,输出指针out指向下一个满缓冲区。
7.在循环缓冲结构中,in指针加1表示为:in=(in+1)%n, out指针加1表示为:out=(out+1)%n。当in=out时,表示缓冲区空,当(in+1)%n =out时,表示缓冲区满。
8.生产者进程使用局部变量product_good晢存每次刚生产的产品,消费者进程使用局部变量consume_good晢存每次取出的产品。
9.设置两个信号量,解决生产者—消费者问题中的同步关系。empty为空缓冲区的数目,初值为缓冲区个数n;full为满缓冲区数目,初值为0。
10.由于众多生产者、消费者共享缓冲池,而缓冲池是个临界资源,必须互斥使用,因此设置一个互斥信号量mutex ,初值为1。
var mutex, empty, full : semaphore := 1,n,0;
buffer : array[0, …, n-1] of item;
in, out : integer∶= 0,0;
parbegin
Producer( ) //生产者进程
{
while(1)
{ …
produce a product_good;
…
P(empty); //获得一个空缓冲区
P(mutex); //互斥使用缓冲区
buffer(in) = product_good ;
in = (in+1) % n //把产品放入缓冲区中
V(mutex); //释放缓冲区
V(full); //缓冲池得到一个满缓冲区
until false;
end
}
}
Consumer() //消费者进程
{
while(1)
{P(full); //获得一个满缓冲区
P(mutex); //互斥使用缓冲区
consume_good = buffer(out);
out =(out+1) % n; //把产品拷入nextc
V(mutex); //释放缓冲区
V(empty); //缓冲池得到一个空缓冲区
consumer the item in nextc;
until false;
}
parend
semaphore bucket=3,jar=1,full=0,empty=10,well=1;
little_monk( ) /*小和尚入水算法*/
{ while(1)
{
P(empty);
P(bucket);
P(well);
从水井中打水;
V(well);
P(jar);
倒入水缸;
V(jar);
V(full);
V(bucket);
}
}
old_monk() /*老和尚取水算法*/
{ while(1)
{ P(full);
P(bucket);
P(jar);
从缸中取水;
V(jar);
V(jempty);
从桶中倒入饮用;
V(bucket);
}
}
2.3 读者-写者问题
3.管程机制
3.1 为何引入管程
信号量机制存在以下缺点:
① 信号量的P、V操作由用户在各个进程中分散使用,使用不当容易造成死锁,增加了用户编程负担。
② 信号量机制涉及多个程序的关联内容,代码可读性差。
③ 使用信号量机制不利于代码的修改和维护,程序模块独立性差,任一变量或一段代码的修改都可能影响全局。
④ 信号量机制的正确性很难保证。操作系统或并发进程通常会采用多个信号量,它们关系错综复杂,很难保证没有逻辑错误。
为了解决上述问题,1973年提出了管程机制。
3.2 管程的定义
**基本思想:**利用共享数据结构,抽象的表示系统中的共享资源,并把对该数据结构的操作定义为一组过程。进程对共享资源的申请、释放、其它操作,都通过这组过程对该数据结构的操作来实现。这组过程还可根据资源的使用情况,接收或阻塞进程的访问。
管程将信号量及其原语操作,封装在一个对象的内部,把共享资源及其对该资源进行的所有操作,都集中在一个模块之内。
**定义:**一个管程定义了一个数据结构和在此数据结构上能为并发进程执行的一组操作,这组操作能同步进程和改变管程中的数据。因此,管程是一种并发性的结构,它包括用于分配一个或一组共享资源的数据和过程,使用者使用时可忽略管程内部的实现细节,减轻了编程者负担。
管程由四部分组成:
1.管程的名称;
2.局部于管程内部的共享数据结构说明;
3.对该数据结构进行操作的一组过程;
4.对管程内部共享数据设置初始值的语句。
管程定义的是公用数据结构,功能主要是同步和初始化操作。它是OS中有一个资源管理模块,被动的被进程调用。 例:一个取名为monitor的管程。
3.3 条件变量
1.在任何时刻,最多只有一个进程在管程中执行,因此用管程很容易实现互斥,只要将需要互斥访问的资源,用数据结构来描述,并将该数据结构放入管程中便可。
2.但当一进程进入管程执行管程的某个过程时,如因某种原因而被阻塞,应立即退出该管程,否则会出现因阻挡其它进程进入管程,而它本身又无法退出管程,形成死锁。
3.为此,系统中引入了条件变量。每个条件变量与进程所等待的条件相联系,当定义一个条件变量时,系统就建立一个相应的等待队列。
条件变量的定义格式为:Var X:condition。
对条件变量只能执行以下两种操作:
① X.wait操作:正在调用管程的进程,因X条件需要被阻塞,则调用此操作将自己插入到X条件的等待队列中,并释放管程,直到X条件发生变化。
② X.signal操作:正在调用管程的进程,发现X条件发生了变化,则调用此操作唤醒与X条件相对应等待队列中的一个进程。
3.4 管程解决生产者-消费者问题
利用管程来解决生产者—消费者问题,首先为它们建立一个管程,命名为p_c。管程p_c中整型变量count,表示缓冲池中己存放的产品数目,条件变量notfull、notempty分别对应于缓冲池不全满、缓冲池不全空两个条件。
此外,管程p_c中有两个局部过程:
① 过程put:负责将产品投放到缓冲池中,当count≥n时,表示缓冲池已满,生产者进程需等待;
② 过程get:负责从缓冲池中取出产品,当count≤0时,表示缓冲池已空,消费者进程需等待。
管程p_c描述如下:
monitor p_c
{
int in=0, out=0,count=0;
item buffer[n];
condition notfull,notempty;
void put(item)
{
if(count>=n)
notfull.wait;
buffer[in]=nextp;
in=(in+1)%n;
count++;
if notempty.queue //如果条件变量notempty的队列非空
notempty.signal; //唤醒条件变量notempty等待队列上的一个进程
}
void get(item)
{
if(count<=0)
notempty.wait;
nextc=buffer[out];
out=(out+1)%n;
count--;
if notfull.queue //如果条件变量notfull的队列非空
notfull.signal; //唤醒条件变量notfull等待队列上的一个进程
}
}
相应的生产者和消费者进程可描述为:
void producer()
{ while(true)
{
produce an item in nextp;
p_c.put(item);
}
}
void consumer()
{ while(true)
{
p_c.get(item);
consume the item in nextc;
}
}
4.进程通信
进程间交换信息的过程叫进程通信(IPC)。进程通信分为低级通信、高级通信。低级通信即控制信息交换,传输数据量小。高级通信即进程间大批量数据交换。
4.1 高级通信分类
高级通信机制可分为三大类:
1.共享存储器系统
-
共享全局变量/数据结构:各进程通过改变全局数据的值,进行通信。
-
共享一个存储区:各进程通过对该存储区写/读操作,进行通信。
2.消息传递系统
进程间通过发送、接收格式化的消息(报文),进行通信。
-
直接通信:利用OS提供的send(接收者ID,消息)、receive(发送者ID,消息)进行通信。
-
间接通信:双方通过中间实体(信箱)进行通信。OS提供原语进行信箱的创建、撤消及消息的发送、接收。
3.管道通信
管道是一个共享文件,连接读进程和写进程。写进程以字符流形式将大量数据送入管道,读进程从管道中接收数据。需要:
-
读、写进程对管道操作的互斥;
-
读、写进程对数据操作的互斥;
-
读、写进程能够确定对方的存在。
4.2 消息传递系统
1.消息缓冲机制
**基本思想:**OS在内存中设置一组消息缓冲区。发送进程发放消息时,先在自己的进程空间设置一个发送区,填入要发送的报文;
之后,通过send原语向系统申请一个空闲缓冲区,将消息报文复制过去并将该消息插入到接收进程的消息队列中,然后通知接收进程。
最后,接收进程利用receive原语,从自己的消息队列中取出第一个消息缓冲区,将其复制到接收区后,释放该消息缓冲区。
(1)消息缓冲区
消息缓冲区由操作系统负责管理,每个消息缓冲区存放一个消息,消息缓冲区的数据结构:
Type messageBuffer=record
sender: integer; //发送消息的进程标识符
size:integer; //消息长度
text:string; //消息正文
next:pointer; //指向下一个消息缓冲区的指针
End
(2)进程PCB中有关数据项
Type PCB=record
…
mutex:semaphore; //消息队列互斥信号量,初值为1
sm:semaphore; //消息队列资源信号量,初值为0
mq: pointer; //消息队列队首指针
….
End
(3)发送原语
发送进程调用发送原语send(receiver, a),申请一个消息缓冲区,把以a为首地址的发送区中的消息,复制到该消息缓冲区,并将其挂接到接收进程的消息队列上。
Procdeure send(receiver, a)
Begin
getbuf(a.size,i); //根据消息长度,申请消息缓冲区i
i.sender:=a.sender; //将a复制到i
i.size:=a.size;
i.text:=a.text;
i.next:=0;
getid(reciver, j); //得到接收进程的PCB指针j
p(j.mutex); //互斥使用消息队列
insert(j.mq,i); //将消息i挂接到接收进程j的消息队列尾部
v(j.mutex);
v(j.sm); //进程j的消息数加1
End
(4)接收原语
接收进程调用接收原语receive(b),从消息队列Mq中把第一个消息缓冲区的数据,复制到以b为首的接收区内。
Procdeure receive(b)
Begin
j=internal name ; //得到本进程的ID
p(j.sm);
p(j.mutex);
remove(j.mq,i); //多j.mq队首取第一个消息i
p(j.mutex);
b.sender:=a.sender; //将i中的内容复制到b
b.size:=i.size;
b.text:=i.text;
releasebuf(i); //释放i
End
2.信箱机制:
1.在直接通信方式下,进行通信的每一方,都必须显式指明消息接收方或发送方。间接通信方式下,消息不是发送到接收方,而是发送到一个共享数据结构中。信箱机制是一种间接通信方式,信箱即是共享的数据结构,用来暂存通信进程的消息。
2.发送方把消息送到信箱,接收方从信箱中取走数据,每个信箱有唯一的标识。
3.在这种方式下,消息在信箱中可以安全地保存,只充许核准的目标用户进程随时访问信箱。
信箱可分为如下三类:
① 私用信箱:用户进程自己创建,用户进程可以读取消息,其它进程只能向信箱发送。
② 公用信箱:由OS创建,并提供给系统中所有的核准进程使用。核准进程可以向信箱发送或读取自己的消息。
③ 共享信箱:由用户进程创建,并同时指定能使用信箱的进程名称。创建者/使用者,都可从该信箱中读取消息。
3.用消息传递方式解决生产者-消费者问题
#define N 100 //空消息个数,也即缓冲区个数
void producer(void)
{ int item;
message m;
while(TRUE)
{ item=produce_item(); //生成一些数据放入缓冲区
receive(consumer,&m); //等待一条空消息到达
build_message(&m,item); //构造一条可供发送的消息
send(consumer,&m);
}
}
void consumer(void)
{ int item,i;
message m;
for(i=0;i<N;i++)
send(producer,&m); //发送N条空消息
while(TRUE)
{ receive(producer,&m);
item=extract_item(&m); //从消息中提取数据项
send(producer,&m); //向消费者发回一个空消息
consume_item(item); //使用数据项进行相关操作
}
}
5.Linux进程通信概述
1.Linux系统中的用户进程和系统内核之间、各个用户进程之间需要相互通信,以便协调彼此间的活动。
2.Linux系统支持多种进程间的通信方式,其中最常用的方式主要有管道、信号、消息队列、信号量、共享内存以及利用socket(套接字)的网络间的进程通信。
5.1 管道
1.管道(Pipe)及有名管道是最早的进程间通信机制之一。管道是单向的字节流,它实际上是在通信进程间开辟一个固定大小的内存缓冲区(一般为4kB),将发送进程的标准输出和接收进程的标准输入连接起来。
2.管道可用于具有亲缘关系的进程(如父子进程或兄弟进程)之间的通信。有名管道克服了管道没有名字的限制,提供一个路径名与管道相连,它除具有管道所具有的功能外,还允许无亲缘关系的进程间通信。
3.有名管道的读操作总是从开始处返回数据,写操作则把数据追加到末尾。
5.2 信号
信号(signal,称为软中断)是在软件层次上对中断机制的一种模拟。从原理上讲,一个进程收到一个信号与处理器收到一个中断请求是一样的。信号是异步的,一个进程不必通过任何操作来等待信号的到达,进程也不知道信号到底什么时候到达。
5.3信号量
1.进程使用信号量来协调它们之间的并发执行顺序。互斥执行时,进程在执行前首先申请信号量,如果信号量已经被使用,则表明程序的另一个实例正在运行。
2.程序可以等待信号量被释放、放弃并退出或者暂时继续其他工作,稍后再试信号量。
3.同步执行时也类似,进程执行到某条指令时,如需要其他进程在这条指令处与其通信,则该进程可通过对相关信号量实施操作,阻塞自己。
4.只有等到所需的信息到来后,阻塞进程才能被激活,继续执行。
5.Linux系统中,有关信号量的数据结构是:struct sem、struct semid_ds、struct sem_queue和struct sembuf等。
5.4共享内存
1.Linux实现内存共享的方式有两种,系统实现方式和用户实现方式。
2.当采用系统实现方式时,Linux利用shmid_ds数据结构来表示每个新创建的共享内存区域。shmid_ds数据结构描述了共享内存的大小、其占用的物理内存页、有多少进程同时使用共享内存以及如何将共享内存映射到进程的地址空间等信息。
3.每一个希望共享内存的进程必须通过系统调用将共享内存连接到自己的虚拟内存中。当某一个进程不再共享虚拟内存时,它通过系统调用将自己的虚拟地址区域从链表中移去,并更新进程页表。当最后一个进程释放了自己的虚拟地址空间后,系统才能释放所分配的物理页。
4.共享内存在一些情况下可以代替消息队列,而且共享内存的读/写比使用消息队列要快。