临界区的互斥

问题描述

1.什么是临界区?
临界区是共享的资源(如共享文件,共享变量,全局变量等)进行访问的一段程序代码。访问共享的内存是临界区和其他代码相区别的地方,当计算机中运行的多个进程都有执行临界区的代码的时候,这个时候就会出现对共享内存的竞争。如果多个临界区都是对共享内存进行读,则问题不大,不会出现竞争;当某个临界区想要修改共享内存时,这个时候问题就大了!如果进程A要修改某个共享变量,读取该变量进行修改后,正要往回写却被挂起,另外一个进程B执行的时候又读取了该共享变量。这个时候便发生了竞争条件。进程B读取到的值是老的值,并不是更新之后的值。类似的情况还有很多,都会导致竞争条件,两个或多个进程读写某些共享数据时,最后的结果取决于进程运行的精确顺序。

那么问题就来了,如何让各个临界区读写共享数据时不出现竞争条件呢?也就是,如何实现临界区的互斥呢?
2.解决方法有哪些?
常用的方法可以分成忙等待的方法、阻塞其他临界区的方法。
忙等待的方法又有以下几种:

  • 屏蔽中断
  • 锁变量
  • 严格轮转法
  • Peterson解法
  • TSL指令

阻塞的方法又有以下几种:

  • 使用sleep 和 wakeup原语
  • 信号量
  • 管城

接下来对它们进行逐一介绍。

解决方法

忙等待的方法

1.屏蔽中断
在单处理器系统中,发生竞争的原因很大一部分是因为在访问共享内存时,进行了进程切换,也就是执行临界区的进程被挂起了,然后其他的临界区又去访问了共享内存。一个简单粗暴的方法就是在执行临界区时直接屏蔽中断。屏蔽中断后,时钟中断也被屏蔽,而CPU只有在发生时钟中断时才能切换进程。访问共享内存时不能切换进程也就保持了访问共享内存的连续性,不能被其他进程打断,对共享内存的访问也就保持了一致性。其他进程想要访问就只能等着了(忙等)
但是这个方法并不好,将屏蔽中断的权利交给用户进程是很不安全的,如果用户进程进入临界区后不打开中断,则整个系统可能会因此而崩溃。而且对于多核处理器,并不能阻止其他CPU上运行的进程对共享变量的访问。

2.锁变量
很形象地来说,就是为共享内存加一把锁,当有进程的临界区想访问这个共享内存时要先试试能不能打开,如果能打开就可以访问,如果不能打开就一直等着,等到能打开为止(也就是忙等)。具体的实现方法:先创建一个共享变量(也就是锁,这把锁也需要共享才能让其他进程也能访问到。不过正是因为锁也是共享的,访问恭锁变量的代码也成了临界区,这又引入了新的问题),并初始化锁为0,代表可以访问共享内存,如果是1则代表有进程正在访问共享内存,要等到锁为0时才能访问(忙等)
锁变量的问题很明显,当不同进程测试锁的时候也是在执行临界区,那么又怎么保证访问锁变量时能互斥呢?

3.严格轮转法
严格轮转法其实是锁变量的一种。严格轮转法中有一个整型变量 turn 来记录轮到哪个进程进入临界区,初始值为0。实现严格轮转法的伪代码:

进程 0 的代码:
while(True){
	while( turn != 0) ; // 循环,忙等
	临界区代码,进行共享内存的访问;
	turn = 1;
	非临界区;
}

进程 1 的代码:
while(True){
	while( turn != 1) ; // 循环,忙等
	临界区代码,进行共享内存的访问;
	turn = 0;
	非临界区;
}

严格轮转法同样有锁变量中的问题,谁来保证 turn 访问的互斥性呢?所以还是有可能两个进程都在访问同一块共享内存。除此之外,与锁变量相区别的是,严格轮转法的轮转的特性,从上面的代码也可以看出,要想访问共享内存,进程 0 和进程 1 需要交替地进行访问,但是如果这俩进程一个执行地飞快,而另一个又很慢呢?这个时候快的进程只能一直在while 那里循环,忙等,(用于忙等的锁也叫自旋锁)。对于执行速度差别较大的进程,不适合用轮转法。

4.Peterson解法
Peterson方法使用也使用轮转法中的 turn 变量,同时还用一个数组来记录谁想执行临界区。Peterson解法的代码如下:

#define FALSE 0
#define TRUE 1
#define N 2									//进程数量

int turn;									//轮到谁进入临界区
int interested[N];							//所有值初始化为 FALSE

void enter_region(int process)				//进程号是0或1
{
	int other;								//另一进程号
	 other = 1 - process;
	 interested[process] = TRUE;			//表示 process 进程想要访问临界区
	 turn = process;
	 while(turn == process && interested[other] == TRUE) ;		//忙等,关键语句,这是这个语句解决了turn的同时访问问题
}

void leave_region(int process)				//process 进程离开临界区
{
	interested[process] = FALSE;
}

这里的interested数组就解决了上面提到的当turn 被多个进程访问时的问题。当多个进程同时写了turn,将其设置为自己的进程号(0 或 1),但只有后写入的进程号才有效,先被写入的turn被后写入的turn所覆盖。假设进程1 是后写入的,则turn 为 1,当两个进程都运行到 while 语句时,进程 01执行0 次循环并进入临界区,而进程1则循环直到进程 0 退出临界区为止。

5.TSL指令
这个方法只要通过一条叫做 TSL 指令实现,

TSL RX, LOCK

TSL指令成为 测试并加锁(Test and Lock),上述代码将内存字 LOCK 读到寄存器 RX 中,然后在该内存地址上存一个非零值。注意,读和写字操作是原子的(不可分割的)。执行 TSL 指令的CPU将锁住内存总线,以禁止其他CPU在本指令结束之前访问内存。锁住存储总线和屏蔽中断不一样,屏蔽处理器在多核处理器中不可行,但锁住总线后其他的处理器则不能访问内存了。使用TSL指令来实现互斥如下:

enter_region:
	TSL REGISTER, LOCK					;复制锁到寄存器并设锁为1
	CMP REGISTER, #0 					;测试锁是否为0
	JNE enter_region					;若不是0,说明已被设置,则循环,忙等
	RET									;返回调用者,调用者进入临界区

leave_region:
	MOVE LOCK, #0						;在锁中存0
	RET

TSL 指令将LOCK 原来的值复制到寄存器中并设置为1,随后这个原来的值与 0 比较,如果非零,则已经被加锁,重新测试;若为0则RET,调用者执行临界区。

阻塞的方法

1.使用sleep 和 wakeup原语
原语是不可中断的过程。sleep原语会使调用者进入休眠状态,被阻塞;wakeup原语会唤醒某个进程。slepp 和 wakeup 一般会作为系统调用。在使用slepp 和 wakeup时,一般会根据某个变量的值来调用slepp 和 wakeup实现互斥。如生产者-消费者问题:

#define N 100					//缓冲区的槽数目
int count = 0;					//缓冲区中的数据项数目

void  producer()
{
	int item;
	while(TRUE){
		item = produce_item();			//生产数据
		if (count == N) sleep();			//如果缓冲区满则调用sleep进入睡眠
		insert_item(item);
		count++;
		if(count == 1) wakeup(consumer);		//如果刚才缓冲区是空的则唤醒睡眠的消费者
	}	
}


void consumer()
{
	int item;
	while(TRUE){
		if(count == 0) sleep();			//如果缓冲区为空则睡眠
		item = remove_item();
		count--;
		if(count == N-1) 	wakeup(producer);			//如果刚才缓冲区是满的,则唤醒生产者
		consume_item(item);
			
	}

}

该方法的问题是对count的访问时竞争的,容易导致唤醒信号丢失。例如,当消费者发现缓冲区为空并读取到count为0,在sleep之前被挂起,之后生产者运行,生产一个item后发现count为1,认为消费者在睡眠,然后遍wankeup(consumer),给消费者发送唤醒信号,但是此时消费者并未睡眠,所以唤醒信号丢失。当消费者运行时,因为之前读取的count为0,于是sleep。随后生产者迟早会填满缓冲区,然后sleep。于是消费者和生产者都睡眠,没人去唤醒任何一个。

2.信号量
信号量使用一个整型变量来累积唤醒次数,这个变量就叫做信号量,信号量的取值可以为0和正值。信号量中也使用了两个操作,down和up(也叫p和v)。对一信号量执行down操作,则是检查其值是否大于0,。若该值大于0,则将其值减1(即用掉一个保存的唤醒信号)并继续执行;若为0,则进程将睡眠,此时down并未结束。对一信号量执行up操作,将会对信号量增加1.如果一个或多个进程在这个信号量上睡眠,即有进程的down操作未完成,则唤醒其中一个睡眠的进程继续完成它的down操作。**注意,down和up操作的检查数值、修改变量以及可能发生的睡眠操作是不可分割的。**使用信号量解决生产者-消费者问题:

#define N 100					//缓冲区的槽数目
typedef int semaphore 			//信号量是一种特殊的整形数据
semaphore mutex = 1;			//控制对临界区的访问
semaphore empty = N;			//计数缓冲区的空槽数
semaphore full = 0;				//技术缓冲区的满槽数
int count = 0;					//缓冲区中的数据项数目

void  producer()
{
	int item;
	while(TRUE){
		item = produce_item();			//生产数据
		down(&empty);			//将空槽数减一,若已满则会阻塞
		down(&mutex);			//进入临界区
		insert_item(item);
	    up(&mutex);				//离开临界区
		up(&full);	 			//将满槽数加1
	}	
}


void consumer()
{
	int item;
	while(TRUE){
		down(&full);			//将满槽数减一,若为空则会阻塞
		down(&mutex);			//进入临界区
		insert_item(item);
	    up(&mutex);				//离开临界区
		up(&empty);	 			//将空槽数加1
	}

}

看似信号量很好的解决了互斥的问题,但是考虑一个问题,若一下代码交换次序的话:

down(&empty);			//将空槽数减一,若已满则会阻塞
down(&mutex);			//进入临界区

则有可能生产者被阻塞,同时也会使消费者被阻塞。这样两个进程都会阻塞。注意,使用信号量时需要非常小心,注意操作的顺序!

3.管程
一个管程是由过程、条件变量及数据结构等组合成的一个集合,它们组成一个特殊的模块或软件包。进程可以在任何需要的时候调用管程中的过程,但它们不能在管程之外的声明的过程中访问管程中的数据结构。管程的一个重要特性是:任一时刻管程中只能有一个活跃进程。需要注意的是,管程是编程语言的组成部分,编译器知道它们的特殊性,因此可以采用与其他过程调用不同的方法来处理对管程的调用。管程中通常使用条件变量来使无法运行的进程阻塞,以及两个操作:wait和signal。

猜你喜欢

转载自blog.csdn.net/Miha_Singh/article/details/90383838