进程同步、进程互斥
- 进程同步
概念:并发性带来异步性,有时需要通过进程同步来解决这种异步问题。有的进程之间需要相互配合地完成工作,各进程的工作推进需要遵循一定的先后顺序。 - 进程互斥
- 概念:对临界资源的访问,需要互斥进行。同一时间段内只能允许一个进程访问资源。
- 逻辑部分:
进入区:负责检查是否可以进入临界区,若可进入,则应设置正在访问临界资源的标识,以阻止其他进程同时进入临界区。do { entry section; //进入区 critical section; //临界区 exit section; //退出区 remainder section; //剩余区 }while (true);
临界区:访问临界资源的那段代码。
退出区:负责解除正在访问临界资源的标识。
剩余区:做其他的事情。 - 需要遵循的原则:
1.空闲让进。临界区空闲时,可以允许一个请求进入临界区的进程立即进入临界区。
2.忙则等待。当已有进程进入临界区时,其他试图进入临界区的进程必须等待。
3.有限等待。对请求访问的进程,应保证能在有限时间内进入临界区(保证不会饥饿)。
4.让权等待。 当进程不能进入临界区时,应立即释放处理机,防止进程忙等待。
进程互斥的软件实现方法
- 双标志先检查法
- 基本思想:当进程要申请使用临界资源的时候,先检查该资源是否有被占用,再进行“上锁”操作,退出区“解锁”。
boolean inside1,inside2; inside1 = false;inside2 = false; cobegin process P1(){ while (inside2); inside1 = true; 访问临界资源; inside1 = false; } process P2(){ while (inside1); inside2 = true; 访问临界资源; inside2 = false; } coend
- 存在问题:假设P1、P2先后执行了while(inside2);和while(inside1);发现对方都不在临界区内,就会同时进入临界区,然后同时访问临界资源。这违背了“忙则等待”原则。
- 基本思想:当进程要申请使用临界资源的时候,先检查该资源是否有被占用,再进行“上锁”操作,退出区“解锁”。
- 双标识后检查法
- 基本思想:先“加锁”后“检查”。退出区“解锁”。
boolean inside1,inside2; inside1 = false;inside2 = false; cobegin process P1(){ inside1 = true; while (inside2); 访问临界资源; inside1 = false; } process P2(){ inside2 = true; while (inside1); 访问临界资源; inside2 = false; } coend
- 存在问题:当P1、P2先后执行了inside1 = true;和inside2 = true;之后,P1、P2分别执行while (inside2);和while (inside1); 时,都会因为条件不满足而无法往下执行。这违背了“有限等待的原则”。
- 基本思想:先“加锁”后“检查”。退出区“解锁”。
- Peterson算法
- 基本思想:当一个进程需要进入临界区时,需要先调用enter_section();函数,判断是否可以安全进入临界区,若不能则等待;当从临界区退出后,调用leave_section();函数,允许其他进程进入临界区。
boolean flag[2]; //表示进入临界区意愿的数组,初始false. int turn = 0; //表示优先让哪个进程进入临界区 void process_P0{ flag[0] = true; //表示自己想进去临界区。 turn = 1; //可以优先让对方进入临界区。 while (flag[1] && turn == 1); critical section; flag[0] = false; remainder section; } void process_P1{ flag[1] = true; turn = 0; while (flag[0] && turn == 0); critical section; flag[1] = false; remainder section; }
进程互斥的硬件实现方法
- 开关中断法
优点:简单、高效。do { 关中断; //关中断后不允许当前进程被中断,也必然不会发生进程切换。 访问临界区; 开中断; //直到当前进程访问完临界区,再执行开中断指令,才有可能有别的进程进行访问临界区。 其余代码; } while (1);
缺点:不适用于多处理机;只适用于操作系统内核进程,不适用于用户进程(因为开/关中断指令只能运行再内核态,这组指令如果能让用户随意使用会很危险) - TS方法
优点:实现简单,无需像软件实现方法那样严格检查是否会有逻辑漏洞;适用于多处理及环境。bool lock; //表示当前临界区是否被加锁。 bool TestAndSet(bool *lock){ bool old; old = *lock; //old存放原来的值。 *lock = true; //无论之前是否加锁,都设为true。 return old; //返回原来的锁 } //下面是使用TS指令实现互斥的算法逻辑 while (TestAndSet(&lock) ); 临界区代码; lock = false; //解锁 剩余代码;
缺点:不满足“让权等待”原则,暂时无法进入临界区的进程会占用CPU并循环执行TS指令,从而导致“忙等”。 - Swap指令
逻辑上同TS方法。//swap作用是交换两个变量的值。 Swap(bool *a,bool *b){ bool temp; temp = *a;*a = *b;*b = temp; } //下面是用swap指令实现互斥的算法逻辑 bool old = true; while (old == true) swap(&lock, &old); 临界区代码; lock = false; 剩余代码;
信号量机制
用户进程可以通过使用操作系统提供的一对原语来对信号量进行操作,从而方便地实现进程互斥、进程同步。
信号量其实就是一个变量(可以是整型或者其他结构),可以用信号量来表示系统中某种资源地数量。
原语是一种特殊地程序段,其执行只能一气呵成,不能被中断。原语是由开/关中断指令实现的。
- 整型信号量
用一个整数型的变量作为信号量,用来表示系统中某种资源的数量。
定义:
int S = 1; //表示初始数量是1;
物理意义:
1.S>0 : 当前有S个资源可用;
2.S=0 : 当前没有资源可用,且没有等待该资源的进程;
3.S<0 : 当前有abs(S)个进程正在等待该资源;void P(int S){ while (S <= 0) ; //如果资源不够,就一直循环等待; S --; //如果资源数量够,就占用一个资源。 } void V(int S){ S++; //使用完之后释放资源。 }
- 记录型信号量
若当前无临界资源可用,则申请访问临界资源的进程将被插入阻塞队列中;进程在退出临界区、释放临界资源时,需唤醒阻塞队列中的其他进程。/*记录型信号量的定义*/ typedef struct { int value; //剩余资源数; struct process *L; //等待队列 } semaphore; /*P、V原语*/ void P(struct semaphore S){ S.value --; //申请一个资源 if (S.value < 0) block(S.p); //若信号量为0,表示无资源可用,加入阻塞队列; } void V(struct semaphore S){ S.value ++; //释放一个资源 if (S.value <= 0) wakeup(S.p); //若信号量<=0,表示有进程在等待该资源,唤醒阻塞队列中的进程。 }
对信号量S的一次V操作意味着进程释放一个单位的该类资源,因此需要先执行S.value++, 表示资源数加1,若加1后仍然是S.value<=0,表示依然有进程在等待该类资源,因此应调用wakeup原语唤醒等待队列中的第一个进程(被唤醒程序从阻塞态->就绪态)。 - 记录型信号量
用信号量实现进程互斥、同步关系
- 用信号量实现进程互斥
1.分析并发进程的关键活动,划定临界区。
2.设置互斥信号量mutex,初值为1。
3.在进入临界区之前执行P(mutex)。
4.在退出临界区之后执行V(mutex)。
semaphore mutex = 1;
void P1(){
···
P(mutex);
临界区代码;
V(mutex);
···
}
- 用信号量实现进程同步
1.分析在什么地方需要实现“同步关系”,即必须保证“一前一后”执行的两个操作。
2.设置同步信号量S,初值为0。
3.在“前操作”之后执行V(S)。
4.在“后操作”之前执行P(S)。semaphore S = 0; void P1(){ void P2(){ ··· P(S); V(S); ··· ··· ··· } } /* 若先执行到V(S);操作,则S++后S = 1;之后当执行到P(S)操作的时候,由于S = 1,P2进程不会执行block原语,而是会继续执行下面的代码。 若先执行到P(S)操作,由于S = 0;S-- 之后S = -1;因此P操作中会执行block原语,主动请求阻塞,当P1执行完V(S)操作,使S变为0之后,P2进程才能继续执行。 */
生产者-消费者问题
问题描述:系统中有一组生产者进程和一组消费者进程,生产者进程每次生产一个产品放入缓冲区,消费者进程每次从缓冲区中取出一个产品并使用。生产者和消费者共享一个初始为空、大小为n的缓冲区。只有缓冲区没满时,生产者才能把产品放入缓冲区,否则必须等待;只有缓冲区不空时,消费者才能从中取出产品,否则必须等待;缓冲区是临界资源,各进程必须互斥地访问。
semaphore mutex = 1; //互斥信号量,实现对缓冲区地互斥访问。
semaphore empty = n; //同步信号量,表示空闲缓冲区的数量。
semaphore full = 0; //同步信号量,表示产品的数量,也即非空缓冲区的数量。
Producer () {
while (1){
生产一个产品;
P(empty); //消耗一个空闲缓冲区
P(mutex);
把产品放入缓冲区;
V(mutex);
V(full); //增加一个产品
}
}
Consumer () {
while (1){
P(full); //消耗一个产品
P(mutex);
从缓冲区取出一个产品;
V(mutex);
V(empty);
使用产品;
}
}
实现互斥是在同一进程中进行一对PV操作;实现两进程的同步关系,是在其中一个进程中执行P,另一个进程中执行V。
需要注意的是实现互斥的P操作一定要放在实现同步的P操作之后,即上面的P(mutex);一定要放在P(empty);之后;V操作不会导致进程阻塞,所有两个V的顺序没有要求,但是还是保持对称比较好。
多生产者-多消费者问题
问题描述:假如桌子上有一只盘子,每次只能向其中放入一个水果。爸爸专门向盘子中放苹果,妈妈专门向盘子中放橘子,儿子专门等着吃盘子中的橘子,女儿专门等着吃盘子中的苹果。只有盘子空时,爸爸或妈妈才可向盘子中放一个水果。仅当盘子中有自己需要的水果时,儿子或女儿可以从盘子中取出水果。
semaphore mutex = 1; //实现互斥访问盘子(缓冲区)
semaphore apple = 0; //盘子中有几个苹果
semaphore orange = 0; //盘子中有几个橘子
semaphore plate = 1; //盘子中还可以放多少水果
void dad () {
while (1) {
准备一个苹果;
P(plate);
P(mutex);
把苹果放入盘子;
V(mutex);
V(apple);
}
}
void mom () {
while (1) {
准备一个橘子;
P(plate);
P(mutex);
把橘子放入盘子;
V(mutex);
V(orange);
}
}
void daughter () {
while (1) {
P(apple);
P(mutex);
从盘子中取出苹果;
V(mutex);
V(plate);
吃掉苹果;
}
}
void son () {
while (1) {
P(orange);
P(mutex);
从盘子中取出橘子;
V(mutex);
V(plate);
吃掉橘子;
}
}
注意:其实在本题中可以不需要mutex这个互斥量。但是当盘子中可以放不止一个水果的时候,那就需要爸爸和妈妈互斥地访问盘子缓冲器了。
读者-写者问题
问题描述:有读者和写者两组并发进程,共享一个文件,当两个或两个以上的读进程同时访问共享数据时不会产生副作用,但若某个写进程和其他进程(读进程或写进程)同时访问共享数据时则可能导致数据不一致的错误。因此要求:①允许多个读者可以同时对文件执行读操作:②只允许一个写者往文件中写信息;③任一写者在完成写操作之前不允许其他读者或写者工作:④写者执行写操作前,应让已有的读者和写者全部退出。
semaphore rw = 1; //用于对文件互斥访问
semaphore mutex = 1; //用于保证对count变量的互斥访问
int count = 0; //记录当前有几个读进程
void writer () {
P(rw);
写文件;
V(rw);
}
void reader () {
P(mutex); //各进程互斥访问count
if (count == 0) P(rw); //第一个读者负责“加锁”
count ++;
V(mutex);
读取文件;
P(mutex);
count --;
if (count == 0) V(rw); //最后一个读者负责“解锁”
V(mutex);
}
上面代码的潜在问题:只要有都进程还在读,写进程就要一直阻塞等待,可能“饿死”,因此,这种算法中,读进程是优先的。
如何解决上面的问题?
semaphore rw = 1;
semaphore mutex = 1;
int count = 0;
semaphore w = 1; //实现“写优先”
void writer () {
P(w);
P(rw);
写文件;
V(rw);
V(w);
}
void reader() {
P(w);
P(mutex);
if (count == 0) P(rw);
count ++;
V(mutex);
V(w);
读文件;
P(mutex);
count --;
if (count == 0) V(rw);
V(mutex);
}
结论:在这种算法中,连续进入的多个读者可以同时读文件;写者和其他进程不能同时访问文件;写着不会“饥饿”,但也并不是真正的“写优先”,而是相对公平的先来先服务原则。
哲学家进餐问题
问题描述:一张圆桌上坐着5名哲学家,每两个哲学家之间的桌上摆一根筷子,桌子的中间是一碗米饭。哲学家们倾注毕生的精力用于思考和进餐,哲学家在思考时,并不影响他人。只有当哲学家饥饿时,才试图拿起左、右两根筷子(一根一根地拿起)。如果筷子己在他人手上,则需等待。饥饿的哲学家只有同时拿起两根筷子才可以开始进餐,当进餐完毕后,放下筷子继续思考。
每个哲学家进程需要同时持有两个临界资源才能开始吃饭,如何避免临界资源分配不当造成的死锁现象,是哲学家问题的精髓。
- 原始代码:
存在问题:如果5各哲学家并发地拿起了自己左手边的筷子,每个哲学家循环等待右边的人放下筷子(阻塞),会发生“死锁”现象。semaphore chopstick[5] = {1,1,1,1,1}; void P_i() { P(chopstick[i]); //拿左 P(chopstick[(i+1)%5]); //拿右 吃饭; V(chopstick[i]); //放左 V(chopstick[(i+1)%5]); //放右 思考; }
- 解决代码:
semaphore chopstick[5] = {1,1,1,1,1}; semaphore mutex = 1; //互斥地取筷子 void P_i() { P(mutex); P(chopstick[i]); P(chopstick[(i+1)%5]); V(mutex); 吃饭; V(chopstick[i]); V(chopstick[(i+1)%5]); 思考; }
管程
- 管程是一种特殊的软件模块,由这些部分组成:
1.局部于管程的共享数据结构说明;
2.对该数据结构进行操作的一组过程;
3.对局部于管程的共享数据设置初始值的语句;
4.管程有一个名字。 - 管程的基本特征:
1.局部于管程的数据只能被局部于管程的过程访问;
2.一个进程只有通过调用管程内的过程才能进入管程访问共享数据;
3.每次仅允许一个进程在管程内执行某个内部过程。 - 用管程解决生产者、消费者问题
monitor ProducerComsumer
condition full,empty;
int count = 0;
void insert(Item item) {
if (count == N) P(full);
count ++;
insert_item(item);
if (count == 1) V(empty);
}
Item remove() {
if (count == 0) P(empty);
count --;
if (count == N-1) V(full);
return remove_item();
}
end monitor;
//生产者进程
Producer () {
item = 生产一个产品;
ProducerConsumer.insert(item);
}
//消费者进程
consumer () {
item = ProducerConsumer.remove();
消费一个item;
}