(三)互斥和同步

1.并发的原理

在单处理器系统的情况下,出现问题的原因是中断可能会在进程中的任何地方停止指令的执行;

在多处理器系统的情况下,不仅同样的条件可以引发问题,而且当两个进程同时执行且都试图访问同一个全局变量时,也会引发问题。

这两类问题的解决方案是相同的:控制对共享资源的访问

1.1进程的交互

感知程度关系潜在问题
互相不知道竞争关系互斥、死锁、饥饿
间接知道对方共享合作互斥、死锁、饥饿、数据一致
直接知道对方通信合作死锁、饥饿

1.1.1进程间资源竞争

  • 当并发进程使用同一资源时,他们之间就会发生冲突

竟争进程面临三个控制问题。

  1. 互斥:假设两个或更多的进程需要访问一个不可共享的资源,如打印机。

    在执行过程中,每个进程都给该IO设备发送数据和接收数据。我们把这类资源称为临界资源,使用临界资源的那部分程序称为程序临界区

一次只允许有一个程序在临界区中,这一点非常重要。

  1. 死锁:两个进程同时需要对方释放资源才能继续运行,而且完成功能前不会释放自己已占有的资源,这样两个资源就进入了无限的等待
  2. 饥饿:两个进程轮番抢占同一资源,即使没有死锁,其他进程也可能被无限地拒绝访问资源

1.1.2进程间共享合作

多个进程间可能共享一个变量、文件或数据库,进程必须保证使用修改数据不涉及其他进程;因此也可能出现之前的互斥、死锁和饥饿等问题,唯一的区别是,可以按照读写两种方式访问数据,并且写操作必须保持互斥

1.1.3进程间通信合作

当进程通过通信进行合作时,各个进程都与其他进程进行连接通信提供协调各种活动的方法。

典型情况下,通信可由各种类型的消息组成,发送消息和接收消息的原语由程序设计语言提供,或由操作系统的内核提供。

由于在传递消息的过程中进程间未共享任何对象,因而这类合作不需要互斥,但仍然存在死锁和饥饿问题。

例如,有两个进程都在等待来自对方的通信,这时发生死锁;或者如果P1和P2不断地交换信息,而P3等待与P1通信,由于P1一直是活动的,因此虽不存在死锁,但P3处于饥饿状态。

1.2互斥的要求

要提供互斥支持必须满足以下要求:

  1. 临界区一次只允许一个进程进入
  2. 非临界区的停止进程不能干涉其他进程
  3. 不能出现死锁和饥饿
  4. 临界区没有进程时,需要的进程可以立即进入
  5. 对相关进程执行速度和处理器数量没有要求和限制
  6. 进程再临界区的时间有限

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,允许另一个进程进入它的临界区。

优点

  1. 适用于单处理器或共享内存的多处理器上的任意数量的进程
  2. 简单且易于证明。
  3. 可用于支持多个临界区,每个临界区可以用它自己的变量定义

缺点

  1. 使用了忙等待:因此,当一个进程正在等待进入临界区时,它会继续消耗处理器时间。
  2. 可能饥饿:当一个进程离开一个临界区且有多个进程正在等待时,选择哪个等待进程是任意的,因此某些进程可能会被无限地拒绝进入。
  3. 可能死锁:考虑单处理器上的下列情况。进程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);若相应的信号仍未发送,则阻塞进程,直到发送完为止。

为达到预期效果,可把信号量视为一个值为整数的变量,整数值上定义了三个操作:

  1. 一个信号量可以初始化成非负数。
  2. P原语使s--,若s<=0,则阻塞执行
  3. 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 二元信号量

  1. 二元信号量可以初始化为0或1.
  2. P操作检查信号的值。若值为0,则进程被阻塞。若值为1,则将值改为0,并继续执行该进程。
  3. 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(先进先出)
  • 弱信号量:没有规定顺序

强信号量可以保证不会饥饿,使操作系统提供的典型信号量形式

quicker_137d71e5-f571-4b62-8f3c-4ce96f6c2a63.png

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操作必须作为原子原语实现。

  1. 可以使用任何一种软件方案,如 Dekker算法或 Peterson算法,这必然伴随着处理开销。

  2. 使用一种硬件支持实现互斥的方案。例如,使用swap指令的实现,这涉及某种形式的忙等待,但P/V操作都相对较短,因此所涉及的忙等待时间量非常小

  3. 对于单处理器系统,在P/V操作期间是可以禁用中断的,这些操作的执行时间相对很短。

4.管程

管程是一种编程语言结构,它在一个抽象数据类型中封装了变量、访问过程和初始化代码。管程的变量只能由管程自身的访间过程访向,每次只能有一个进程在其中执行(编译器实现),访过程即临界区。管程可以有一个等待进程队列

4.1 使用信号的管程

管程是由一个或多个过程、一个初始化序列局部数据组成的软件模块,其主要特点如下:

  1. 成员私有化:局部数据变量只能被管程的过程访问,任何外部过程都不能访问。
  2. 一个进程通过调用管程的一个过程进入管程。
  3. 在任何时候,只能有一个进程在管程中执行,调用管程的任何其他进程都被阻塞,以等待管程可用。

管程通过使用条件变量来支持同步,这些条件变量包含在管程中,并且只有在管程中才能被访问。有两个函数可以操作条件变量:

  • 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);
}

管程优点

  1. 与信号量相比较,使用管程,程序员只需要关注同步,而不用考虑互斥

  2. 管程所有的同步机制都被限制在内部,因此不但易于验证同步的正确性,而且易于检测出错误。

此外,若一个管程被正确地编写,则所有进程对受保护资源的访问都是正确的;而对于信号量,只有当所有访问资源的进程都被正确地编写时,资源访问才是正确的。

  • 使用管程的广播 当一个进程不知道有多少进程将被激活时,使用广播可以使在该条件上等待的进程都置于就绪态;当一个进程难以准确地判定将激活哪个进程时,也可使用广播。

5.消息传递

为实施互斥,进程间需要同步为实现合作,进程间需要交换信息。提供这些功能的一种方法是消息传递
两个消息传递基本原语:

  • send(destination, message)
  • receive(source, message)

5.1 同步

发送者和接收者都可阻塞或不阻塞。
无阻塞send是最自然的。例如,无阻塞send用于请求一个输出操作(如打印),它允许请求进程以消息的形式发出请求,然后继续。无阻塞send有一个潜在的危险:错误会导致进程重复产生消息

对大多数并发程序设计任务来说,阻塞 receive是最自然的。通常,请求一个消息的进程都需要这个期望的信息才能继续执行下去,但若消息丢失或者一个进程在发送预期的消息之前失败,则接收进程会无限期地阻塞。这个问题可以使用无阻塞 receive来解决。

5.2寻址

在send和receive原语中确定目标或源进程的方案分为两类:直接寻址和间接寻址

  • 直接寻址,send原语包含目标进程的标识号,而 receive原语有两种处理方式。

    1. 要求进程显式地指定源进程,因此该进程必须事先知道希望得到来自哪个进程的消息,这种方式对于处理并发进程间的合作非常有效。
    2. 当不可能指定所期望的源进程,例如打印机服务器进程将接受来自各个进程的打印请求,对这类应用使用隐式寻址更为有效。
  • 间接寻址。此时,消息不直接从发送者发送到接收者,而是发送到一个共享数据结构,该结构由临时保存消息的队列组成,这些队列通常称为信箱。因此,两个通信进程中,一个进程给合适的信箱发送消息,另一个进程从信箱中获取这些消息。

  • 一对一:允许在两个进程间建立专用的通信链接,隔离它们间的交互避免其他进程的错误干扰;
  • 多对一:对客户服务器间的交互非常有用,一个进程给许多其他进程提供服务,这时信箱常称为一个端口
  • 一对多:适用于一个发送者和多个接收者,它对于在一组进程间广播一条消息或某些信息的应用程序非常有用。
  • 多对多:可让多个服务进程对多个客户进程提供服务。

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设备的访问。进程间由于共享访问一个公共对象,如一块内存空间或一个文件,可能间接知道对方,这类交互中产生的重要问题是互斥和死锁

互斥指的是,对一组并发进程,一次只有一个进程能够访问给定的资源或执行给定的功能。互斥技术可用于解决诸如资源争用之类的冲突,也可以用于进程间的同步,使得它们能够合作。同步的例子是生产者/消费者模型,在该模型中,一个进程向缓冲区中放数据,另一个或更多的进程从缓冲区中取数据。

支持互斥的第二种方法要使用专用机器指令,这种方法能降低开销,但由于使用了忙等待,效率较低。

支持互斥的另一种方法是在操作系统中提供相应的功能,其中最常见的两种技术是信号量和消息机制。信号量用于在进程间发信号,能很容易地实施一个互斥协议。消息对实施互斥很有用,还为进程间的通信提供了一种有效的方法。

  • 2
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值