1.并发的原理
在单处理器系统的情况下,出现问题的原因是中断可能会在进程中的任何地方停止指令的执行;
在多处理器系统的情况下,不仅同样的条件可以引发问题,而且当两个进程同时执行且都试图访问同一个全局变量时,也会引发问题。
这两类问题的解决方案是相同的:控制对共享资源的访问
1.1进程的交互
感知程度 | 关系 | 潜在问题 |
---|---|---|
互相不知道 | 竞争关系 | 互斥、死锁、饥饿 |
间接知道对方 | 共享合作 | 互斥、死锁、饥饿、数据一致 |
直接知道对方 | 通信合作 | 死锁、饥饿 |
1.1.1进程间资源竞争
- 当并发进程使用同一资源时,他们之间就会发生冲突。
竟争进程面临三个控制问题。
-
互斥:假设两个或更多的进程需要访问一个不可共享的资源,如打印机。
在执行过程中,每个进程都给该IO设备发送数据和接收数据。我们把这类资源称为临界资源,使用临界资源的那部分程序称为程序的临界区。
一次只允许有一个程序在临界区中,这一点非常重要。
- 死锁:两个进程同时需要对方释放资源才能继续运行,而且完成功能前不会释放自己已占有的资源,这样两个资源就进入了无限的等待
- 饥饿:两个进程轮番抢占同一资源,即使没有死锁,其他进程也可能被无限地拒绝访问资源
1.1.2进程间共享合作
多个进程间可能共享一个变量、文件或数据库,进程必须保证使用修改数据不涉及其他进程;因此也可能出现之前的互斥、死锁和饥饿等问题,唯一的区别是,可以按照读写两种方式访问数据,并且写操作必须保持互斥
1.1.3进程间通信合作
当进程通过通信进行合作时,各个进程都与其他进程进行连接。通信提供协调各种活动的方法。
典型情况下,通信可由各种类型的消息组成,发送消息和接收消息的原语由程序设计语言提供,或由操作系统的内核提供。
由于在传递消息的过程中进程间未共享任何对象,因而这类合作不需要互斥,但仍然存在死锁和饥饿问题。
例如,有两个进程都在等待来自对方的通信,这时发生死锁;或者如果P1和P2不断地交换信息,而P3等待与P1通信,由于P1一直是活动的,因此虽不存在死锁,但P3处于饥饿状态。
1.2互斥的要求
要提供互斥支持必须满足以下要求:
- 临界区一次只允许一个进程进入
- 非临界区的停止进程不能干涉其他进程
- 不能出现死锁和饥饿
- 临界区没有进程时,需要的进程可以立即进入
- 对相关进程执行速度和处理器数量没有要求和限制
- 进程再临界区的时间有限
2.硬件的支持
2.1禁用中断
保证互斥,只需保证一个进程不被中断即可,这种能力可通过系统内核为启用和禁用中断定义的原语来提供。
这种方法的代价非常高。由于处理器被限制得只能交替执行程序,因此执行的效率会明显降低。
另一个问题是,当有多个处理器时,通常就可能有一个以上的进程同时执行,在这种情况下,禁用中断并不能保证互斥。
2.2专用机器指令
/*program mutualexclusion*/
const int n =/*进程个数*/;
int bolt;
void P(int i){
while (true){
while (compare_and_swap (bolt, 0, 1)== 1)
/*不做任何事 忙等待*/;
/*临界区*/;
bolt = 0;
/*其余部分*/;
}
}
void main(){
bolt = 0;
parbegin ((1), P(2),...,P(n));//并发开始
}
共享变量bolt被初始化为0.唯一可以进入临界区的进程是发现bolt等于0的那个进程。所有试图进入临界区的其他进程进入忙等待模式。
术语忙等待或自旋等待指的是这样一种技术:进程在得到临界区访问权之前,它只能继续执行测试变量的指令来得到访问权,除此之外不能做任何其他事情。
一个进程离开临界区时,它把bolt重置为0,此时只允许一个等待进程进入临界区。进程的选择取决于哪个进程正好执行紧接着的 compare&swap
指令。
/*program mutualexclusion*/
const int n =/*进程个数*/;
int bolt;
void P(int i){
while (true){
int keyi=1:
do exchange (keyi, bolt)
while(keyi=0);
/*临界区*/;
bolt = 0;
/*其余部分*/;
}
}
void main(){
bolt = 0;
parbegin ((1), P(2),...,P(n));//并发开始
}
上述代码显示了基于exchange
指令的互斥协议:共享变量bolt初始化为0,每个进程都使用一个局部变量key且初始化为1.唯一可以进入临界区的进程是发现bolt等于0的那个进程。它通过把bolt置为1来避免其他进程进入临界区。一个进程离开临界区时,它把bolt重置为0,允许另一个进程进入它的临界区。
优点
- 适用于单处理器或共享内存的多处理器上的任意数量的进程。
- 简单且易于证明。
- 可用于支持多个临界区,每个临界区可以用它自己的变量定义
缺点
- 使用了忙等待:因此,当一个进程正在等待进入临界区时,它会继续消耗处理器时间。
- 可能饥饿:当一个进程离开一个临界区且有多个进程正在等待时,选择哪个等待进程是任意的,因此某些进程可能会被无限地拒绝进入。
- 可能死锁:考虑单处理器上的下列情况。进程P1执行专用指令并进入临界区,然后P1被中断并把处理器让给具有更高优先级的P2。若P2试图使用同一资源,由于互斥机制,它将被拒绝访问。因此,它会进入忙等待循环。但是,由于P1比P2的优先级低,因此它将永远不会被调度执行。
3.信号量
3.1常用并发机制
-
*信号量:用于进程间传递信号的一个整数值。在信号量上只可进行三种操作,即初始化、递减和增加,这三种操作都是原子操作。递减操作用于阻塞一个进程,递增操作用于解除一个进程的阻塞。信号量也称为计数信号量或一般
-
*管程:一种编程语言结构,它在一个抽象数据类型中封装了变量、访问过程和初始化代码。管程的变量只能由管程自身的访间过程访向,每次只能有一个进程在其中执行(编译器实现),访过程即临界区。管程可以有一个等待进程队列
-
*信箱/消息:两个进程交换信息的一种方法,也可用于同步
-
二元信号量:只取0值和1值的信号量互斥量
-
互斥量:类似于二元信号量。关键区别在于为其加锁(设定值为0)的进程和为其解锁(设定值为1)的进程必须为同一个进程
-
条件变量:一种数据类型,用于阻塞进程或线程,直到特定的条件为真
-
事件标志:用做同步机制的一个内存字,应用程序代码可为标志中的每个位关联不同的事件。通过测试相关的一个或多个位,线程可以等待一个事件或多个事件。在全部所需位都被设定(AND)或至少一个位被设定(OR)之前,线程会一直被阻塞
-
自旋锁:一种互斥机制,进程在一个无条件循环中执行,等待锁变量的值可用
3.2信号量工作原理
基本原理如下
两个或多个进程可以通过简单的信号进行合作,可以强迫一个进程在某个位置停止,直到它接收到一个特定的信号。任何复杂的合作需求都可通过适当的信号结构得到满足。
要通过信号量s传送信号,进程须执行V原语:seminal(s)
;
要通过信号量s接收信号,进程须执行P原语: semwait(s)
;若相应的信号仍未发送,则阻塞进程,直到发送完为止。
为达到预期效果,可把信号量视为一个值为整数的变量,整数值上定义了三个操作:
- 一个信号量可以初始化成非负数。
P原语
使s--
,若s<=0
,则阻塞执行V原语
使s++
,若s>0
,则解除阻塞
除了这三个操作外,没有任何其他方法可以检查或操作信号。
- 在执行
P原语
前,无法提前知道该信号量是否会被阻塞。 - 当执行
V原语
后,无法知道哪个进程会立即继续运行。 - 当执行
V原语
后,被解除阻塞的进程数要么0,要么1
struct semaphore{
int count;
queueType queue;
}
void semWait(semphore s){
s.count--:
if (s count< 0)
/*把当前进程插入队列*/;
/*阻塞当前进程*/;
}
void semsianal(semaphore s){
s.count++
if (s.count<=0){
/*把进程P从队列中移除*/;
/*把进程P插入就绪队列*/;
}
}
3.3 二元信号量
- 二元信号量可以初始化为0或1.
P操作
检查信号的值。若值为0,则进程被阻塞。若值为1,则将值改为0,并继续执行该进程。V操作
检查是否有任何进程在该信号上受阻。若有进程受阻,则受阻的进程会被唤醒;若没有进程受阻,则值设置为1.
struct binary_semaphore{
enum (zero, one) value;
queueType queue;
}
void semwaitb(binary_semaphore s){
if (s.value == one)
s.value = zero;
else{
/*把当前进程插入队列*/;
/*阻塞当前进程*/;
}
}
void semSignalb(semaphore s){
if (s.queue is empty())
s.value=one;
else{
/*把进程P从等待队列中移除*/;
/*把进程P插入就绪队列*/;
}
}
理论上,二元信号量更易于实现,且可以证明它和普通信号具有同样的表达能力,为了区分这两种信号,非二元信号量也常称为计数信号量或一般信号量
与二元信号量相关的一个概念是互斥锁。互斥是一个编程标志位,用来获取和释放一个对象。(见3.1)
强信号量和弱信号量
队列用来保存正在等待的进程,出列顺序是怎样的?
- 强信号量:FIFO(先进先出)
- 弱信号量:没有规定顺序
强信号量可以保证不会饥饿,使操作系统提供的典型信号量形式
3.3 互斥
下方给出了一种使用信号量s解决互问题的方法:每个进程进入临界区前执行semWait(s)
,若s<0
,则进程被阻塞;若s==1
,进程立即进入临界区;由于s<0
,因而其他任何进程都不能进入临界区。
/*program mutualexclusion*/
const int n =/*进程个数*/;
semaphore s=1;
void P(int i){
while (true){
int keyi=1:
do exchange (keyi, bolt)
while(keyi=0);
/*临界区*/;
bolt = 0;
/*其余部分*/;
}
}
void main(){
bolt = 0;
parbegin ((1), P(2),...,P(n));//并发开始
}
s≥0时,s是可执行而不被阻塞的进程数;s<0时,s的大小是阻塞在队列中的进程数。
3.4生产者/消费者问题
有一个或多个生产者生产数据,并放置在缓冲区中,有一个消费者从缓冲区中取数据,每次取一项;系统保证避免对缓冲区的重复操作,即在任何时候只有一个主体(生产者或消费者)可访问缓冲区。当缓存已满时,生产者不会添加数据;当缓存为空时,消费者不会移走数据。我们将讨论该问题的多种解决方案,以证明信号量的能力和缺陷。
缓冲区无限
/* program producerconsumer */
semaphore n =0, s=1;
//s表示在使用缓冲区,互斥作用
//n表示缓冲区不为空,同步作用
void producer(){
while (true){
produce();
semWait(s);
append();
semSignal(s);
semSignal(n);
}
}
void consumer(){
while (true){
semWait(n);
semWait(s);
take ();
semSignal(s);
consume();
}
}
void main(){
parbegin (producer, consumer);
}
这是使用一般信号量的做法,现在假设消费者的P(n),P(s)操作颠倒顺序,这时会先占用临界区,但是有可能因为缓冲区为空而发生死锁。
- 所以同步信号量一定在互斥信号量外,才能避免死锁
缓冲区有限
对于缓冲区有限的问题,加入一个e信号量表示缓冲区没满,用于同步作用,同样的,e也应该在s之外,否则会死锁
/* program producerconsumer */
semaphore n =0, s=1,e=sizeofbuffer;
//s表示在使用缓冲区,互斥作用
//n表示缓冲区不为空,同步作用
void producer(){
while (true){
produce();
semWait(e);
semWait(s);
append();
semSignal(s);
semSignal(n);
}
}
void consumer(){
while (true){
semWait(n);
semWait(s);
take ();
semSignal(s);
semSignal(e);
consume();
}
}
void main(){
parbegin (producer, consumer);
}
3.5信号量的实现
如前所述,P/V操作必须作为原子原语实现。
-
可以使用任何一种软件方案,如 Dekker算法或 Peterson算法,这必然伴随着处理开销。
-
使用一种硬件支持实现互斥的方案。例如,使用
swap
指令的实现,这涉及某种形式的忙等待
,但P/V操作都相对较短,因此所涉及的忙等待时间量非常小 -
对于单处理器系统,在P/V操作期间是可以禁用中断的,这些操作的执行时间相对很短。
4.管程
管程是一种编程语言结构,它在一个抽象数据类型中封装了变量、访问过程和初始化代码。管程的变量只能由管程自身的访间过程访向,每次只能有一个进程在其中执行(编译器实现),访过程即临界区。管程可以有一个等待进程队列
4.1 使用信号的管程
管程是由一个或多个过程、一个初始化序列和局部数据组成的软件模块,其主要特点如下:
- 成员私有化:局部数据变量只能被管程的过程访问,任何外部过程都不能访问。
- 一个进程通过调用管程的一个过程进入管程。
- 在任何时候,只能有一个进程在管程中执行,调用管程的任何其他进程都被阻塞,以等待管程可用。
管程通过使用条件变量来支持同步,这些条件变量包含在管程中,并且只有在管程中才能被访问。有两个函数可以操作条件变量:
-
cwait(c)
:调用进程的执行在条件c上阻塞,管程现在可被另一个进程使用。 -
csignal(c)
:恢复执行在 cwait之后因某些条件而被阻塞的进程。若有多个这样的进程,选择其中一个若没有这样的进程,什么也不做。
/* program producerconsumer*/
monitor boundedbuffer;
char buffer [N];// 分配N个数据项空间
int nextin, nextout;//缓冲区指针
int count;// 缓冲区中数据项的个数
cond notfull, notempty;/*为同步设置的条件变量*/
void append (char x){
if(countn) cwait(notfull) //缓冲区满,防止溢出
buffer[nextin] =x;
nextin =(nextin +1)% N;
count++; /*缓冲区中的数据项个数增1*/
csignal (nonempty);//释放任何一个等待的进程
}
void take (char x){
if(count=o) cwait(notempty);//缓冲区空,防止下溢
x=buffer[nextout];
nextout=(nextout +1)%N;
count--; /*缓冲区中数据项个数减1*/
csignal (notful1); /*释放任何一个等待的进程*/
}
/*管程体*/
{
nextin 0;nextout =0; count =0;/*缓冲区初始化为空*/
}
void producer(){
char x;
while (true){
produce (x);
append(x);
}
}
void consumer(){
char x;
while (true){
take(x);
consume(x);
}
}
void main(){
parbegin (producer, consumer);
}
管程优点
-
与信号量相比较,使用管程,程序员只需要关注同步,而不用考虑互斥。
-
管程所有的同步机制都被限制在内部,因此不但易于验证同步的正确性,而且易于检测出错误。
此外,若一个管程被正确地编写,则所有进程对受保护资源的访问都是正确的;而对于信号量,只有当所有访问资源的进程都被正确地编写时,资源访问才是正确的。
使用管程的广播当一个进程不知道有多少进程将被激活时,使用广播可以使在该条件上等待的进程都置于就绪态;当一个进程难以准确地判定将激活哪个进程时,也可使用广播。
5.消息传递
为实施互斥,进程间需要同步为实现合作,进程间需要交换信息。提供这些功能的一种方法是消息传递。
两个消息传递基本原语:
send(destination, message)
receive(source, message)
5.1 同步
发送者和接收者都可阻塞或不阻塞。
无阻塞send是最自然的。例如,无阻塞send用于请求一个输出操作(如打印),它允许请求进程以消息的形式发出请求,然后继续。无阻塞send有一个潜在的危险:错误会导致进程重复产生消息。
对大多数并发程序设计任务来说,阻塞 receive是最自然的。通常,请求一个消息的进程都需要这个期望的信息才能继续执行下去,但若消息丢失或者一个进程在发送预期的消息之前失败,则接收进程会无限期地阻塞。这个问题可以使用无阻塞 receive来解决。
5.2寻址
在send和receive原语中确定目标或源进程的方案分为两类:直接寻址和间接寻址。
-
直接寻址,send原语包含目标进程的标识号,而 receive原语有两种处理方式。
- 要求进程显式地指定源进程,因此该进程必须事先知道希望得到来自哪个进程的消息,这种方式对于处理并发进程间的合作非常有效。
- 当不可能指定所期望的源进程,例如打印机服务器进程将接受来自各个进程的打印请求,对这类应用使用隐式寻址更为有效。
-
间接寻址。此时,消息不直接从发送者发送到接收者,而是发送到一个共享数据结构,该结构由临时保存消息的队列组成,这些队列通常称为信箱。因此,两个通信进程中,一个进程给合适的信箱发送消息,另一个进程从信箱中获取这些消息。
- 一对一:允许在两个进程间建立专用的通信链接,隔离它们间的交互避免其他进程的错误干扰;
- 多对一:对客户服务器间的交互非常有用,一个进程给许多其他进程提供服务,这时信箱常称为一个端口
- 一对多:适用于一个发送者和多个接收者,它对于在一组进程间广播一条消息或某些信息的应用程序非常有用。
- 多对多:可让多个服务进程对多个客户进程提供服务。
5.3消息格式
给出了操作系统支持的变长消息的典型格式。该消息分为两部分:包含相关信息的消息头和包含实际内容的消息体。消息头包含消息源和目标的标识符、长度域及判定各种消息类型的类型域,还可能含有一些额外的控制信息。
5.4排队原则
先进先出或允许指代消息的优先级
5.5互斥实现
//program mutualexclusion
const int=/*进程数*/
void consumer(){
message cmsg;
while (true){
receive(mayconsume, cmsg);
consume (cmsg);
send (mayproduce, null);
//null无实际意义,只是告诉生产者,消费类一个,缓冲区没满,可以继续生产
}
}
void producer(){
message pmsg;
while (true){
receive(mayproduce, pmsg);
pmsg= produce();
send (mayconsume, pmsg);
}
}
void main(){
create_mailbox (mayproduce);
create_mailbox (mayconsume);
for (int i=1; i<=capacity;i++) send(mayproduce, nul1);
parbegin (producer, consumer);
}
6.读者/写者问题
写进程之间不互斥,读进程和所有进程互斥
6.1读者优先
/* program readersandwriters.*/
int readcount;
semaphore x=1,wsem=1;
void reader(){
while (true){
//互斥保护,防止其他读进程捣乱
semwait(x);
readcount++
if(readcount==1)//有>=1个读进程,就不需等待
semWait(wsem);
semsignal (x):
READUNIT();
//互斥保护,防止其他读进程捣乱
semwait(x);
readcount--;
if(readcount ==0)//如果我是最后一个读进程,就要释放资源,让读进程可以进入
semWait(wsem);
semsignal (x);
}
}
void writer(){
while (true){
//互斥保护,防止其他写进程捣乱,并等待读进程释放
semWait(wsem);
WRITEUNIT ()
semsignal (wsem);
}
}
void main (){
readcount = 0;
parbegin(reader, writer);
}
6.2写者优先
/* program readersandwriters*/
int readcount, writecount;
semaphore x=1,y=1,z=1, wsem=1,rsem=1;
void reader(){
while (true){
//连续使用z和rsem两个信号,是防止读无限排队,在释放rsem时,写进程可以进行进入
semWait (z);
semWait (rsem);
//和读者优先一样,x时读的计数互斥防止其他读捣乱
semWait (x);
readcount++;
if (readcount==1)semWait (wsem);
semsignal(x);
semSignal (rsem);
semsignal (z);
READUNIT();
semWait (x);
readcount--;
if (readcount==0) semsignal(wsem);
semsignal (x);
}
}
void writer(){
while (true){
//由于写进程的wsem外没有其他信号量,所以可能无线排队,造成读进程饥饿的现象
//y是写计数的互斥,防止其他写捣乱
semWait (y);
writecount++;
if (writecount==1)semWait (rsem);//第2个写程序就不需要等待了
semsignal (y);
//写主程序互斥
semwait (wsem);
WRITEUNIT();
semsignal (wsem);
//同样减的时候也要互斥
semWait(y);
writecount--;
if (writecount==0) semsignal (rsem); //如果我是最后一个写程序,我就要释放rsem,好让都程序可以读
semSignal (y)
}
}
void main(){
readcount=writecount =0;
parbegin (reader, writer)
}
6.3信箱实现
void reader (int i){
message rmsg;
while (true){
rmsg = i;
send (readrequest, rmsg); //发送读请求
receive (mbox[i], rmsg);
READUNIT ();
rmsg = i;
send(finished,rmsg);
}
}
void writer(int j){
message rmsg;
while(true){
rmsg =j:
send (writerequest,rmsg);
receive(mbox,rmsg);
WRITEUNIT()
rmsg = j;
send (finished, rmsg);
}
}
//count 初始化给了100,代表可用的缓冲区为100
void controller(){
while (true){
if (count >0){//大于零时正常读
if (!empty(finished)){ //有读完成的,收回资源,可用count+1
receive (finished, msg);
count++:
}
else if (!empty(writerequest)){//如果有写请求,那么count直接变为负数,读请求会被阻塞
receive (writerequest,msg);
writer id = msa.id;
count=count - 100;
}
else if (!empty(readrequest)){//有一个读请求,缓冲区资源-1
receive (readrequest, msg);
count--;
send (msg.id, "ok");
}
}
if(count==0){//给写进程一个信号,可以开始了,并等待写进程给finished
send (writer id, "ok");
receive (finished, msg);
count =100;
}
while (count<0){//小于0,就先把剩下的都读完
receive (finished, msg);
count++;
}
}
}
总结
并发进程可按多种方式进行交互。互相之间不知道对方的进程可能需要竞争使用资源,如处理器时间或对IO设备的访问。进程间由于共享访问一个公共对象,如一块内存空间或一个文件,可能间接知道对方,这类交互中产生的重要问题是互斥和死锁。
互斥指的是,对一组并发进程,一次只有一个进程能够访问给定的资源或执行给定的功能。互斥技术可用于解决诸如资源争用之类的冲突,也可以用于进程间的同步,使得它们能够合作。同步的例子是生产者/消费者模型,在该模型中,一个进程向缓冲区中放数据,另一个或更多的进程从缓冲区中取数据。
支持互斥的第二种方法要使用专用机器指令,这种方法能降低开销,但由于使用了忙等待,效率较低。
支持互斥的另一种方法是在操作系统中提供相应的功能,其中最常见的两种技术是信号量和消息机制。信号量用于在进程间发信号,能很容易地实施一个互斥协议。消息对实施互斥很有用,还为进程间的通信提供了一种有效的方法。