一、信号量
1.1 引入信号量
下列是一段最简单的生产者与消费者的代码:
二者通过 counter 来作为信号进行协作,但是这里存在一个问题,就是如果有两个生产者 P1 和 P2,当缓冲区满的时候,P1 和 P2 都被挂起,然后消费者消费了一个数据,P1 被唤醒,此时 counter 就小于 BUFFER_SIZE 了,所以 P2 可能永远不被唤醒。
导致上述问题的关键在于信号能够表示的信息太少了,它只表示了是否有进程正在被挂起,而没有表示当前挂起了几个进程,所以这里就引入了一个数据结构——信号量,用信号量来保存当前挂起的进程个数以及挂起的进程队列。
信号量的定义如下:
1.2 使用信号量
struct semaphore
{
int value; //记录资源个数
PCB *queue;
}
wait(semaphore *s){ //消费资源
s->value--;
if(s->value < 0) { //这里是小于 0,临界值代表之前的资源个数小于等于 0
add this process to s->queue;
block();
}
}
signal(semaphore *s) { //生产资源
s->value++;
if(s->value <= 0) { //这里是小于等于 0,临界值代表之前的资源个数小于 0
remove a process P from s->queue;
wakeup(P);
}
}
二、保护信号量——临界区
2.1 引入
上述提到的信号量用于进程间的协作,但是信号量可能是不安全的(如同经典的存款取款问题),所以要对信号量进行保护,即临界区问题——同时只能有一个进程修改共享变量。
为了实现临界区,关键在于进入和退出临界区执行的操作。
临界区代码的保护原则:
- 互斥:如果一个进程在临界区中执行,则其他进程不允许进入;
- 有空让进:如果临界区处于空闲,应该让一个进程尽快进入;
- 有限等待:从进程发出请求到允许进入,不能无限等待。
2.2 软件解决方案(一步一步推导)
2.2.1 轮换法
满足互斥,不满足有空让进,即如果 turn = 1,但是 P1 没有进入临界区,那么 P0 将无法进入。
2.2.2 标记法
标记法满足了有空让进,但是导致了另一个问题——如果 flag[0] 和 flag[1] 同时为 true,那么就陷入了无限等待。
2.2.3 重点:Peterson 算法(轮换法 + 标记法)
Peterson 算法的正确性:
互斥:turn 要么为 0,要么为 1;
有空让进:如果 P1 不在临界区,那么 flag[1] = false 或者 turn = 0,P0 能进入;
有限等待:P0 要求进入,flag[0] = true,当 P1 执行完之后,turn = 0,它将不能进入,P0 就得到了机会。
2.3 硬件解决方案
硬件的解决方案能简化编程任务并且提高系统效率。
通过特殊的硬件指令以允许原子地(不可中断地,即一个该 CPU 占用不会被调度到其他进程)检查和修改字的内容或交换两个字的内容(作为不可中断指令)。
所以信号量就是基于硬件实现原子操作的同步工具。
三、经典同步问题
3.1 生产者消费者问题
思路:生产者和消费者同时只能有一个进程修改缓冲区,所以用一个信号量 mutex 来实现互斥,初始化为 1;同时用信号量 empty 和 full 分别用来表示空缓冲项和满缓冲项的个数。empty 初始化为 n;full 初始化为 0;
实现:
//生产者
while(true) {
...
//生产一个 item
...
//消费一个空闲资源,如果没有资源,则加入到等待队列
wait(empty);
wait(mutex); //占领互斥锁,因为要修改缓冲区
...
//将item 加入到缓冲区中
...
signal(mutex); //释放互斥锁
signal(full); //释放一个 full 资源,如果有消费者进程等待,则该消费者能够前进
}
//消费者
while(true) {
//消费一个物品,如果没有,则加入等待队列
wait(full);
wait(mutex); //占领互斥锁,因为要修改缓冲区
...
//从缓冲区中移走一个物品 item
...
signal(mutex); //释放互斥锁
signal(empty); //释放一个 empty 资源,如果有生产者进程等待,则该生产者前进
...
//消费者消费该物品
...
}
3.2 读者-写者问题
问题分析:① 同时只能有一个写者; ②多个读者可以同时读; ③当写者在写时,读者不能读;④当读者在读时,写者不能写;
思路:用信号量 wrt 来实现只有一个写者;用 readcount 来计数读者的个数;用 mutex 来实现互斥
实现:
//写者
while(true) {
wait(wrt); //消费一个写者资源,如果有写者在写或者有读者在读,则该写者等待
...
//写入
...
signal(wrt); //释放一个写者资源,如果有写者或读者阻塞,则使阻塞进程前进
}
//读者
while(true) {
wait(mutex); //占领互斥锁,因为要修改 readcount
readcount++; //读者个数增加
if(readcount == 1) {
wait(wrt); //如果是第一个读者,则要等待写者写完或者占领锁来防止有写者进行写
}
signal(mutex); //释放互斥锁
...
//读取内容
...
wait(mutex); //该读者读完,readcount 就要减一
readcount--;
if(readcount == 0) {
signal(wrt); //如果没有读者了,则写者可以前进
}
signal(mutex);
}
存在的问题:可能导致写者或读者饥饿。
3.2 哲学家进餐问题
问题分析:这个问题与前面两个有所不同的是该问题是每个进程会消费两个资源,假设有 5 个哲学家,5 只筷子
思路:用信号量数组 chopsticks {1,1,1,1,1} 表示资源,i 表示第 i 个哲学家左手边的筷子,(i + 1) % 5 表示哲学家右手边的筷子
实现:
while(true) {
wait(chopsticks[i]); //左手边的筷子
wait(chopstacks[(i + 1) % 5]); //右手边的筷子
...
//哲学家得到两只筷子开始吃
...
signal(chopsticks[i]);
signal(chopstacks[(i + 1) % 5]); //释放资源
}
存在的问题:如果哲学家同时拿起了左手边的筷子,那么所有哲学家右手边的筷子就都没有了,会导致死锁
解决办法:① 桌子上最多坐四个哲学家;② 使用互斥锁来实现哲学家同时拿起左右两边的筷子;③ 非对称的解决办法,奇数位上的哲学家先拿起左边的筷子,再拿右边的筷子,偶数位上的哲学家先拿右边,后拿左边
四、管程
因为程序员自己写信号量的加锁与释放很容易出现问题,所以要将对共享变量和对共享变量的操作放放到一个结构中,这个结构我们称为管程。任何进程想要操作共享变量,必须使用管程内的方法。