本文主要参考《计算机操作系统(第四版)》(西安电子科技大学出版社)以及清华大学操作系统公开课(向勇、陈渝),整理操作系统的基本概念,供自己复习查阅。
信号量机制
进程控制中最重要的一部分便是协调好进程的并发,控制进程同步,最具体的体现就是处理临界资源。信号量机制便广泛应用在临界资源处理方面。
信号量的分类与发展
信号量最初的定义是表示资源的数目。
整型信号量
顾名思义,这种信号量就是用一个整型量s表示某临界资源的数目。在初始化时,把s初始化为资源的数目,并限制只能通过原子操作wait(s)和signal(s)来访问,这两个操作通常也被称为P、V操作。可代码表示如下:
wait(s) {
while(s <= 0);
--s;
}
signal(s) {
++s;
}
记录型信号量
可以看到,在整型信号量中,只要申请不到资源,就会不断调试,并不符合“让权等待”原则(即让进程处于“忙等”状态),而记录型信号量就可以解决这个问题。
在记录型信号量中,简单的整型量s被扩充为记录型结构体,里面额外保存了一个进程表指针,用于链接所有等待进程。
struct ProcessControlBlock;
using PCB = ProcessControlBlock;
struct Semaphore {
int value; // 资源数目
PCB* processList;
};
wait(Semaphore *s) {
if(--s->value < 0) {
// 资源已被分配给其他进程,当前进程自我阻塞
// 并把其插入等待队列
block(s->processList);
}
}
signal(Semaphore *s) {
if(++s->value <= 0) {
// 条件为真说明有进程在等待队列中,唤醒队列的第一个进程
wakeup(s->processList);
}
}
可以看到,进程在申请不到资源时会进行自我阻塞,也就符合让权等待的原则。特殊地,在资源初始数量为一时就会转化为互斥信号量。
AND型信号量
进程互斥只是针对多进程共享一个临界资源的情况。在某些情况下,一个进程需要获得不止一个共享资源后才可以执行任务。这种情况下进程很容易发生死锁。如:若两个进程需要的临界资源一致但申请顺序不一致且交替申请,那么他们都得不到所有所需的临界资源,即陷入死锁。
针对这个问题,AND型信号量的解决方案是:将进程在整个运行过程中需要的所有资源一次性全部分配给进程,待进程使用完后再一起释放。
Swait(s1, s2, ..., sn) {
while(true) {
if(s1 >= 1 && s2 >= 1 && ... && sn >= 1) {
--s1; --s2; ...; --sn;
break;
} else {
wait();
}
}
}
Ssignal(s1, s2, ..., sn) {
while(true) {
++s1; ++s2; ...; ++sn;
}
}
信号量集
由于P、V操作只能对信号量进行加一和减一操作,当一次需要n个单位时显然是低效的,而且逐单位分配更容易引起死锁。为了系统安全,有些情况下我们需要管制资源,具体而言即当所申请的资源数d低于对应的下限值t时不予分配。
针对这个问题,信号量集的解决方案是:在进行P、V操作时需要知道进程对资源的需求量以及对应资源的下限值,并由此决定资源的分配。
Swait(s1, t1, d1, ..., sn, tn, dn);
Ssignal(s1, d1, ..., sn, dn);
三种特殊的信号量集:
Swait(s, 0, 0)
:信号量集中只有一个信号量,且下限值和允许申请数相同;Swait(s, 1, 1)
:此时的信号量集退化为记录型信号量;Swait(s, 1, 0)
:当s>0时,允许多个进程进入某个特定区,当s变为0时封锁区域,作用类似于开关。
信号量的应用
实现进程互斥
要实现某个资源的互斥访问,只需要为该资源设置一个互斥信号量mutex并设初值为1,结合P、V操作即可。
Semaphore mutex = 1;
// a进程P资源
Pa() {
while(true) {
wait(mutex);
// 临界区
signal(mutex);
// 剩余区
}
}
实现前趋关系
前趋图是一种描述进程前趋关系的有向无环图。
利用信号量可以描述进程之间的前趋关系。有前趋图如下:
那么可以用如下代码框架描述这个关系:
Semaphore a, b, c, d, e, f, g;
p1() { s1; signal(a); signal(b); }
p2() { wait(a); s2; signal(c); signal(d); }
p3() { wait(b); s3; signal(e); }
p4() { wait(c); s4; signal(f); }
p5() { wait(d); s5; signal(g); }
p6() { wait(e); wait(f); wait(g); s6; }
main() {
a.value = b.value = c.value = d.value = 0;
e.value = f.value = g.value = 0;
cobegin
p1(); p2(); p3(); p4(); p5(); p6();
coend
}
经典同步问题
生产者-消费者问题
有一群生产者进程在生产产品,并将产品提供给消费者进程消费。为了使生产者和消费者能并发执行,在两者之间设置了一个具有n个缓冲区的缓冲池。这也就暗含两个约束关系:消费者不能到一个空缓冲区取产品;生产者不能往一个满缓冲区投放产品。
利用记录型信号量即可解决这一问题。这里需要设置两个信号量:empty
和full
表示缓冲区的两个临界状态,用代码描述如下:
int in = 0, out = 0;
Product pool[n]; // 环形缓冲池
Semaphore mutex = 1, empty = n, full = 0;
void Producer() {
while(true) {
Produce(); // 生产产品
wait(empty); // 需要一个空的缓冲区容纳新产品
wait(mutex); // 请求锁
pool[in] = nextp; // 存放新产品
in = (in + 1) % n; // 偏移指针指向下一个可存放区域
signal(mutex); // 释放锁
signal(full); // 增加一个满缓冲区
}
}
void Consumer() {
while(true) {
wait(full); // 需要一个满缓冲区来取其中的产品
wait(mutex); // 请求锁
nextc = pool[out]; // 取出产品
out = (out + 1) % n; // 偏移指针指向下一个可取出区域
signal(mutex); // 释放锁
signal(empty); // 增加一个空缓冲区
Consume(); // 消费取出的产品
}
}
void main() {
cobegin
Producer();
Consumer();
coend
}
需要注意的是,对互斥信号量的申请一定要出现在对资源信号量的申请之后,否则可能引起死锁。显然这个问题也可以用AND型信号量解决。
int in = 0, out = 0;
Product pool[n];
Semaphore mutex = 1, empty = n, full = 0;
void Producer() {
while(true) {
Produce();
Swait(empty, mutex); // 要同时申请到空缓冲区和互斥信号量
pool[in] = nextp;
in = (in + 1) % n;
Ssignal(mutex, full); // 同时释放满缓冲区和互斥信号量
}
}
void Consumer() {
while(true) {
Swait(full, mutex);
nextc = pool[out];
out = (out + 1) % n;
Ssignal(mutex, empty);
Consumer();
}
}
哲学家进餐问题
有五个哲学家共用一张圆桌,桌上放着5个碗和5只筷子,哲学家只做两件事:思考、进餐。一个哲学家总是在思考,饥饿时也只能拿离他最近的两只筷子,拿到了才能进餐。进餐结束才继续思考。
利用记录型信号量可以解决这一问题。
Semaphore chopstick[5] = {1, 1, 1, 1, 1};
while(true) {
Think();
wait(chopstick[i]);
wait(chopstick[(i + 1) % 5]);
Eat();
signal(chopstick[i]);
signal(chopstick[(i + 1) % 5]);
}
但上面的做法会有一个问题:如果5位哲学家都处于饥饿状态,那么他们都会先去拿自己左手边的筷子,引起死锁。可以制定一些规则解决死锁问题。而用AND型信号量则不用考虑死锁问题。
Semaphore chopstick[5] = {1, 1, 1, 1, 1};
while(true) {
Think();
Swait(chopstick[i], chopstick[(i + 1) % 5]);
Eat();
Ssignal(chopstick[i], chopstick[(i + 1) % 5]);
}
读者-写者问题
一个数据文件可被多个进程共享,只对该文件做读操作的称为Reader进程,其他进程称为Writer进程。多个读进程可以同时读取文件,但当有写进程访问该文件时,其他进程(不管是读进程还是写进程)都不能访问该文件。这个问题常用于测试新的同步原语。
利用记录型信号量可以解决这个问题。
Semaphore rMutex = 1, wMutex = 1;
int readerCount = 0;
void Reader() {
while(true) {
wait(rMutex); // 申请读锁
if(readerCount == 0) wait(wRmutex); // 如果当前没有读者,那就需要锁写入
++readerCount;
signal(rMutex); // 由于读取可以同时进行,所以现在就释放读锁
Read();
wait(rMutex);
--readerCount;
if(readerCount == 0) signal(wMutex); // 没有读者才释放写锁
signal(rMutex);
}
}
void Writer() {
while(true) {
wait(wMutex);
Write();
signal(wMutex);
}
}
void main() {
cobegin
Reader();
Writer();
coend
}
如果文件要求最多可以被RN个读进程访问,那么用信号量集解决问题更方便。
int RN;
Semaphore L = RN, mutex = 1;
void Reader() {
while(true) {
Swait(L, 1, 1);
Swait(mutex, 1, 0);
Read();
Ssignal(L, 1);
}
}
void Writer() {
while(true) {
Swait(mutex, 1, 1, L, RN, 0);
Write();
Ssignal(mutex, 1);
}
}
void main() {
cobegin
Reader();
Writer();
coend
}