操作系统概念_第六章_进程同步

概述

并发执行的程序会出现很多问题,一个简单的例子是生产者-消费者问题。

生产者生产资源(执行count++)的同时消费者消耗(count--),count的初值是5,理想结果是count=5,但如果同时执行两条语句,对于生产者和消费者,他们的count都是5,那么最后根据恢复的情况不同(生产者后执行完或消费者后执行完),最后会导致count还有4、6两种情况。

这是并发带来的问题,即多个进程并发访问和操作同一数据执行结果与访问发生的特定顺序有关,称为竞争条件(race condition),为了避免这一个问题,需要确保一段时间只有一个进程能操作变量count

需要避免这些问题,因此需要进程同步(Process synchronization)协调(coordination)


临界区问题

临界区(critical section)
是指多个进程中能够改变共同变量,更新同一个表,访问同一个文件的代码区段,当其中一个进程进入临界区的时候,没有任何其他进程能够进入临界区。

临界区问题
是设计一个协议,让进程能够协作。每个进程必须通过请求进入临界区,实现这一请求的代码称为进入区(entry section),临界区之后会有退出区(exit section),其他代码称为剩余区(remainder section),其通用结构如下。
这里写图片描述

临界区问题的答案需求

  • 互斥(mutual exclusion)
  • 前进(progress),如果没有进程在临界区,且有进程需要进入临界区,那么只有那些不在剩余区内执行的进程可以参与选择,并且这种选择不能无限推迟。
  • 有限等待(bounded waiting),从一个进程做出进入临界区的请求,直到该请求允许为止,其他程序允许进入其临界区的次数有上限。
    • 假定每个进程的执行速度不为0
    • 无法对n个进程的相对速度做任何假设。

在操作系统的临界区问题中,通过抢占内核非抢占内核两种方式来处理,很显然非抢占内核不会导致竞争条件,而对于抢占内核,则需要精心设计以避免竞争条件


Peterson算法

该算法是一个经典的基于软件的临界区问题的解答,但只适用于两个进程在临界区和剩余区间交替执行。
Peterson算法在两个进程间共享两个数据,turn表示哪个进程可以进入临界区,flag表示哪个进程想进入临界区。

扫描二维码关注公众号,回复: 173825 查看本文章
int turn;
boolean flag[2];

在该算法中的两个进程,假设为pi和pj,当使用pi时候,用pj来表示另一个进程,即j=1-i
该算法在进程i中的结构如下:

do{
    flag[i] = TRUE;
    turn = j;
    while(flag[j] && turn == j);
    //临界区
    flag[i] = FALSE;
    //剩余区
}while(TRUE);

这个算法其实是两个进程之间互相“谦让”的:如果对方想进,并且turn是对方的,那么自己就进入等待区。

如果两个进程同时想进入临界区,那么会同时改变turn的值,但turn最后只会保持为一个值,也就是竞争是平等的,但如果争不过,那就不争了

通过一些论证可以说明Peterson算法的解答是正确的(也即满足了互斥成立,前进要求满足,有限等待要求满足)


硬件同步

Peterson算法是基于软件的解答,而硬件也可以有一些机制来解决临界区问题。

其中,单处理器和多处理器的情况不太一样:

  • 对于单处理器,临界区问题的解决很简单:只需要在修改共享变量时禁止中断出现即可(也就是要求非强占),这样能确保当前指令序列的执行不会中断。
  • 对于多处理器,问题要复杂的多,禁止中断的操作要传递到所有处理器上会很费时,从而降低系统效率。

因此,现代计算机系统提供了特殊的硬件指令来相对简单的解决临界区问题,现在将这种指令的概念抽象成代码,可以描述成下文的形式:

//TestAndSet()指令的定义
boolean TestAndSet(boolean *target){
    boolean rv = * target;
    *target = TRUE;
    return rv;
}
//使用TestAndSet的互斥实现
do{
    while(TestAndSetLock(&lock));
    //临界区
    lock = FALSE//剩余区
}while(TRUE);
//Swap() 指令的定义
void Swap(boolean *a,boolean *b){
    boolean temp = *a;
    *a = *b;
    *b = temp;
}
//使用Swap()指令的互斥实现
do{
    key = TRUE;
    while(key == TRUE)
        swap(&lock,&key);
    //临界区
    lock = FALSE;
    //剩余区
}while(TRUE);

在这里,课本提到,上述的两个方法解决了互斥,但没有解决有限等待的问题,我一直没有相同,在查阅其他的文章之后有人谈到他的理解,是在两个进程的时候解决了有限等待问题,但多个进程的时候没有办法解决有限等待问题
欢迎有其他想法的人评论区留言讨论一下。

另外还有一个基于TestAndSet()的算法,这个算法满足了临界区问题的三个要求。

//操作系统概念第七翻译版-P172页
//共有变量(默认均为FALSE)
boolean waiting[n];
boolean lock;
//算法实现
do{
    waiting[i]  = TRUE;
    key = TRUE;
    while(waiting[i] && key)
        key = TestAndSet(&lock);
    waiting[i] = FALSE;
    //临界区
    j=(j+1) % n;
    while((j!=i) && !waiting[j] )
        j = (j+1)%n;
    if(j == i)
        lock = FALSE;
    else
        waiting[j] = FALSE;
    //剩余区
}while(TRUE);

经典同步问题

一些问题经典到几乎所有的同步方案都会用这些问题来测试,因此在介绍同步方案之前先提前介绍一下这些问题

生产者-消费者问题

也叫有限缓冲问题,在本文开头简单已经介绍过这个问题。

读者-写者问题

一个数据库可以被多个并发进程共享,其中有的是读者(读取数据库),有的是写者(修改数据库),只有读者显然不会产生问题,但一个写者和其他线程同时访问共享对象就可能出问题。

为了确保不会产生这样的混乱,要求写者对共享数据库有排他的访问。这一问题称为读者-写者问题。

这个问题根据读者写者优先度不同有两个变种:

  • 第一读者-写者问题要求没有读者需要等待除非有一个写者已获得允许使用数据库。
  • 第二读者-写者问题要求如果一个写者就绪,那么就不会有新读者获得允许使用数据库。

第一读者-写者问题可能会导致写者饥饿;第二读者-写者问题可能会导致读者饥饿。也因此提出了其他没有饥饿问题的变种读者-写者问题,这里不做讨论。

哲学家进餐问题

假设有五位哲学家围坐在一张圆形餐桌旁,他们只做两件事:吃饭,或者思考。吃东西的时候,他们就停止思考,思考的时候也停止吃东西。餐桌中间有饭,每两个哲学家之间有一只筷子。他们遵循以下规则:

  • 哲学家要吃饭只能将自己左右手边的筷子都拿起来才能吃饭
  • 一个哲学家不能抢别的哲学家已经拿起来的筷子
  • 在吃完后,哲学家会放下筷子

这里写图片描述

一般解决哲学家就餐问题有以下方法:

  • 最多只允许4个哲学家同时坐在桌子上(?还是用餐?)
  • 只有两个筷子都可用时候才允许这个哲学家拿起它们
  • 非对称解决方法:奇数序哲学家先拿左侧筷子再拿右侧筷子,偶数哲学家先拿右侧筷子再拿左侧筷子。

之后,就是一些同步方案。

可以理解为不同的同步方案是方法的不同实现


信号量

基于硬件的解决方案(硬件同步一节提到的TestAndSet()Swap())对于应用程序员来说过于复杂,为了解决临界区问题,可以采用叫做信号量(semaphore)同步工具。

定义整数变量S和方法wait()signal(),并规定只有这两个方法能访问S,这两个方法定义如下:

//wait()的定义
wait(S){
    while(S<0);//不执行任何操作的无限循环
    S--;
}

//signal的定义
signal(S){
    S++;
}

信号量的使用

信号量一般有两种,一种是没有值域的计数信号量(整型),一种是二进制信号量(只有0和1),操作系统能够区分这两种信号量。其中二进制信号量也叫互斥锁,因为他们可以提供互斥。

信号量的用途有多种,比如解决互斥问题解决资源申请问题解决同步问题

解决互斥问题

解决互斥问题用到的是二进制信号量,实现如下:

do{
    wait(mutex);
    //临界区
    signal(mutex);
    //剩余区
}while(TRUE);

解决资源申请问题

通过将一个信号量初始化为一种可用资源的数量,在申请资源前,调用wait,在释放资源后,调用signal,如果资源数目不够,则调用wait时,会被阻塞,直到有资源为止

解决同步问题

通过如下形式的代码,可以控制两个进程的代码执行的先后顺序(下图只有在进程a执行完S1后,进程b才能执行S2):

//进程a
do something(S1);
signal(synch);
//进程b
wait(synch);
do something(S2);

上述所使用的信号量及相关方法有一个主要缺点:忙等待(busy waiting),这种形式在实际多道程序的设计中是在浪费CPU时钟(CPU cycles),因为这本可以被其他的进程有效利用。
这种类型的信号量也叫自旋锁(spinlock),它的缺点很明显(忙等待),但也有其优点:自旋过程中不进行上下文切换,而上下文切换可能要花费相对较多的时间。
因此,自旋锁比较适合锁的占用时间比较短的情况。

信号量的实现

之前提到的信号量有忙等待的缺点,为了克服这一缺点,可以通过修改wait()signal()的定义,让一个进程在等待的时候,不是进入自旋,而是阻塞自己,将自己加入到信号量等待队列中,切换成等待状态,并转到CPU调度程序,选择另一个进程来执行。

具体参考操作系统概念第七版-P176

死锁和饥饿

当一组内的每个进程都等待一个事件,而这个事件只能由另一个处于等待的进程完成时,就称这一组进程处于死锁(deadlock)状态。

可以得到,当一个多个线程之间的信号量不止一个的时候,就可能出现死锁。
与死锁相关的问题将在第七章详细讨论。

另一个相关问题是无限期阻塞(indefinite blocking),也叫饥饿(starvtion),即进程在信号量内无限期等待。

找个茬

由于使用信号量的过程中,wait()signal()必须是成对出现的,而且对其出现顺序也有要求,所以一旦程序员在编程中 手抖了 ,把两个方法写错名字或者交换了顺序,那么就会导致一些奇奇怪怪的错误,而且由于是多线程,这类错误不一定每次都会发生,这就很容易导致神秘代码的出现。

上面这一段我觉得没什么必要,甚至提出来感觉有点滑稽,但为了引出管程这一概念,类似的说法被放在了书中管程一节的开头,但这种错误确实还是可能发生的,因此我保留了这一段话,并把它提到了信号量这一节中。


经典问题的信号量解决方案

生产者-消费者问题

这个问题可以有一个通用的解决结构,生产者为消费者增加缓冲项,消费者为生产者减少缓冲项,其具体实现代码如下:

//生产者
do{
    //在nextc中生产一个物品
    wait(empty);
    wait(mutex);
    //添加到缓冲区
    signal(mutex);
    signal(full);

}while(TRUE);
//消费者
do{
    wait(full);
    wait(mutex);
    //从buffer中移动一个物体到nextc
    signal(mutex);
    signal(empty);
    //从nextc中消费物体
}

读者-写者问题

这里仅介绍第一读者-写者问题的解决方案:

//涉及数据
semaphore wrt,mutex;
int readcount;
//写者进程结构
do{
    wait(wrt);
    //写
    signal(wrt);
}while(TRUE);

//读者进程结构
do{
    wait(mutex);
    readcount++;
    if(readcount == 1)
        wait(wrt);
    signal(mutex);
    //读
    wait(mutex);
    readcount -- ;
    if(readcount == 0)
        signal(wrt);
    signal(mutex);
}while(TRUE);

详细部分参考操作系统概念第七版-P179

mutexwrt初始化为1,readcount初始化为0,其用途为:

  • wrt为读者和写者进程共享。它供写者作为互斥信号量,但只被第一个进入临界区和最后一个进入临界区的读者使用。
  • mutex确保更新readcount时的互斥
  • readcount用于跟踪有多少个进程正在读对象

读写锁在以下情况最有用:

  • 每个进程的作用专一,即可以区分哪些进程只需要读哪些进程只需要写共享数据时
  • 读者进程数比写进程多时。因为读写锁的建立开销一般比信号量和互斥锁要大,而这一开销可以通过允许多个读者来增加并发度的方法弥补

哲学家就餐问题

//共享数据:
semaphore chopstick[5];//均初始化为1
//第i个哲学家的进程结构
do{
    wait(chopstick[i]);
    wait(chopstick[(i+1) % 5];
    //吃
    signal(chopstick[i]);
    signal(chopstick[(i+1) % 5]);

    //思考
}while(TRUE);

注意:这一解决方案会导致死锁,如5个哲学家同时拿左边的筷子时。
不会导致死锁的解决方案可以有许多种,如:

  • 最多允许4个哲学家同时坐在椅子上
  • 只有两个筷子都可用时哲学家才能拿起筷子(在临界区拿筷子)
  • 使用非对称方案:奇数位置哲学家先拿左手边筷子,偶数位置哲学家先拿右手边筷子。

哲学家就餐问题还需要确保不会饿死,没有死锁的解决方案不一定能保证没有饿死。


管程***

管程(monitor)是一种基本的,高级的同步构造,是为了处理信号量一节最后提出的一些错误而创造出来的,能更好的解决临界区问题。

管程在功能上和信号量及PV操作类似,属于一种进程同步互斥工具,但是具有与信号量及PV操作不同的属性。

PV操作:P表示通过的意思,V表示释放的意思。也就是wait()signal()

使用

类型封装了私有数据和操作数据的公有方法,而管程就是一种类型

管程类型 的表示包括:

  • 一组变量的声明(定义了一个类型实例的状态)
  • 对这些变量操作的子程序和函数的实现

另一个对管程类型的结构的概括如下:

  • 局部于管程的共享变量
  • 对数据结构进行操作的一组过程
  • 对局部于管程的数据进行初始化的语句

一个简单的管程结构如下所示:


monitor 
{
    //共有变量声明
    procedure P1(...){
        do something
    }
    procedure P2(...){
        do something
    }
    procedure P3(...){
        do something
    }
    //...
    procedure Pn(...){
        do something
    }
    initialization code(...){
        do something
    }
}

管程入口处的等待队列

管程是互斥进入的,所以当一个进程试图进入一个巳被占用的管程时它应当在管程的入口处等待,因而在管程的入口处应当有一个进程等待队列,称作入口等待队列。
这里写图片描述

资源等待队列和条件变量

该结构还比较简单,为了能处理一些特定的同步方案,需要一些额外的同步机制,这些可以通过条件(condition)结构提供。

condition x,y;

condition 包含wait()signal()方法,x.wait()意味着调用该操作的进程会挂起,直到另一进程调用x.signal()

没有调用x.wait()的情况下调用x.signal()没有作用,这是条件结构与信号量不同的一个地方

紧急等待队列

假设有一个悬挂进程Q与条件变量x相关联,现在当执行进程P调用了x.signal()的时候,进程Q会被允许重新执行,这时在概念上QP都可以继续在管程中执行,由于任一时刻,管程中只能有一个活跃进程。所以需要处理,办法有:

  • 唤醒并等待: P等待,直到Q离开管程或等待另一个条件
  • 等待并唤醒: Q等待,直到P离开管程或等待另一个条件
  • 折中:P执行x.signal()的时候,会直接离开管程,则Q会立即开始执行

折中方案也就是规定:signal()操作必须为进程中的最后一个可执行操作(Pascak语言中采取这个方案)


经典问题的管程解决方案

哲学家就餐问题

这个方案是一个无死锁的解决方案,要求一个哲学家在两根筷子都可用的时候才能拿起筷子。

//引入的数据结构
enum{THINKING,HUNGRY,EATING} state[5];
//声明条件变量
condition self[5];

方案遵循以下原则:
- 哲学家i只有两个邻接不就餐的时候才能将state[i]设置为EATING 即:state[(i+4)%5) != EATING && state[(i+1)%5] != EATING
- 哲学家i在饥饿但不满足条件时可以通过self[i]延迟自己

具体代码和解释如下:

monitor dp
{
    enum{THINK,HUNGRY,EATING} state[5];
    condition self[5];

    void pickup(int i){
        state[i] = HUNGRY;
        test(i);
        if(state[i] != EATING)
            self[i].wait();

    }

    void putdown(int i){
        state[i] = THINKING;
        test((i+4)%5);
        test((i+1)%5);
    }
    void test(int i){
        if((state[(i+4)%5] != EATING) && 
        (state[i] != HUNGRY) &&
        (state[(i+1) %5] != EATING)) {
            state[i] = EATING;
            self[i].signal();
        }
    }

    initialization_code() {
        for(int i=0 ; i<5;i++)
            state[i] = THINKING;
    }
}

哲学家就餐通过管程dp控制:每个哲学家在就餐之前,必须调用操作pickup(),这个操作有可能会挂起该哲学家进程;在成功执行该操作后,该哲学家可以就餐;接着,他可以调用putdown(),开始思考。

dp.pickup(i)
    //do something
dp.putdown(i)

这一方案确保了不会产生死锁,但不能确保哲学家不会饿死。


同步机制


原子事务

临界区的互斥确保临界区原子的执行,即如果两个临界区并发执行,那么就相当于两个临界区按某个次序执行

系统模型

执行单个逻辑功能的一组指令或操作称为事务(transaction),处理(原子)事务的主要问题就是无论计算机是否执行失败,都要保证事务的原子性。

从用户观点看,事务是一系列的读写操作,并最终以commitabort结束

commit表示事务已经成功执行
abort表示因各种逻辑错误,事务必须停止执行。

确保事务的原子性需要认识每种存储介质的属性,包括相对速度,容量和容错能力。

  • 易失性存储(volatile storage):在该介质上的信息在系统崩溃后不能保存,会丢失
  • 非易失性存储(nonvalatile storage):在该介质上的信息在系统崩溃后通常能保存
  • 稳定存储(stable storage):在该介质上的信息绝对(某种程度上的)不会丢失,这通常需要多重非易失性存储备份+实时更新

本节只关心在易失性存储下事务的原子性


基于日志的恢复

确保原子性的一种方法是在非易失性存储介质上记录有关对数据修改的描述信息。一般为:先记日志后操作

系统在稳定存储介质上维护一个被称为日志的数据结构,每个日志记录了一个事务写出的单个操作。

每一条日志记录具有如下域:

  • 事务名称:执行写操作事务的唯一名称
  • 数据项名称:所写数据项的唯一名称
  • 旧值:写操作前的数据项的值
  • 新值:写操作后的数据项的值

另外还有一些特殊日志记录用于记录处理事务的重要事件,如开始事务和事务的提交或放弃。

在开始执行前<Ti start>被写到日志中,在每个Ti的写操作前都要将相关操作先记录在日志中,最后在提交后<Ti commit>被写到日志中。

必须确保在把日志存储写到稳定存储前,不能执行真正的更新操作。

恢复算法采用两个步骤:

  • undo(Ti):将Ti更新的所有数据的值恢复到原来值
  • redo(Ti):将Ti更新的所有数据的值设置成新值

所有的新值和旧值都可以在日志记录中找到

如果事务Ti失败,那么使用undo(Ti)恢复记录,如果系统崩溃,则可以按照如下的方式恢复:

  • 如果日志包括了<Ti starts>但没有<Ti commit>,那么事务需要撤销(undo)
  • 如果日志两者都包含,那么事务需要重做(即使已经更新了数据,这样做可以保证数据的可靠性)

要注意undo(Ti)redo(Ti)幂等性,也即调用一次和调用多次的效果对一个事务Ti来说是一样的。


检查点

如果日志记录的时间很长,那么每一次恢复的检索操作,或者系统崩溃后的重做操作都是很大的额外开销

为了降低额外开销,引入检查点(checkpoint)的概念,即除了每次更新日志外,系统还要定时的执行检查点并执行如下操作:

  1. 将当前所有驻留在易失性存储的日志记录输出到稳定存储上
  2. 将当前所有驻留在易失性存储的修改数据输出到稳定存储上
  3. 在稳定存储上输出一个日志记录<checkpoint>

这样就可以让系统简化恢复行为。在系统崩溃后,<checkpoint>的所有事
T都可以不用重做


并发原子操作(P195)

串行化能力

加锁协议

基于时间戳的协议

猜你喜欢

转载自blog.csdn.net/sailist/article/details/79992167