进程同步、进程互斥

一、进程互斥、同步实现

1. 软件实现方法

1.1 单标志法

算法思想:两个进程在访问完临界区后会把使用临界区的权限转交给另一个进程。也就是说每个进程进入临界区的权限只能被另一个进程赋予

int turn = 0;//turn表示当前允许进入临界区的进程号

//P0进程
while(turn != 0);   //进入区
critical section;   //临界区
turn = 1;           //退出区
remainder section;	//剩余区

//P1进程
while(turn != 1);
critical section;
turn = 0;
remainder section;

该算法可以实现“同一时刻最多只允许一个进程访问临界区”

turn表示当前允许进入临界区的进程号,而只有当前允许进入临界区的进程在访问了临界区之后,才会修turn的值,也就是说,对于临界区的访问,一定是按P0->P1-> P0->P1->…这样轮流访问。

这种必须“轮流访问”带来的问題是.如果此时允许进入临界K的进程是P0. 而P0—直不访问临界区,那么虽然此时临界区空闲,但是并不允许P1访问.

因此.单标志法存在的主要问题是:违背“空闲让进”原则。

1.2 双标志先检查

算法思想:设置一个布尔型数组flag[ ],数组中各个元素用来标记各进程想进入临界区的意愿,比如flag[0]=ture 意味着P0进程现在想要进入临界区。每个进程在进入临界区之前先检査当前有没有别的进程想进入临界区,如果没有,则把自身对应的标志flag[ ] 设为true,之后开始访问临界区。

bool flag[2];//表示进入临界区意愿的数组
flag[0] = false;
flag[1] = false;//刚开始设置为两个进程都不想进入临界区

//P0进程
while(flag[1]);		//		  1
flag[0] = true;		//		  2
critical section;   //临界区   3
flag[0] = false;    //退出区   4
remainder section;	//剩余区

//P1进程
while(flag[0]);		//		  5	
flag[1] = true;		// 		  6
critical section;   //临界区	7
flag[1] = false;    //退出区	8
remainder section;	//剩余区

若按照①⑤②⑥③⑦…的顺序执行,P0和P1将会同时访问临界区

因此,双标志先检查法的主要问题是:违反“忙则等待”原则

原因在于,进入区的“检查”和“上锁”两个处理不是一气呵成的。“检查”后,“上锁”前可能发生进程切换

1.3 双标志后检查

**算法思想:双标志先检查法的改版。前一个算法的问题是先“检查”后“上锁”,但是这两个操作又无法一气呵成因此导致了两个进程同时进入临界区的问题。因此,人们又想到先“上锁”后“检查的方法,来避免上述问题。

bool flag[2];//表示进入临界区意愿的数组
flag[0] = false;
flag[1] = false;//刚开始设置为两个进程都不想进入临界区

//P0进程
flag[0] = true;		//		  1
while(flag[1]);		//		  2
critical section;   //临界区   3
flag[0] = false;    //退出区   4
remainder section;	//剩余区

//P1进程
flag[1] = true;		//		  5	
while(flag[0]);		// 		  6
critical section;   //临界区	7
flag[1] = false;    //退出区	8
remainder section;	//剩余区

若按照①⑤②⑥…的顺序执行,P0和P1将都无法进入临界区

因此,双标志后检查法虽然虽然解决了“忙则等待”的问题,但是又违背了“空闲让进”和“有限等待”原则,会因各进程都长期无法访问临界资源而==产生“饥饿”==现象。

两个进程都争着想进入临界区,但是谁也不让谁,最后谁都无法进入临界区。

1.4 Peterson算法

算法思想:双标志后检查法中,两个进程都争着想进入临界区,但是谁也不让谁,最后谁都无法进入临界区。

Gary Peterson想到了一种方法,如果双方都争着想进入临界区,那么可以尝试“孔融让梨”,主动让对方先使用临界区。

bool flag[2];//表示进入临界区意愿的数组
flag[0] = false;
flag[1] = false;//刚开始设置为两个进程都不想进入临界区
int turn = 0;//turn表示优先让哪个进程进入临界区  0表示优先让P0进入

//P0进程
flag[0] = true;
turn = 1;
while(flag[1] && turn == 1); 
critical section;  
flag[0] = false;
remainder section;	

//P0进程
flag[1] = true;					//表示自己想进临界区
turn = 0;						//可以优先让对方进入临界区
while(flag[0] && turn == 0); 	//对方想进,且最后一次是自己“让梨”,那自己就循环等待
critical section;  
flag[1] = false;    			//访问完临界区,表示自己已经不想访问临界区了
remainder section;	

PeterSon算法用软件方法解决了进程互斥问题,遵循了空闲让进、忙则等待、有限等待三个原则,但是依然未遵循让权等待的原则。

2. 硬件实现方法

2.1 中断屏蔽方法

利用“开/关中断指令”实现。

优点:简单、高效。

缺点:不适用于多处理机

​ 只适用于操作系统内核进程,不适用于用户进程(因为开关中断指令只能运行在核心态,只组指令如果能让用户随意使用会很危险

2.2 TestAndSet(TS指令/TSL指令)

TSL指令是用硬件实现的,执行过程中不允许被中断,只能一气呵成。以下是用C语言描述的逻辑

while(TestAndSet(&lock));	//“上锁”并“检查”
critical section;
lock = false;				//“解锁”
remainder section;	

bool TestAndSet(bool *lock){
    bool old;
    old = *lock;	//old用来存放lock原来的值
    *lock = true; 	//无论之前是否已“加锁”,都将lock设为true
    return old;		//返回lock原来的值
}

相比软件实现方式,TSL指令把“上锁”和“检查”操作用硬件的方式变成了一气呵成的原子操作。

优点:实现简单,无需像软件实现方式那样严格检查是否会有逻辑漏洞;适用于多处理机

缺点:不满足“让权等待”原则。

2.3 Swap(XCHG指令)

有的地方也叫Exchange指令,或者简称XCHG指令。

XCHG指令是用硬件实现的,执行过程中不允许被中断,只能一气呵成。以下是用C语言描述的逻辑

bool old = true;
do{
    Swap($lock,&old);		//交互两个变量的值
} while(old == true);
critical section;
lock = false;				//“解锁”
remainder section;

与TSL无多大区别

3. 信号量机制

以上所有的方案都无法实现“让权等待

1965年,荷兰学者Dijkstra提出了原则卓有成效的实现进程互斥、同步的方法----信号量机制

用户进程可以通过使用操作系统提供的一对原语来对信号量进行操作,从而很方便的实现了进程互 斥、进程同步。

信号量其实就是一个变量(可以是一个整数,也可以是更复杂的记录型变量),可以用一个信号量来表示系统中某种资源的数量,比如:系统中只有一台打印机,就可以设置一个初值为1的信号量。

原语是一种特殊的程序段,其执行只能一气呵成,不可被中断。原语是由关中断/开中断指令实现 的。软件解决方案的主要问题是由“进入区的各种操作无法一气呵成”,因此如果能把进入区、退 出区的操作都用“原语”实现,使这些操作能“一气呵成”就能避免问题。

一对原语wait(S)原语和Signal(S)原语,可以把原语理解为我们自己写的函数,函数名分别为wait 和signal,括号里的信号量S其实就是函数调用时传入的一个参数。

wait、signal原语常简称为P、V操作(来自荷兰语proberen和verhogen)。因此,做题的时候常把 wait(S)、signal(S)两个操作分别写为P(S)、V(S)

3.1 整型信号量

用一个整数型的变量1作为信号量,用来表示系统中某些资源的数量。

3.2 记录型信号量

整型信号量的缺陷是存在“忙等”问题,因此人们又提出了“记录型信号量”,即用记录型数据结构表示的信号量。

/*记录型信号量的定义*/
typedef struct {
    int value;			//剩余资源数
    struct process *L;	//等待队列
} semaphore;

/*某进程需要使用资源时,通过wait原语申请*/
void wait(semaphore S){
    S.value--;
    if(S.value < 0){
        /* 如果剩余资源数不够,使用block原语使进程从运行态进入阻塞态,
        并把该进程挂到信号量S的等待队列(即阻塞队列)中 */
        block(S.L);
    }
}

/*某进程使用完资源后,通过signal原语释放*/
void signal(semaphore S){
    S.value++;
    if(S.value <= 0){
        /* 释放进程后,若还有别的进程在等待这种资源,则使用wakeup原语,
        换醒等待队列中的一个进程,该进程从阻塞态变为就绪态 */
        wakeup(S.L);
    }
}

wait(S)、signal(S)也可以记为P(S)V(S),

这对原语可用于实现系统资源的“申请”和“释放”

S.value的初值表示系统中某种资源的数目

对信号量S的一次P操作意味着进程请求一个单位的该类资源,因此需要执行S.value--,表示资源数减1,当S.value <0时表示该类资源己分配完毕,因此进程应调用block原语进行自我阻塞(当前运行的进程从运行态–>阻塞态),主动放弃处理机,并插入该类资源的等待队列S.L中。可见,该机制遵循了 “让权等待”原则, 不会出现“忙等”现象。

对信号量S的一次V操作意味着进程释放一个单位的该类资源,因此需要执行S.Value++,表示资源数加1, 若加1后仍是S.value <=0,表示依然有进程在等待该类资源,因此应调用wakeup原语唤醒等待队列中的第一 个进程(被唤醒进程从阻塞态–>就绪态)。

4. 经典的进程同步问题

4.1 生产者、消费者问题

  1. 问题描述

    系统中有一组生产者进程和一组消费者进程,生产者进程每次生产一个产品放入缓冲区,消费者 进程每次从缓冲区中取出一个产品并使用。(注:这里的“产品”理解为某种数据)

    生产者、消费者共享一个初始为空、大小为n的缓冲区

    只有缓冲区没满时,生产者才能把产品放入缓冲区,否则必须等待

    只有缓冲区不空时,消费者才能从中取出产品,否则必须等待

    缓冲区是临界资源,各进程必须互斥地访问

1558750659273

  1. 实现
int in = 0,out = 0;		//存取位置
item buffer[n];
semaphore mutex = 1;	//互斥信号量,实现对缓冲区的互斥访问
semaphore empty = n;	//同步信号量,表示空闲缓冲区的数量
semaphore full = 0;		//同步信号量,表示产品的数量,也即非空缓冲区的数量

void producer(){
    do{
        producer an item nextp;
        ...
        wait(empty);	//消耗一个空闲缓冲区			1
        wait(mutex);	//						  2
        buffer[i] = nextp;
        in = (in+1)%in;
        signal(mutex);
        signal(full);	//增加一个产品
    } while(true);
}

void consumer(){
    do{
        wait(full);		//消費一个产品			  3
        wait(mutex);	//						  4
        nextc = buffer[out];
        out = (out+1)%out;
        signal(mutex);
        signal(empty);
        consumer the item nextc;
        ...
    } while(true);
}

void main(){
    conbegin
        producer();
    	consumer();
    coend;
}

​ 实现两进程的同步关系,是在其中一个进程中执行P,另一个进程执行V

  1. 思考:能否改变相邻P、V操作的顺序?

    若此时缓冲区内己经放满产品,则empty=0,full=n。

    则生产者进程执行①使mutex狀变为0,再执行②,由于己没有空闲缓冲区,因此生产者被阻塞。 由于生产者阻塞,因此切换回消费者进程。消费者进程执行③,由于mutex为0, 即生产者还没释放对临界资源的“锁”,因此消费者也被阻塞。

    这就造成了生产者等待消费者释放空闲缓冲区,而消费者又等待生产者释放临界区的情况,生 产者和消费者循环等待被对方唤醒,出现==“死锁”==

    同样的,若缓冲区中没有产品,即full=0, empty=n。按③④①的顺序执行就会发生死锁。

    因此,实现互斥的P操作一定要在实现同步的P操作之后

    V操作不会导致进程阻塞,因此两个V操作顺序可以交换

  2. PV操作题目解题思路

    1. 关系分析。找出题目中描述的各个进程,分析它们之间的同步、互斥关系。

    2. 整理思路。根据各进程的操作流程确定P、V操作的大致顺序。

    3. 设置信号量。设置需要的信号量,并根据题目条件确定信号量初值。(互斥信号量初值一般为 1,同步信号量的初始值要看对应资源的初始值是多少)

4.2 多生产者、多消费者问题

  1. 问题描述

    桌子上有一只盘子,每次只能向其中放入一个水果。爸爸专向盘子中放苹果,妈妈专向盘子中放 橘子,儿子专等着吃盘子中的橘子,女儿专等着吃盘子中的苹果。只有盘子空时,爸爸或妈妈才可向盘子中放一个水果。仅当盘子中有自己需要的水果时,儿子或女儿可以从盘子中取出水果。

    用PV操作实现上述过程。

1558751213137

  1. 问题分析

    1558752327013

  2. 实现

    semaphore mutex = 1;	//实现互斥访问盘子
    semaphore apple = 0;	//盘子中有几个苹果
    semaphore orange = 0;	//盘子中有几个橘子
    semaphore plate = 1;	//盘子中还可以放多少个水果
    

    1558752533561

    本题中的缓冲区大小为1,在任何时刻,apple、orande、plate三个同步信号量最多只能有一个是1,因此任何思考,最多只有一个进程的P操作不会被阻塞,并顺利的进入临界区…所以,即使不设置专门的互斥变量mutex,也不会出现多个进程同时访问盘子的现象

    如果盘子(缓冲区)容量为2,则必须设置互斥变量。

4.3 吸烟者问题

  1. 提出问题

    假设一个系统有三个抽烟者进程和一个供应者进程每个抽烟者不停地卷烟并抽掉它,但是要卷起并抽掉一支烟,抽烟者需要有三种材料:烟草、纸和胶水。三个抽烟者中,第一个拥有烟草、第二个拥有纸、第三个拥有胶水。供应者进程无限地提供三种材料,供应者每次将两种材料放桌子上,拥有剩下那种材料的抽烟者卷一根烟并抽掉它,并给供应者进程一个信号告诉完成了,供应者就会放另外两种材料再桌上,这个过程一直重复(让三个抽烟者轮流地抽烟

本质上这题也属于“生产者一消费者”问题,更详细的说应该是“可生产多种产品的“单生产者一多消费者”。

1558755716307

  1. 问题分析

    1558755760812

    1. 如何实现

1558755910708

4.4 读者、写者问题

  1. 问题描述

    有读者和写者两组并发进程,共享一个文件,当两个或两个以上的读进程同时访问共享数据时不会产生副作用,但若某个写进程和其他进程(读进程或写进程)同时访问共享数据时则可能导致数据不一致的错误。因此要求:

    ​ ① 允许多个读者可以同时对文件执行读操作:

    ​ ② 只允许一个写者往文件中写信息:

    ​ ③ 任一写者在完成写操作之前不允许其他读者或写者工作;

    ​ ④ 写者执行写操作前,应让己有的读者和写者全部退出。

    1. 问题分析

    两类进程:写进程、读进程

    互斥关系:写进程一写进程、写进程一读进程。读进程与读进程不存在互斥问题。

    ​ 写者进程和任何进程都互斥,设置一个互斥信号量rw, 在写者访问共享文件前后分别执行 p 、 V 操作。读者进程和写者进程也要互斥,因此读者访问共享文件前后也要对 rw 执行 p 、 V 操作。如果所有读者进程在访问共享文件之前都执行 P(rw)操作,那么会导致各个读进程之间也无法同时访问文件。 Key :读者写者问题的核心思想一一怎么处理该问题呢

    P(rw)V(rw)其实就是对共享文件的“加锁”和“解锁”。既然各个读进程需要同时访问,而读进程与写进程又必须互斥访问,那么我们可以让第一个访问文件的读进程“加锁”,让最后一个访问完文件的读进程“解锁”。可以设置一个整数变量 count 来记录当前有几个读进程在访问文件。

  2. 如何实现

    semaphore rw= 1;		//用于实现对文件的互斥访问。表示当前是否有进程在访问共享文件
    int count = 0;			//记录当前有几个读进程在访问文件
    semaphore mutex = 1;	//用于保证对count变量的互斥访问   		    1
    semaphore w = 1;		//用于实现“写优先”						   2
    

    ​ ① 若不加mutex,对count变量的检查和赋值无法一气呵成

    ​ ② 若不加w,那么只要有读进程还在读,写进程就要一直阻塞等待,可能“饿死”。

    ​ 若加上w,写者不会饥饿,但也并不是真正的“写优先”,而是相对公平的先来先服务原则

    void writer(){
        while(true){
            wait(w);
            wait(rw);
            perform write operation;
            signal(rw);
            signalw);
        }
    }
    
    1. 思考

      读者写者问题为我们解决复杂的互斥问题提供了一个参考思路。

      核心思想在于设置了一个计数器count来记录当前正在访问共享文件的读进程数。我们可以用 count的值来判断当前进入的进程是否是第一个/最后一个读进程,从而做出不同的处理。

      另外,对count 变量的检查和赋值不能一气呵成导致了一些错误,如果需要实现“一气呵成”,自然应该想到用互斥信号量

      最后,还要认真体会如何解决“写进程饥饿”问题的。

4.5 哲学家进餐问题

  1. 问题描述

    一张圆桌上坐着 5 名哲学家,每两个哲学家之间的桌上摆一根筷子,桌子的中间是一碗米饭。哲学家们倾注毕生的精力用于思考和进餐,哲学家在思考时,并不影响他人。只有当哲学家饥饿时,才试图拿起左、右两根筷子(一根一根地拿起)。如果筷子已在他人手上,则需等待。饥饿的哲学家只有同时拿起两根筷子才可以开始进餐,当进餐完毕后,放下筷子继续思考。

  2. 问题分析

    1. 关系分析。系统中有 S 个哲学家进程, S 位哲学家与左右邻居对其中间筷子的访问是互斥关系。
    2. 整理思路。这个问题中只有互斥关系,但与之前遇到的问题不同的事,每个哲学家进程需要同时持有两个临界资源才能开始吃饭。如何避免临界资源分配不当造成的死锁现象,是哲学家问题的精髓。
    3. 信号量设置。定义互斥信号量数组 chopstick[5] ={1,1,1,1,1 }用于实现对 5 个筷子的互斥访问。并对哲学家按0~4 编号,哲学家 i 左边的筷子编号为 i ,右边的筷子编号为(i+1)%5 。
  3. 具体分析
    若每个哲学家吃饭前依次拿起左、右两支筷子,如果5个哲学家并发地拿起了自己左手边的筷子…则每位哲学家循环等待右边的人发下筷子(阻塞),发生“死锁”。

    如何防止死锁的发生呢?

    1. 可以对哲学家进程施加一些限制条件,比如最多允许四个哲学家同时进餐。这样可以保证至少有一个哲学家是可以拿到左右两只筷子的。
    2. 要求奇数号哲学家先拿左边的筷子,然后再拿右边的筷子,而偶数号哲学家刚好相反。用这种方法可以保证如果相邻的两个奇偶号哲学家都想吃饭,那么只会有其中一个可以拿到第一只筷子,另一个会直接阻塞。这就避免了占用一只再等待另一只的情况。
    3. 仅当一个哲学家左右两只筷子都可以用时才允许他抓起筷子。
  4. 实现

    这里用上面防止死锁的第三种方法举例

    semaphore chopstick[5] = {1,1,1,1,1};
    semaphore mutex = 1;				//互斥的取筷子
    Pi(){				
        while(true){
            P(mutex);
            P(chopstick[i]);			//拿左
            P(chopstick[(i+1)%5]);		//那右
            V(mutex);
            吃饭。。。
            V(chopstick[i]);
            V(chopstick[(i+1)%5]);
            思考。。。
        }
    }
    
    

5. 管程(Monitor )

5.1 为什么要引入管程

  1. 信号量机制使大量的同步操作分散在各个进程中,这不仅给系统的管理带来了麻烦,而且还会以为同步操作的使用不当导致系统死锁。编写程序困难,易出错
  2. 能不能设计一种机制,让程序员写程序是不需要再关注复杂的PV操作,让写代码更轻松?
  3. 1973年,Brinch Hansen 首次在程序设计语言(Pascal)中引入了“管程”成分,一种高级同步机制。

5.2 管程的定义和基本特征

5.2.1 定义

系统中的各种硬件资源和软件资源均可用数据结构抽象地描述其资源特性,即用少量的信息和对该资源所执行的操作来表征该资源,而忽略它们的内部结构和实现细节。

代表共享资源的数据结构以及对该共享结构实施操作的一组过程所组成的资源管理程序共同构成了一个操作系统的资源管理模块,我们称之为==“管程”==

管程被请求和释放资源的进程所调用。

Hansen 为管程所下的定义是:“一个管程定义了一个数据结构和能为并发进程所执行(在该数据结构上)的一组操作,这组操作能同步进程和改变管程中的数据”。

5.2.2 管程的组成

管程是一种特殊的软件模块,有这些部分组成:

  1. 管程的名字。

  2. 局部于管程的共享数据结构说明;

  3. 对该数据结构进行操作的一组过程

  4. 对局部于管程的共享数据设置初始值的语句;

“过程”其实就是“函数”

5.2.3 管程的基本特征

  1. 局部于管程的数据只能被局部于管程的过程所访问;
  2. 一个进程只有通过调用管程内的过程才能进入管程访问共享数据
  3. 每次仅允许一个进程在管程内执行某个内部过程。

从语言的角度来看,管程主要有以下特性:

  1. 模块化。即管程是一个基本程序单位,可以单独编译
  2. 抽象数据类型。指管程中不只有数据,而且有对数据的操作
  3. 信息掩蔽。指管程中的数据结构只能被管程中的过程所访问,这些过程也是在管程内部定义的,供管程外的进程调用,而管程中的数据结构已经过程(函数)的具体实现外部不可见。

5.2.4 管程的语法描述

Monitor monitor_name{				//管程名
    share variable declarations;	//共享变量说明
    condition declarations;			//条件变量说明
    public:							//能被进程调用的过程
    void P1(...){					//对数据结构操作的过程
        ......
    }
    void P2(...){
        ......
    }
    ......
        
    {								//管程主体
        initialization code;		//初始化代码
        ......
    }
}

5.2.5 用管程解决生产者消费者问题

Monitor ProducerConsumer
	condition full,empty;		//设置条件变量和等待/唤醒操作,已解决同步问题
	int count = 0//缓冲区的产品数
	void insert(Item item){		//把产品item放入缓冲区
		if(count == N)
			wait(full);
		count++;
		insert_item(item);
		if(count == 1)
			signal(empty);
	}
	Item remove(){				//从缓冲区取走一个产品
		if(count == 0)
			wait(empty);
        count--
        if(count == N-1)
        	signal(full);
        return remove_item();
	}
end Monitor;
//生产者进程
producer(){
    while(true){
        item = 生产一个产品;
        ProducerConsumer.insert(item);
    }
}
//消费者进程
consumer(){
    while(true){
        item = ProducerConsumer.remove();
        消费产品item;
    }
}

引入管程的目的无非就是要更方便地实现进程互斥和同步。

  1. 需要在管程中定义共享数据(如生产者消费者问题的缓冲区)
  2. 需要在管程中定义用于访问这些共享数据的“入口”一一其实就是一些函数(如生产者消费者问题中,可以定义一个函数用于将产品放入缓冲区,再定义一个函数用于从缓冲区取出产品)
  3. 只有通过这些特定的“入口”才能访问共享数据
  4. 管程中有很多“入口”,但是每次只能开放其中一个“入口”,并且只能让一个进程或线程进入(如生产者消费者问题中,各进程需要互斥地访问共享缓冲区。管程的这种特性即可保证一个时间段内最多只会有一个进程在访问缓冲区。注意:这种互斥特性是由编译器负责实现的,程序员不用关心)
  5. 可在管程中设置条件变量等待/唤醒操作以解决同步问题。可以让一个进程或线程在条件变量上等待(此时,该进程应先释放管程的使用权,也就是让出“入口”) ;可以通过唤醒操作将等待在条件变量上的进程或线程唤醒。

程序员可以用某种特殊的语法定义一个管程(比如: Monitor ProducerConsumer… … end Monitor ; )

之后其他程序员就可以使用这个管程提供的特定“入口2”很方便地使用实现进程同步/互斥了。

5.2.6 java中类似于管程的机制

java中,如果用关键字synchronized来描述一个函数,那么这个函数同一时间段内只能被一个线程调用

static class Monitor{
    private Item buffer[] = new Item[N];
    private int count = 0;
    
    public synchronized void insert(Item item){
        ......
	}
}

  1. 与普通整数变量的区别:对信号量的操作只有三种,即 初始化、P操作、V操作 ↩︎

  2. “封装”思想 ↩︎

你可能感兴趣的:(操作系统)