问题描述
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。