互斥和同步

前言

进程间通信(Inter Process Communication)主要有三个问题:

  • 进程如何把信息传递给另一个进程
  • 确保两个或更多的进程在关键活动中不会出现交叉,比如两个进程试图争夺同一资源
  • 正确的顺序,某个进程运行前必须等待另外一个进程的运行完、

注意后面两个问题和解决方法同样适用于线程

1、互斥

原子性(Atomic):连续的,在执行过程中不允许被中断

原语:完成某种特定功能的一段程序,具有原子性,

竞争条件:当两个或多个进程读写某些共享资源,如何对进程访问资源的顺序敏感,则称存在竞争条件

互斥:也就是排他性,在一个进程访问资源时,不允许其他进程再访问。操作系统中某些资源一次只允许一个进程使用,称临界资源或互斥资源或共享变量

临界区:导致竞态条件发生的程序段或代码片段,

临界区的使用原则:

  • 没有进程在临界区时,想进入临界区的进程可进入
  • 不允许两个进程同时处于其临界区中
  • 临界区外运行的进程不得阻塞其他进程进入临界区
  • 不得使进程无限期等待进入临界区

调度里面的优先级反转,一个低优先级的进程进入到了临界区,若有更高优先级的进程进入就绪状态,就会抢占低优先级进程的CPU使用权,在此时,更高优先级的进程也无法进入临界区,被阻塞,

2、互斥的实现方案

忙等待:进程在得到临界区访问权之前,持续测试而不做其他事情

2.1 屏蔽中断

一种硬件方案。在每个进程刚刚进入临界区后立即屏蔽所有中断,并在就要离开之前再打开中断。

它的优点是简单、高效,缺点则有:其一,代价高,限制CPU并发能力;其二,不适合多处理器,屏蔽中断的指令只能屏蔽一个CPU;其三,屏蔽中断适用于操作系统本身,并不适合用户程序

2.2 锁变量

一种软件解决方案,设想有一个共享变量,其初始值为0,当一个进程向进入其临界区,先测试锁的值,0表示临界区没有进程,1表示有。若为0,则进程进入临界区并设置锁为1,若为1,则进程等待直到锁的值为0。

如果用软件来实现,上述思想也就是一个if判断语句,但是显然是有问题,在一个进程程判断锁为0且未设置为1时,另外一进程被调度运行,那么临界区就会有两个进程,更好的做法是使用一个循环来判断,只有锁一直为0,才能够在临界区运行。用于忙等待的锁,称自旋锁(Spin lock)(Java中也有),比如:

while(TRUE){
    while(turn != 0)
    critical_region();
    turn = 1;
    noncritical_region();
}

要注意的时,在单CPU系统上,显然使用自旋锁不好,因为浪费了CPU的周期,其他进程无法占用CPU,但是在多CPU系统上,一个CPU测试,其他CPU还是可以运行其他进程的

2.3 Peterson算法

#define FALSE 0
#define TRUE 1
#define N 2 //这里假设有两个进程

int turn;
int interested[N];

void enter_region(int process){
    int other;

    other = 1 - process;
    interested[process]=TRUE;//想进入临界区的进程标记为true
    turn = process;//标记最后一次想进入临界区的进程号
    while(turn == process && interested[other] == TRUE)
        ;
}

//临界区

void leave_region(int process){
    interested[process] = FALSE;
}

考虑两个进程都想进入临界区,循环判断条件中的interested均为真,因为turn是标记最后一次想进入临界区的进程号,所有对于第一个想进入临界区的进程来说,循环执行0次,直接进入临界区,而对于第二个想进入临界区的进程,循环将一直执行下去,直到第一个进程执行了leave_region,

2.4 TSL指令

一种硬件方法,称测试加锁指令,它适用于多CPU系统,其中LOCK是一个内存字

为了确保读锁和写锁(两个操作一起)是原子性的,执行TSL指令将锁住内存总线,禁止其他CPU在该指令结束前访问内存

进程在进入临界区前先调用enter_region,这将导致忙等待,直到锁空闲为止(0表示锁空闲),随后它将获得空闲的锁并返回,当进程从临界区返回时将调用leave_region,这将设置锁为空闲状态

2.5 XCHG指令

思路和TSL差不多,它原子性地交换了两个位置的内容,然后在判断是否加锁,所有Intel x86 CPU在底层同步都是使用XCHG指令

3、睡眠与唤醒

由于在忙等待时CPU空转,这浪费了CPU时间,而且还会引起预想不到的后果——优先级反转问题,调度规则规定优先调度优先级较高的进程,设有两个进程H和L,H优先级较高,当L处于临界区中时,此时H变到就绪态,于是H开始忙等待,但由于H就绪时,L不会被调度,也就无法离开临界区,所有H就会永远忙等待下去。

考虑进程间通信的几个原语,他们在无法进入临界区时将阻塞,而不是忙等待,最简单是sleep和wakeup,sleep是一个将引起调用进程阻塞的系统调用,即被挂起;wakeup调用有一个参数,即要被唤醒的进程

3.1 生产者—消费者问题

作为使用原语的例子,考虑生产者—消费者问题(producer-consumer),又称有界缓冲问题。两个进程共享一个公共的固定大小的缓冲区,其中一个是生产者,将信息放入缓冲区;另一个是消费者,从缓冲区取出信息。

该问题的关键是当缓冲区已满,此时生产者就不能往里面放入一个新的数据,于是将生产者睡眠,待消费者从缓冲区中取出一个或多个数据时再唤醒它;当缓冲区为空时,消费者也就不能从其中取出数据,而是将消费者睡眠,直到生产者向其中放入了一个数据时将其唤醒。

#define N 100//缓冲区大小
int count = 0;//统计缓冲区中的数据项数目

void producer(void){
    int item;

    while(TRUE){//无限循环
        item = producer_item();//产生数据项
        if(count == N)//缓冲区满,使生产者睡眠
            sleep();
        insert_item(item);//未睡眠,放入数据到缓冲区
        count = count + 1;
        if(count == 1)//未产生数据前,count为0,所以消费者处于睡眠状态,需要唤醒
            wakeup(consumer);
        }
}

void consumer(void){
    int item;

    while(TURE){//无限循环
        if(count == 0)//缓冲区空,消费者睡眠
            sleep();
        item = remove_item();//未睡眠,取出数据
        count = count - 1;
        if(count = N - 1)//未消耗数据前,count为N,所以生产者处于睡眠状态,需要唤醒
            wakeup(producer);
        consumer_item(item);//打印数据
    }
}

这里的竞争条件是因为对count的访问未加以限制(生产者、消费者是进程或线程),有可能出现的情况:缓冲区为空,消费者读取count发现为0,此时调度程序决定暂停消费者运行生产者,生产者向缓冲区写入一个数据项,于是count变为1,它认为在产生数据前count为0(也就是producer中的if wakeup),于是生产者调用wakeup来唤醒消费者;但是此时消费者并未处于睡眠,所以wakeup信号丢失了java中也有),当消费者再次运行时,经过一次循环之后,count变为0,于是消费者进入睡眠;之后运行生产者,当缓冲区满了之后,生产者睡眠,两者进程(线程)都将永远睡眠下去。

4、信号量

为了解决丢失的信号,Dijkstar提出了一种方法,使用整形变量来累计唤醒次数,以供后面使用。信号量(semaphore)的取值可为0或者正值,0表示没有保存下来的唤醒操作,正值表示有一个或多个唤醒操作。它可用于解决竞争问题和同步问题

对信号量可以实施的操作:初始化、P(down,对应sleep)、V(up,对应wakeup)。其中P是荷兰语test,V是increment

互斥量:信号量的简化版,即互斥量(mutex),它没有信号量的计数能力,互斥量是处于两态之一的变量——解锁或加锁,只需要一个二进制位就可表示,实际中常使用整数来表示,0表示解锁,其他表示加锁。互斥量仅适用于管理共享资源或一小段代码,由于其简单高效,在实现用户空间线程库时非常有用。

信号量的定义:

struct semaphore{
    int count;
    queueType queue;
}

P操作

P(s){
    s.count --;
    if (s.count < 0){
    该进程状态置为睡眠状态;
    将该进程插入相应的等待队列s.queue末尾;
    重新调度其他进程;
    }
}

V操作

V(s){
    s.count ++;
    if (s.count < = 0){
    唤醒等待队列s.queue中的一个进程;
    改变其状态为就绪态,并将其插入就绪队列;
    }
}

毫无疑问,P、V操作必须是原语操作,否则还会有竞争条件,如何实现呢?用硬件来实现,对于单CPU系统,在测试信号量、更新信号量以及在需要使某个进程睡眠时,暂时屏蔽中断;对于多CPU系统,使用TSL或XCHG指令来确保同一时刻只有一个CPU在对信号量进行操作。上述方法只需要几条指令来使用硬件实现互斥,信号量的操作仅需要几个毫秒,而生产者-消费者可能会等待任意长时间

4.1 使用P、V操作解决进程间互斥问题

使用信号量的P、V操作解决进程间互斥问题的基本思路

  • 分析并发进程的关键活动,划定临界区
  • 设置信号量mutex,初值为1
  • 在进入临界区前实施P(mutex)
  • 在退出临界区后实施V(mutex)

举个栗子,有三个进程,他们都对同一资源进行操作:

  1. 假设P1先进入临界区,执行P操作,mutex为0了,
  2. 在P1处于临界区的时间内被中断,若P2和P3都想进入临界区,假定P2先调度,那么执行P操作,mutex为-1了,根据P操作的定义,无法进入临界区,随后阻塞——进入等待队列并让出CPU,然后调度P3进程,执行P操作,mutex为-2了,还是无法进入临界区,阻塞——进入等待队列并让出CPU
  3. 假定调度P1,出临界区后执行V操作,mutex为-1,所以会唤醒队列中的P2进程进入就绪队列,然后P1接着做别的事情,
  4. CPU再次调度P2,因为P操作之前已经执行过了,所以直接进入临界区,当P2从临界区出来,执行V操作,mutex就为0了,所以就会唤醒等待序列中的P3进程

4.2 使用信号量解决生产者消费者问题

#define N 100

semaphore mutex;
mutex.count = 1;

semaphore empty;
empty.count = N;

semaphore full;
full.count = 0;

void producer(void){
    int item;
    while(TRUE){
        item = produce_item();
        P(&empty);
        P(&mutex);
        insert_item(item);
        V(&mutex);
        V(&full);
    }
}

void consumer(void){
    int item;
    while(TURE){
        p(&full);
        P(&mutex);
        item = remove_item(item);
        V(&mutex);
        V(&empty);
        consume_item(item);
    }
}

上述代码里面信号量有两种用途——互斥、同步

  • 这里,缓冲区是临界区,信号量mutex是用来保护它的,信号量mutex是用来实现互斥的,在同一进程中都是对同一信号量采取P、V操作包围住临界区
  • 再来看信号量empty、full,先来看empty,当生产者要产生数据项时,它要判断缓冲区是不是满的,通过P(empty)来判断,empty初值是N,开始时生产者可以放N个数据,当缓冲区满了后,empty就为0了,第N+1次循环,一次P(empty)使得empty为-1,生产者阻塞,那么什么时候被唤醒呢?这就要看消费者了,消费者消耗一个数据项,执行V(&empty)使得加1,若执行P操作后empty大于0,那么CPU下次执行就绪队列里面的生产者进程时就会唤醒它,full信号量同理,在这里,empty、full信号量是用来实现同步的(重在控制顺序,但是其结果也实现了互斥),此时一个信号量的P、V操作是在不同的进程里

关于同步和互斥区别的详细讨论,请移步这里

4.3 使用信号量解决读写者问题

多个进程共享一个数据区,这些进程分两组,读者进程(只读数据区中的数据),写者进程(只往数据区中写数据)。显然写者进程是互斥的,因为多个写者会改变数据(顺序不同则结果不同),而如果同时有写者和读者,那么读者所读到数据是不确定的,所以写者进程在写的时候,数据区中不允许有其他写者和读者;而可以有多个读者读数据,因为不会改变数据,

void reader(void){
    while(TRUE){
        P(mutex);//计数器的更新也必须互斥
        rc = rc+1;
        if(rc == 1) P(w);//第一个读者才需要P操作
        V(mutex);

        读操作

        P(mutex);
        rc = rc-1;
        if(rc == 0) V(w);//最后一个读者V操作
        V(mutex);


    }
}
void writer(void){
    while(TRUE){
    P(w);//写操作是互斥的
    写操作
    V(w);
    }
}

在实际应用当中,若临界区的访问要么是读,要么是写,它不会又是读又是写,使用读写锁是最好的选择(读写锁,读写者问题的解决方案)。

5、管程

信号量机制的不足:程序编写困难,如果P、V操作的位置错误,会引起死锁问题。管程是一种高级同步机制,是在程序设计语言中引入的,管程又称监视器(Moniter),最初是一种想象的语言——类Pacsal,现在支持管程的语言有Java、C#、C++、Python、Ruby等。

管程的定义:是一个特殊的模块,有名字,由关于共享资源的数据结构及在其上操作的一组过程组成,比如(个人认为,非常像java中类的定义):

进程只能通过调用管程中的过程(方法)来间接地管理管程中的数据结构(资源的抽象)

管程的特性:

  • 互斥,管程中只能有一个活跃进程,这是由编译器来负责保证的(java是使用synchronized关键字来实现的),通常做法是使用互斥量或二元信号量
  • 同步,比如生产者——消费者,何时把进程阻塞、唤醒,管程中是通过设置条件变量以及等待/唤醒操作(比如java中的wait和notify)

可以让一个进程或线程在条件变量上等待(由于管程是互斥的,所有应先释放管程的使用权,类似让出CPU),也可通过发送信号将等待在条件变量上的进程或线程唤醒

应用管程时遇到的问题:可能在管程中同时出现多个进程/线程,比如进入管程的进程不满足条件而等待并唤醒其他进程(释放了互斥权),后面进入管程的进程执行唤醒操作时(比如P唤醒Q),那么此时将有两个活跃的进程。

解决方法:

  • P等待Q执行——Hoare管程
  • Q等待P继续执行——Mesa管程
  • 规定唤醒操作为管程中的最后一个可执行操作——Hansen,并发pascal

5.1 Hoare管程

Hoare,有一个入口等待队列以便管程外面的进程等待;在管程内,有条件变量,若进程等待并释放互斥权,则在该条件变量上等待,通过wait/signal操作,在管程内有紧急等待队列,等待的进程进入该队列中,它的优先级高于入口等待队列

条件变量——在管程内部说明和使用的一种特殊类型的变量,对于条件变量,可以执行wait和signal操作,比如

var c:conditions;

wait(c)
/*
若紧急等待队列非空,则唤醒第一个等待者;否则释放管的互斥权,执行次操作的进程进入c链末尾
*/

signal(c)
/*
若c链为空,则相当于空操作,执行此操作的进程继续执行;否则唤醒第一个等待者,执行此操作的进程进入紧急等待队列的末尾
*/

5.2 Mesa管程

Hoare管程的一个缺点——额外的进程切换,比如P唤醒Q,要从P切换到Q,上下文切换需要开销。为了解决这个问题,使用notify代替signal操作——使条件队列头的进程得到通知,在将来合适的时候且当CPU可用时恢复执行

由于收到通知时并未执行,所以当进程正真被调度时,条件不一定成立——比如不能保证其他进程进入管程,可以会导致丢失的信号这个问题,所以检查条件要使用while循环而不是if判断

相比Hoare管程,Mesa对条件变量至少多了一次额外的检测,但是不需要进程的切换,且对等待进程在notify之后何时运行没有任何限制,所以Mesa管程比Hoare管程要简单高效些

5.2.1 对notify的改进

  • 给每个条件原语关联一个监视计时器,不论是否通知,若超时,则被设置为就绪态。它可以预防饥饿——进程无法产生相关条件的信号,等待该条件的进程就会被无限期推迟执行
  • 改进notify为broadcast,将该条件上等待的所以进程都被释放进入就绪队列

参考资料:

  1. 现代操作系统
  2. 北大陈向群——操作系统原理
  • 3
    点赞
  • 9
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值