Bootstrap

【操作系统】信号量解决经典同步问题


信号量机制是Dijkstra提出的一种卓有成效的进程同步工具。信号量有整形信号量、记录型信号量、AND型信号量等,这里主要介绍我们常见的记录型信号量。

1. 基本结构

typedef struct {
	int value;  //信号量值
	struct process_cntrol_block *list;  //阻塞队列
}semaphore;

在应用信号量的时候,信号量的值往往是临界资源的数量。当临界资源数量不足时,新的进程就阻塞,并插入到信号量阻塞队列中。

2. P,V操作

wait(S)和signal(S)操作是信号量机制的基本操作(通常称作P,V操作),定义如下:

wait(semaphore *S) {
	S->value--;  //信号量的值减一
	if(S->value < 0) block(S->list);  //如果信号量的值小于零,进程阻塞

signal(semaphore *S) {
	S->value++;  //信号量的值加一
	if(S->value <= 0) wakeup(S->list);  //唤醒
}

在实现进程同步时,一般先将S->value的初值设定为临界资源的初始数量,一旦进程要请求一个临界资源,先对代表此进程的信号量执行P操作,也就意味着资源数减一,当该进程运行完毕时,执行V操作,意味着资源数加一。值得注意的是,如果当前资源数量为0,再有进程想请求临界资源,就会在执行P操作时进入阻塞队列,信号量值变为-1,此后执行P操作的进程也都会被阻塞,信号量的绝对值为阻塞的进程数目。

3. 信号量的应用

3.1 信号量实现进程互斥

有了P,V操作,我们就能很简单的实现进程互斥。方法是:设mutex为互斥信号量,初值为1。在需要互斥的临界区前后分别使用P,V操作即可,示意代码如下:

semaphore mutex = 1;  //一般初值为1的信号量用来实现互斥
P_A() {  //进程A
	while(1) {
		P(mutex);
		临界区;
		V(mutex);
		剩余区;
	}
}
P_B() {  //进程B
	while(1) {
		P(mutex);
		临界区;
		V(mutex);
		剩余区;
	}
}

在互斥问题中,我们可以把P操作理解成“上锁”,把V操作理解成“解锁”,当P_A和P_B两个进程并发执行时,无论哪一个进程先执行到了P操作,都会接下来执行P操作的进程阻塞,直到前一个进程执行到了V操作为止,这样就实现了临界区的互斥。

3.2 信号量实现前驱关系

思路如下:为每一组想要实现前后关系的进程,都分别定义一个信号量,初值设为0,把你想要先执行的进程后面加一个V操作,想要后执行的进程前面加一个P操作。这样一来,你想要后执行的进程如果先执行了,就会因为执行了其前面的P操作而阻塞,直到你想要先执行的进程执行完了,执行V操作后,才能解除阻塞继续执行。以上就是用信号量实现前驱关系的过程。
假如我们要实现四个语句S1,S2,S3,S4需要按照一定的顺序同步执行,比如S1执行完S2,S3才能执行,S2,S3执行完了S4才能执行,代码如下:

p1() {S1; V(a); V(b);}
p2() {P(a); S2; V(c);}
p3() {P(b); S3; V(d);}
p4() {P(c); P(d); S4;}

main() {
	semaphore a,b,c,d = 0;
	cobegin
		p1();p2();p3();p4();
	coend
}

4. 用信号量解经典同步问题

4.1 生产者消费者问题

假定生产者和消费者之间的公用缓冲池有n个缓冲区,生产者可以向缓冲区生产一个产品,消费者可以从缓冲区消耗一个产品,注意:生产者不能同时生产,消费者也不能同时消费,也不能同时生产和消费,当缓冲区空时无法消费,当缓冲区满时无法生产。请用信号量机制解决这一问题。
解法如下:

semaphore mutex = 1;  //实现生产与消费、生产与生产、消费与消费
semaphore full = 0, empty = n;  //full表示已用缓冲区数,empty表示空缓冲区数
void producer() {
	while(1) {
		P(empty);  //每次生产会减少一个空缓冲区,如果没有空缓存区了就阻塞
	    P(mutex);  //保证互斥
	    生产一个产品;
	    V(mutex);  //保证互斥
	    V(full);  //每次生产增加一个已用缓冲区
	}
}
void consumer() {
	while(1) {
		P(full);  //每次消费会减少一个已用缓冲区,如果没有已用缓存区了就阻塞
	    P(mutex);
	    消耗一个产品;
	    V(mutex);
	    V(empty);  //每次生产增加一个空缓冲区
	}
}

4.2 读者写者问题

假设一个文件可被多个进程共享,我们允许多个进程同时读这个共享对象,但是不允许一个进程写这个共享对象的同时,别的进程进行读或写。换句话说:读和读不互斥,读和写互斥,写和写互斥。请用信号量机制解决这一问题。
解法如下:

semaphore rmutex, wmutex = 1; //wmutex用以实现写进程与其它进程的互斥
int readcount = 0;  //记录读进程的数量
void reader() {
	while(1) {
		P(rmutex);  //见下文解释
		if(readcount == 0) P(wmutex);  //第一个读进程去把写进程上锁
		readcount++;  
		V(rmutex);  //见下文解释
		读者读;
		P(rmutex);  //见下文解释
		readcount--;
		if(readcount == 0) V(wmutex);  //解锁
		V(rmutex);  //见下文解释
	}
}
void writer() {
	while(1) {
		P(wmutex);  //实现写进程与其他进程都互斥
		写者写;
		V(wmutex);
	}
}

这个问题的程序中有个关键点,就是明明读进程是不互斥的,为什么还需要rmutex来实现if判断的互斥呢。原因如下:当我们在进程互斥中使用条件语句和数值变化的时候,如果不把那一段也实现互斥的话,很有可能出问题,比如在这段代码中,要是不加第5行和第8行的话:如果一个读进程执行完了第6行,还没有执行第7行的readcount++操作之前,这个读进程被剥夺了处理机,换另一个读进程上处理机运行了,那么另一个读进程就会阻塞在第6行的P操作上,就无法实现多个读者一起读了;10行和13行同理,如果一个读进程执行完了第11行就被剥夺处理机,换上另一个读进程也执行了11行,这样readcount被连减两次,那么这两个进程都能通过12行的if判断,会执行两次V操作,产生错误。

4.3 狒狒过桥问题

一个主修人类学、辅修计算机科学的学生参加了一个课题,调查非洲狒狒是否能被教会理解死锁。他找到一处很深的峡谷,在上边固定了一根横跨峡谷的绳索,这样狒狒就可以攀住绳索越过峡谷。同一时刻可以有几只狒狒通过,只要它们朝着相同的方向。但如果向东和向西的狒狒同时攀在绳索上则将发生死锁(狒狒将被卡在中间),因为它们无法在吊在峡谷上时从另一只的背上翻过去。如果一只狒狒想越过峡谷,它必须看当前是否有别的狒狒正在逆向通过。使用信号量写一个避免死锁的程序来解决该问题。
解法如下:

semaphore wmutex, emutex, mutex = 1; 
int wcount, ecount = 0;  //分别记录东西狒狒上绳索的个数
void west_monkey {
	while(1) {
		P(wmutex);  //使if语句互斥
		if(wcount == 0) P(mutex);  //第一个西狒狒给东狒狒上锁
		wcount++;
		V(wmutex);  //使if语句互斥
		西狒狒过桥;
		P(wmutex);
		wcount--;
		if(wcount == 0) V(mutex);
		V(wmutex);
	}
}
void east_monkey {  //和上面的一样
	while(1) {
		P(emutex);
		if(ecount == 0) P(mutex);
		ecount++;
		V(emutex);
		西狒狒过桥;
		P(emutex);
		ecount--;
		if(ecount == 0) V(mutex);
		V(emutex);
	}
}

该问题属于读者写者问题的改进,如果完全理解了读者写者问题的解法,那么这个问题也能很快解决。东狒狒之间不互斥,西狒狒之间不互斥,东西狒狒之间互斥,思路是第一个东狒狒给西狒狒上锁,第一个西狒狒给东狒狒上锁,注意if判断也要实现互斥。

4.4 理发师理发问题

理发店里有一位理发师、一把理发椅和n把供等候理发的顾客坐的椅子。如果没有顾客,则理发师便在理发椅上睡觉。当一个顾客到来时,他必须先叫醒理发师,如果理发师正在理发时又有顾客来到,则如果有空椅子可坐,他们就坐下来等。如果没有空椅子,他就离开。这里的问题是为理发师和顾客各编写一段程序来描述他们的行为,要求不能带有竞争条件。
解法如下:

semaphore customer, barber = 0;  //一开始没有顾客,理发师也是睡着的
semaphore mutex = 1;  //互斥信号量
int empty = N;  //空椅子数量为N
void Barber() {
	while(1) {
		P(customer);  //只有顾客进程的V执行后才能执行,没有顾客就阻塞(睡觉)
		P(mutex);  //把数量的变化实现互斥,以免影响到顾客进程的if判断语句
		empty++;  //椅子上的顾客起身
		V(barber);  //有了一个理发师可以理发
		V(mutex);
		理发;
	}
}
void Customer() {
	P(mutex);
	if(empty > 0) {
		empty--;  //不管是不是第一个顾客,来了先得坐凳子上,因为理发师在理发椅上睡觉呢
		V(customer);  //增加一个顾客,唤醒沉睡的理发师
		V(mutex); 
		P(barber);  //消耗一个理发师,没有理发师就阻塞
		理发;
	} else {
		V(mutex);
		离开;
	}
}

这道题本身不难,但其中很多细节的实现需要实现,比如座位是有上限的,这样就不得不设置判断条件和计数,来使超过N个的顾客离开,而加入计数和判断条件后就又要实现其互斥,增加了问题的复杂性。

4.5 哲学家进餐问题

五个哲学家共用一张圆桌,分别坐在周围的五张椅子上,在桌子上有五只碗和五只筷子,他们的生活方式是交替地进行思考和进餐。平时,一个哲学家进行思考,饥饿时便试图取用其左右最靠近他的筷子,只有在他拿到两只筷子时才能进餐。进餐毕,放下筷子继续思考。请用信号量机制解决。
这是一个讲解死锁的时候的经典例子,解决这道题的直接思路如下:

semaphore chopstick[5] = {1,1,1,1,1};
void philosopher() {
	while(1) {
	/*当哲学家饥饿时,总是先拿左边的筷子,再拿右边的筷子*/
	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 philosopher() {
	while(1) {
		P(mutex);
        P(chopstick[i]);
		P(chopstick[(i+1)%5]);
		V(mutex);
		吃饭;
		V(chopstick[i]);
		V(chopstick[(i+1)%5]);
	}
}

但仅仅这样做还是有问题,如果一个哲学家获得了两只筷子,开始进餐,此时他左边或者右边的哲学家进入了临界区后,被阻塞在第6行或者第7行,那么其他的哲学家就无法进入临界区了,也就是说这个时间只能有一个哲学家进餐,但显然同一时间是可以有不相邻的两个哲学家同时进餐的。
对这个问题的更好解决方法可以去看Dijkstra在1965年给出的算法。

;