系列文章
第一章 操作系统概述
第二章 进程管理之进程描述与控制
第二章 进程管理之进程调度
第二章 进程管理 同步
2.12 并发的原理
同步出现的意义
资源是有限的,进程是相互独立的,进程要协调完成任务,必须要求同步
并发相关
- 原子操作:由一个或多个指令序列实现的动作或函数,这组指令要么全部执行,要么全部不执行
- 临界资源:需要互斥访问的资源,如打印机
- 临界区:访问临界资源的代码段,必须互斥运行
- 互斥:当一个进程在临界区访问共享资源时,其他进程不能进入该临界区访问共享资源的情形
- 活锁:两个或两个以上进程为影响其他进程而持续改变自己的状态,但不做有用工作的状态(两个人面对面走进一条很窄的巷子,互相想谦让,但发现对方也在谦让,于是陷入一种奇妙的默契,两人都无法通过)
- 死锁:两个或两个以上的进程因等待其他进程做完某些事而不能继续执行的情形(去厕所,蹲位满了,你只能等,但蹲位里的人没有卫生纸,你给了他卫生纸你就不能上厕所所以不能给,他没有卫生纸所以不能结束使用蹲位)
- 竞争条件:多个进程或线程读写共享的数据时,结果取决于多个进程的指令执行顺序
- 忙等:进程等待进入临界区的过程中持续消耗cpu时间
- 饥饿:具备调度条件的进程得不到调度
2.12.1 并发控制的产生
进程执行相对速度不可预测,这给并发带来了困扰
共享全局变量很危险(没有互斥读写)想象一下两个人买了电影院的同一个位置
2.12.2 进程间三个交互方式
- 竞争:进程请求同一个资源(相互未知),这时并发需要注意互斥、死锁、饥饿问题
- 共享:多个进程共享变量、文件、数据库(相互已知),一个进程的结果可能取决于另一个进程的信息,这时需要注意互斥、死锁、饥饿和数据一致性
- 通信合作:通过通信完成同步和协调,不涉及访问共享资源,这时需要注意死锁、饥饿问题
2.13 互斥
要求
- 空闲让进:若空闲,申请即进
- 忙则等待:只允许临界区存在一个进程,若忙碌,区外等待
- 有限等待:进程等待的时间是有限的,不会造成死锁、饥饿
- 让权等待:进程不能在临界区长时间阻塞等待某事件
- 以上类比生活中任何公共资源都可,如公用电话
2.13.1 互斥:软件方法
思路
- 在进入区设置标志来判断是否有进程在临界区
- 若临界区已有进程,则循环等待
- 进程离开临界区后在退出区修改标志
第一代:轮换使用临界区
每个进入临界区的进程的权限只能被另一个进程赋予
int turn=0;
//进程P0
do{
while(turn!=0);//进入区
P0代码;//退出区
turn=1;//退出区
}while(true)
//进程P1
do{
while(turn!=1);//进入区
P1代码;//临界区
turn=0;//退出区
}while(true)
严格轮换,实现了互斥访问
违反了空闲让进的原则:比如turn=1时,临界区空,但P1无法进入
第二代:设置临界区状态标志
进入之前看有没有其他进程要进入,没有则将自己的状态置为true
boolean flag[2]={false,false}//该全局变量标志临界区空闲与否
//进程P0
do{
while(flag[1]);//进入区
flag[0]=true;//进入区
P0代码;//临界区
flag[1]=false;//退出区
}while(true)
//进程P1
do{
while(flag[0]);//进入区
flag[1]=true;//进入区
P1代码;//临界区
flag[1]=false;//退出区
}while(true)
违反了忙则等待原则:假设CPU先将时间片分给P0,然后P0判断flag[1]=false跳出循环向下执行,这时CPU又将时间片分给 P1,P0尚未执行到置flag[0]=true;P1判断flag[0]=false跳出循环向下执行,两者都将进入临界区
注意:while(flag[1]);这一句表明:当flag[1]=true时停在这一句,flag[1]=false时跳出循环执行下一句,P0即进入临界区
第三代:表明使用临界区的状态
先亮明状态,再循环等待
boolean flag[2] = {false, false};//共享的全局变量
//进程P0
do {
flag[0] = true; //进入区
while (flag[1]) ;//进入区
进程P0的临界区代码; //临界区
flag[0] = false; //退出区
进程P0的其它代码 //剩余区
} while (true)
//进程P1
do {
flag[1] = true; //进入区
while (flag[0]) ;//进入区
进程P1的临界区代码; //临界区
flag[1] = false; //退出区
进程P1的其它代码 //剩余区
} while (true)
违反了空闲让进原则:CPU先将时间片分给P0,然后P0置flag[0]=true,这时CPU又将时间片分给 P1,P1置flag[1]=true;那么接下来两者都在while循环处等待对方清零,都无法进入临界区,形成死锁
第四代:表明使用临界区的态度+谦让
预先表明想进入临界区,但为了防止死锁,在进入临界区前会延迟一段时间
boolean flag[2] = {false, false};//共享的全局变量
//进程P0
do {
flag[0] = true;
while (flag[1]) {
flag[0] = false;
<随机延迟一小段时间>;//谦让
flag[0] = true;
}
进程P0的临界区代码; //临界区
flag[0] = false; //退出区
进程P0的其它代码 //剩余区
} while (true)
//进程P1
do {
flag[1] = true;
while (flag[0]) {
flag[1] = false;
<随机延迟一小段时间>;//谦让
flag[1] = true;
}
进程P1的临界区代码; //临界区
flag[1] = false; //退出区
进程P1的其它代码 //剩余区
} while (true)
可能会活锁,过分谦让,长时间僵持:CPU先将时间片分给P0,然后P0置flag[0]=true,这时CPU又将时间片分给 P1,P1置flag[1]=true;接下来CPU先将时间片分给P0,P0发现flag[1]=true;进入while循环,先将flag[0]清零,再挂起一段时间,重新置位flag[0],查看flag[1]是否仍为1,若是则继续清零等待,重复这个过程,监听flag[1]的变化;同理,P1可能会与P0默契地进行同样的活动,两者同时监听,同时挂起,形成活锁,可能随时间自动解除
Dekker互斥算法
由于前一种算法活锁的原因是只监听了对方的flag,这时添加一个只能由对方改变的信息turn即可
boolean flag[2] = {false, false}; //共享的全局变量
int turn = 1; //共享的全局变量
//进程P0
do {
flag[0] = true; //进入区
while (flag[1]) {
if (turn == 1) {
flag[0] = false;
while (turn == 1) ; //等待
flag[0] = true;
}
} //进入区
进程P0的临界区代码; //临界区
turn = 1;
flag[0] = false; //退出区
进程P0的其它代码 //剩余区
} while (true)
//进程P1
do {
flag[1] = true; //进入区
while (flag[0]) {
if (turn == 0) {
flag[1] = false;
while (turn == 0) ; //等待
flag[1] = true;
}
} //进入区
进程P1的临界区代码; //临界区
turn = 0;
flag[1] = false; //退出区
进程P1的其它代码 //剩余区
} while (true)
在预先表明态度+谦让的基础上改进了谦让机制:
P1在退出临界区时会置turn=0;P0退出临界区会将turn置为1
这样一来,可以在监听对方flag时知道对方是否已经退出临界区,而不是等待一段随机时间,我认为这时重新尝试将自己的flag置为true是更好的做法,不会产生活锁
Peterson互斥算法
与dekker算法的区别:不是在退出时而是在进入前将turn改变
boolean flag[2] = {false, false}; //共享的全局变量
int turn; //共享的全局变量
//进程P0
do {
flag[0] = true; //进入区
turn = 1; //进入区
while (flag[1] && turn == 1) ; //进入区
进程P0的临界区代码; //临界区
flag[0] = false; //退出区
进程P0的其它代码 //剩余区
} while (true)
进程P1
do {
flag[1] = true; //进入区
turn = 0; //进入区
while (flag[0] && turn == 0) ; //进入区
进程P1的临界区代码; //临界区
flag[1] = false; //退出区
进程P1的其它代码 //剩余区
} while (true)
假设P0进程先置自己的flag为true,改变turn=1,此时有一个while循环,当P1的flag=false时,说明P1还没有运行到置flag=true的行,可以继续进入临界区,或当turn=0时,这时说明P1执行后转而执行P0,P0此时在turn行,但P0无法进入临界区,因为此时flag[0]=true,而此时P0可以无视flag[1]=true的条件而继续进入临界区
评价软件互斥方法
- 不能解决忙等问题(使用while监听方法),效率低
- 进程互斥使用临界资源困难,难以控制两个以上进程的互斥
- 算法设计时容易死锁或互斥失败
2.13.2 互斥:硬件方法
中断禁用(屏蔽中断)
- 用于单CPU系统
- 使用屏蔽中断的方法,屏蔽时无法切换进程,达到进程互斥的目的
while (true ) {
disable interrupt //屏蔽中断
critical section //临界区
enable interrupt //启用中断
remainder //其余部分
}
评价
- 显而易见的效率低
- 屏蔽期间无法响应外部请求
- 无法切换进程
- 无法工作在多处理器环境
专用机器指令
- 处理器设计者指定的两种机器指令,在一个指令周期中执行,不会被打断和受到其他指令干扰
- 也可用于多处理器环境
比较一个内存单元的值和测试值,相等则交换
int compare_and_swap(int *word, int testval, int newval){
int oldval;
oldval = *word;
if(oldval == testval) *word = newval;
return oldval;
}
原子性交换寄存器和内存的值
procedure exchange(var r: register; var m: memory);
var temp;
begin
temp := m;
m := r;
r := temp;
end
评价
优点
- 支持多处理器
- 简单易证明
- 支持多临界区
缺点
- 存在忙等
- 可能出现饥饿
- 可能出现死锁
2.14 信号量(重点)
进程通过传递信号进行协作
- 进程因为某个条件阻塞,不能继续推进
- 进程因为某个条件被唤醒,可以继续推进
可以实现信号灯作用的整数变量:信号量
信号量的三种操作
- 初始化:初始化为非负数,代表某种初始状态
- 自增semSignal(操作代号:V):该操作使信号量+1,若此时信号量仍<=0,唤醒被P操作阻塞的进程
- 自减semWait(操作代号:P):该操作使信号量-1,若此时信号量<0,则阻塞执行P该操作的进程
2.14.1 信号量分类
struct binary_semaphore{
enum { zero, one } value;
queueType queue;//内置队列
};
void semwaitB(binary_semaphore s){//P操作
if(s.value == one)
s .value = zero;
else { //此时阻塞
place this process in s.queue;//当前进程加入阻塞队列
block this process;//不是忙等,不占cpu时间,等待唤醒
}
}
void semsignalB(semaphore s){//V操作
if( s.queue is empty())
s .value=one;
else {
remove a process P from s.queue;//从阻塞队列取出进程
place process P on ready list;//唤醒阻塞进程
}
}
- 计数信号量:即一般信号量,信号量取值范围更大
struct semaphore {
int count;
queueType queue;
};
void semwait ( semaphore s ){//P操作
s.count--;
if ( s.count <0 ){
place this process in s.queue;
block this process;
}
}
void semsignal ( semaphore s){//V操作
s.count++;
if ( s.count <= 0 ){
remove a process P from s.queue;
place process P on ready list;
}
}
2.14.2 信号量内部的队列
- 强信号量:进程按照FIFO移出
- 弱信号量:不规定进程移出的顺序
2.14.3 使用信号量解决互斥问题
进程不能进行P操作时需要等待另一个进程的V操作,实现互斥
/* program mutualexclusion */
const int n = / *number of processes */;
semaphore s = 1 ;//初始化
void P(int i){
while (true){
semwait(s);//P操作:semaphore=1,可以--
/*访问临界区*/;
semsignal(s);//V操作:semaphore++
/*remainder*/;
}
}
void main (){
parbegin (P( 1),P( 2), . . ., P(n) ) ;
}
2.14.4 信号量的实现
- semWait、semSignal作为原语实现
- 任意时刻仅有一个进程使用PV操作修信号量
- 可以使用硬件实现
- 可以使用前面的机器指令实现
semWait (s){P操作
while (compare_and_swap(s.flag,0,1) == 1)//指令作用:s.flag=0?s.flag=1:;循环作用:s.flag=1跳出循环
/ *do nothing */;
s.count--;
if (s.count < 0) {
place this process in s.queue;
block this process (must also set s.flag to 0);
}
s.flag = 0;
}
semSignal(s){//V操作
while (compare and swap(s.flag, 0,1) == 1)//指令作用:s.flag=0?s.flag=1:;循环作用:s.flag=1跳出循环
/* do nothing */;
s.count++;
if (s.count <= 0){
remove a process P from s.queue;
place process P on ready list;
}
s.flag = 0;
}
- s.count ≥ 0, s.count 表示执行semWait(s)操作而不被阻塞的进程数(可看作可用资源数)。这种情形信号量可支持同步与互斥。
- s.count < 0, s.count 表示阻塞在s.queue队列上的进程数。
现在信号量包含一个新的整数元素,s.flag,诚然,这可能导致一种新的忙等。然而,semWait和semSignal操作相对较短,所以涉及的忙等待量应该不大。对于单处理器系统,可以在semWait或semSignal操作的持续时间内屏蔽中断,这些操作的持续时间相对较短,意味着这种方法是合理的。
2.15 生产者消费者问题
Dijkstra对同步问题的抽象
抽象解释
- 一个或多个生产者生产资源,放入临界区
- 一次仅有一个消费者访问临界区并消耗资源
- 每次仅有一名生产者/消费者访问临界区
- 临界区满时生产者不会继续添加资源
- 临界区空时消费者不会继续消耗资源
流程图
要使用一个空间信号量告知生产者有无空间
要使用一个资源信号量告知消费者有无资源
2.15.1 生产者消费者问题要点
- 先申请资源,再申请互斥,顺序不可颠倒,否则假设消费者P0申请了互斥后,等待资源,生产者P1需要生产资源,却无法申请互斥进入临界区,形成死锁
- 同一个进程中的空间、资源semSignal语句可以颠倒,因为先释放互斥和先释放资源没有影响,另一个进程都需要在两个信号量上等待,但是可能会降低性能,所以一般先释放资源再释放互斥
- 同一个信号量的wait和signal可能在不同进程,如资源信号量,也可能在同一进程,如互斥信号量,但必须成对
- 对于互斥信号量,wait一定先于signal,资源信号量则有可能先signal
2.15.2 生产者消费者问题示例
示例1
情境描述
- 桌子上有一只盘子,最多能放N(N>0)个水果(资源空间N)
- 爸爸随机向盘子中放入苹果或橘子(一个生产者)
- 儿子只吃橘子,女儿只吃苹果(两个消费者)
- 没有自己想吃的水果就不吃(有各自资源信号量时消费者可以消耗)
- 盘子满了爸爸就不放水果(空间信号量有限)
- 每次只能放入或取出一个水果,不允许同时使用盘子(互斥使用临界区)
用信号量机制实现爸爸、儿子和女儿之间的同步与互斥活动,并说明所定义信号量的含义。要求用伪代码描述。
分析
- 一个生产者,针对两个消费者生产两种不同资源,共用一个大小为N的临界区
- 盘子是临界区,需要互斥信号量mutex
- 爸爸和儿子因为orange的产生和消耗而同步:先产生后消耗
- 爸爸和女儿因为apple的产生和消耗而同步:先产生后消耗
- 爸爸和儿子、女儿共享临界区,需要空间信号量empty
- 信号量:
- empty:盘子是否为空
- orange:有无资源orange
- apple:有无资源apple
- mutex:互斥信号量
伪代码实现
semaphore mutex = 1; //盘子操作互斥信号量
semaphore apple = 0, orange = 0; //苹果、桔子放入、取出的资源信号量
semaphore empty = N; //盘子中可放入的水果数目
P1()
{
while (true) {
result= prepare _fruit(); //准备水果,result为水果类型
P(empty); //盘子中可放入的水果数目减1
P(mutex); //互斥访问盘子
put a fruit on the plate; //将一个水果放入盘子
V(mutex); //恢复访问盘子
if (result == apple) //准备的水果为苹果
V(apple); //允许女儿取苹果
else //准备的水果为桔子
V(orange); //允许儿子取桔子
}
}
son() {
while (true) {
P(orange); //判断是否可取桔子
P(mutex); //互斥访问盘子
get an orage from plate(); //取桔子
V(mutex); //恢复访问盘子
V(empty); //盘子中可放入的水果数目加1
}
}
daughter() {
while (true) {
P(apple); //判断是否可取苹果
P(mutex); //互斥访问盘子
get an apple from plate(); //取苹果
V(mutex); //恢复访问盘子
V(empty); //盘子中可放入的水果数目加1
}
}
示例2
情境描述
- 桌子上有一只盘子,只能放一个水果(资源空间1)
- 爸爸向盘子中放入苹果,妈妈向盘子中放入橘子(两个生产者)
- 儿子只吃橘子,女儿只吃苹果(两个消费者)
- 没有自己想吃的水果就不吃(有各自资源信号量时消费者可以消耗)
- 只有盘子为空时爸爸或妈妈才能放入水果(空间信号量有限)
- 每次只能放入或取出一个水果,不允许同时使用盘子(互斥使用临界区)
用信号量机制实现爸爸、妈妈、儿子和女儿之间的同步与互斥活动,并说明所定义信号量的含义。要求用伪代码描述。
分析
- 两个生产者,针对两个消费者生产两种不同资源,共用一个大小为1的临界区
- 盘子是临界区,需要资源信号量plate
- 爸爸和女儿因为apple的产生和消耗而同步:先产生后消耗
- 妈妈和儿子因为orange的产生和消耗而同步:先产生后消耗
- 信号量:
- plate:盘子是否为空
- orange:有无资源orange
- apple:有无资源apple
#####为什么不需要互斥信号量
因为不需要
- 在示例一中,空间可以容纳多个资源,假设空间为5,当现存3个资源时,生产者和消费者可能同时访问临界区,一个是去放新的资源,一个是去取旧的资源,这时需要空间和互斥两个信号量
- 而在示例二中现存0个资源时只有生产者可以访问临界区,现存1个资源时只有消费者可以访问临界区,所以在生产者进入临界区,还未使得资源信号量++时,消费者不是因为互斥无法进入,而是因为没有资源可取,只有当生产者出临界区后才会请求进入临界区取新生产的资源,消费者进入临界区时同理,生产者也不会进入临界区
- 所以不需要互斥
伪代码实现
semaphore plate = 1; //是否允许向盘子放入水果
semaphore apple = 0, orange = 0; //盘子中是否有苹果、桔子
dad() {
while (true) {
prepare an apple;
P(plate); //互斥向盘子放水果
put an apple on the plate; //将苹果放入盘中
V(apple); //允许取苹果
}
}
mom() {
while (true) {
prepare an orange;
P(plate); //互斥向盘子放水果
put an orange on the plate; //将桔子放入盘中
V(orange); //允许取桔子
}
}
son()
{
while (true) {
P(orange); //互斥取水果
get an orange from the plate; //从盘中取出桔子
V(plate); //允许向盘中放入水果
}
}
daughter()
{
while (true) {
P(apple); //互斥取水果
get an apple from the plate; //从盘中取出苹果
V(plate); //允许向盘中放入水果
}
}
示例3
情境描述
- 桌子上有一只盘子,最多放两个水果(资源空间2)
- 爸爸向盘子中放入苹果,妈妈向盘子中放入橘子(两个生产者)
- 女儿负责吃水果(一个消费者)
- 当且仅当盘子同时存在苹果和橘子时女儿才会取出水果(有资源信号量时消费者可以消耗)
- 只有盘子为空时爸爸或妈妈才能放入水果(空间信号量有限)
- 每次只能放入或取出一个水果,不允许同时使用盘子(互斥使用临界区)
用信号量机制实现爸爸、妈妈、女儿之间的同步与互斥活动,并说明所定义信号量的含义。要求用伪代码描述。
分析
- 两个生产者,生产两种不同资源,共用一个大小为2的临界区
- 爸爸和女儿因为apple的产生和消耗而同步:先产生后消耗
- 妈妈和儿子因为orange的产生和消耗而同步:先产生后消耗
- 盘子是否可以放入苹果需要判断:apple_empty
- 盘子是否可以放入橘子需要判断:orange_empty
- 信号量:
- apple_empty:是否可以放apple
- orange_empty:是否可以放orange
- orange:有无资源orange
- apple:有无资源apple
#####为什么不需要互斥信号量
因为不需要
- 在示例一中,空间可以容纳多个资源,假设空间为5,当现存3个资源时,生产者和消费者可能同时访问临界区,在示例二中现存0个资源时只有生产者可以访问临界区,现存1个资源时只有消费者可以访问临界区,所以在生产者进入临界区,还未使得资源信号量++时,消费者不是因为互斥无法进入,而是因为没有资源可取,只有当生产者出临界区后才会请求进入临界区取新生产的资源
- 而在示例三中看似是一个空间,实际上是两个独立的大小为1的空间,因为大小为2的空间却只能放1个苹果和一个橘子,不能放两个相同的水果,就相当于两个盘子,每个盘子只能放一种水果,两个盘子的水果不能相同,参考示例二,这种情况也不需要互斥
伪代码实现
semaphore apple = 0, orange = 0; //盘子中是否有苹果、桔子
semaphore empty_apple = 1, empty_orange = 1; //盘子是否可放入苹果、桔子
dad(){
while (true) {
prepare an apple;
P(empty_apple); //盘子中是否可放入苹果
put an apple on the plate; //将一个苹果放入盘子
V(apple); //允许女儿取苹果
}
}
mom(){
while (true) {
prepare an orange;
P(empty_orange); //盘子中是否可放入桔子
put an orange on the plate; //将一个桔子放入盘子
V(orange); //允许女儿取桔子
}
}
daughter() {
while (true) {
P(apple); //盘子中是否有苹果
P(orange); //盘子中是否有桔子
get an apple and an orange from plate(); //取水果
V(empty_apple); //盘子中可以放入苹果
V(empty_orange); //盘子中可以放入桔子
}
}
示例4
情境描述
- 女儿负责画画(一个生产者)
- 爸爸、妈妈负责欣赏(两个消费者)
- 女儿在白板上画完一幅画后,请爸爸、妈妈均欣赏过一遍后,再创作新画
- 一个生产者进程和两个消费者进程共享大小为1的缓冲,当且仅当缓冲为空时,生产者进程负责放入数据,当且仅当缓冲有数据时,消费者读数据,只有当两个消费者都读取数据后,生产者才能删除原有数据并继续生产下一个数据。
用信号量机制实现爸爸、妈妈、女儿之间的同步与互斥活动,并说明所定义信号量的含义。要求用伪代码描述。
分析
- 一个生产者和两个消费者,一幅画要分别给两人看
- 可看作一个生产者每次要针对不同消费者生产两个数据
- 两个数据都被消费后才能继续生产
- 爸爸是否欣赏过,设置空间资源信号量empty_dad
- 爸爸是否可以欣赏,设置产品资源信号量full_dad
- 妈妈是否欣赏过,设置空间资源信号量empty_mom
- 妈妈是否可以欣赏,设置产品资源信号量full_mom
- 信号量:
- empty_dad:爸爸是否欣赏过
- empty_mom:妈妈是否欣赏过
- full_dad:爸爸是否可以欣赏
- full_mom:妈妈是否可以欣赏
伪代码实现
semaphore empty_dad = 1, empty_mom = 1; //爸爸、妈妈是否已看过女画的新画
semaphore full_dad = 0, full_mom = 0; //是否存在可供爸爸、妈妈看的新画
daughter(){
while (true) {
P(empty_dad); //爸爸是否看过
P(empty_mom); //妈妈是否看过
draw a new picture on the whiteboard; //画一幅新画
V(full_dad); //爸爸可以看了
V(full_mom); //妈妈可以看了
}
}
dad() {
while (true) {
P(full_dad); //白板上是否存在没有看过的画
enjoy the picture on the whiteboard; //看画
V(empty_dad); //爸爸已看过新画
}
}
mom() {
while (true) {
P(full_mom); //白板上是否存在没有看过的画
enjoy the picture on the whiteboard; //看画
V(empty_mom); //妈妈已看过新画
}
}
2.16 读者写者问题
抽象解释
- 多个进程访问一个共享的数据区
- 读者(读进程)只能读数据,写者(写进程)只能写数据
- 适用于数据库、文件、内存、寄存器等数据区的访问模型
- 如12306购票系统,由于用户量庞大和数据量巨大,不可避免地会出现多个进程同时查询(读)或修改(写)同一条数据的情况
2.16.1 读者写者问题的三个约束条件
- 读者之间不互斥(你和室友可以同时查看元旦那天早上7点的高铁信息)
- 写者之间必须互斥(你和室友不能同时申请购买元旦那天早上七点G1234的6D号位置)
- 读者写者之间也互斥(假如你正在买G1234的6D座位,你的室友无法读到该位置的售票信息)
2.16.2 读者写者问题VS生产者消费者问题
不能
像生/消问题那样严格互斥读者写者虽然可以保证数据的正确,却无法实现同时读的特点
- 生产者消费者问题:数据消费后就没有了;读者写者问题:数据可多次读
- 生产者消费者问题:消费者彼此互斥;读者写者问题:读者可以同时读
2.16.3 解决读者写者问题的三种方案
策略一 读者优先
- 有读者在读的话,随后的读者可以进入临界区一起读
- 待临界区全部读者退出后再轮到下一个写者
- 有越过写者的现象,造成写者饥饿
变量设置
- wsem:互斥信号量
- readcount:统计同时读的readers
- x:对readcount的修改加锁
伪代码
int readcount=0;
semaphore x = 1, wsem=1;
void reader() {
while (1) {
P(x);
readcount++;
Ⅰ if (readcount==1) P(wsem); //第一个读者会执行此if语句,随后的会跳过P(wsem)直接进入临界区
Ⅱ V(x);
READ;
P(x);
readcount--;
Ⅲ if (readcount==0) V(wsem);//最后退出的读者执行此语句,释放临界区权限
Ⅳ V(x);
}
}
void writer() {
while (1) {
P(wsem);
WRITE;
V(wsem);
}
}
读者优先的相关思考
- Ⅰ、Ⅱ语句能否互换?
答:不能,readcount的判断一定要在锁内,否则会出现读写不互斥:假设此时写者在临界区,第一个读者到来后readcount++,阻塞在P(wsem),第二个读者到来后直接可以修改count++,导致跨过P(wsem)直接进入临界区,读写不互斥。这时失去的条件是:先有一个读者进入临界区时等其进入后后面的读者才能修改count。 - Ⅲ、Ⅳ语句能否互换?
答:不能,交换后考虑最后一个进程退出时又到来一个读者进程的情况:交换后readcount=0,尚未判断if时,读者仍会进入临界区,判断后释放临界区权限,写者也进入临界区,这时读写不互斥。这时失去的条件是:最后一个读者退出临界区时等其退出后才能修改count - 考虑如下进程序列(设序列中从右到左为进程先后到达顺序),下列哪一种情况下可能存在写者饥饿
1.R R R
2.W W W
3.R W
4.R R W
5.R R W R
6.W W R
7.W R R W
答:5.RRWR时有一个R先到临界区,后面的读者进程在等待时会越过P(wsem)直接进入临界区,也就是越过了W,造成写者饥饿
策略二 公平优先
- 有读者在读的话,下一个写者之前的读者可以进入临界区一起读
- 写过程中,其他到来的进程按顺序处理
- 没有越过写者的现象,不会造成写者饥饿
变量设置
- wsem:互斥信号量
- readcount:统计同时读的readers
- x:对readcount的修改加锁
- wrsem:互斥信号量,reader和writer在这里排队,用处是在读者优先的基础上,不让读者越过写者
伪代码
int readcount=0, semaphore x=l, wrsem=1, wsem=l;
void reader() {
while (true) {
P(wrsem);
P(x);
readercount++;
if (readercount == 1)
P(wsem);
V(x);
V(wrsem);
READ;
P(x);
readercount--;
if (readercount == 0)
V(wsem);
V(x);
}
}
void writer() {
while (true) {
P(wrsem);
P(wsem);
WRITE;
V(wsem);
V(wrsem);
}
}
与读者优先相比
在读者优先中,wsem只对第一个读者起阻塞作用,后续读者不受其影响。为了保证按照到达顺序处理,故公平优先方式设置wrsem,读者/写者按到达顺序在wrsem上排队。
策略三 写者优先
- 有写者声明要写时,不允许读者再进入临界区
- 降低了并发度
- 有越过读者的现象,会造成读者饥饿
变量设置
- rsem:互斥信号量,当至少有一个写者申请写数据时,互斥新的读者进入读数据
- 第一个写者受rsem影响,一旦有第一个写者,后续写者不受rsem其影响。但是读者需要在rsem上排队
- writecount:用于控制rsem对写者的控制
- y:对writecount的修改加锁
伪代码
int readcount = 0, writecount = 0;
semaphore x=l, y= 1, wsem=1, rsem=l;
void reader( ) {
while (1) {
P(rsem);
P(x);
readcount++;
if (readcount ==1) P(wsem);
V(x);
V(rsem);
READ;
P(x);
readcount--;
if (readcount ==0) V(wsem);
V(x);
}
}
void writer( ) {
while (1) {
P(y);
writecount++;
if (writecount ==1) P(rsem);
V(y);
P(wsem);
WRITE;
V(wsem);
P(y);
writecount--;
if (writecount==0) V(rsem);
V(y);
}
}
与公平优先相比
在公平优先中,读者和写者都会受到wrsem的限制,而写者优先中,由于增加了if (writecount ==1) P(rsem)的语句,当写者到来时会比读者优先进入临界区,但WRRRR的情况写者不会优先
策略四 写者优先改进
- 为了解决WRRRR中W不能优先的问题
变量设置
- 沿用写者优先的变量
- 增加z信号量,仅对写者有作用,让读者队列在z上排队,只有一个读者在rsem上排队
伪代码
int readcount=0,writecount=0;
semaphore x=l, y= 1,z=1,wsem=1,rsem=l;
void reader( ) {
while (1) {
P(z);
P(rsem);
P(x);
readcount++;
if (readcount ==1) P(wsem);
V(x);
V(rsem);
V(z);
READ;
P(x);
readcount--;
if (readcount ==0) V(wsem);
V(x);
}
}
void writer( ) {
while (1) {
P(y);
writecount++;
if (writecount ==1) P(rsem);
V(y);
P(wsem);
WRITE;
V(wsem);
P(y);
writecount--;
if (writecount==0) V(rsem);
V(y);
}
}
写者优先改进方法的思考
- z信号量起到了什么作用?
- 在rsem上不允许建造读进程的长队列,否则写进程将不能跳过这个队列.
- 允许一个读进程在rsem上排队,其他读进程在信号量z上排队
- P(z)和P(rsem)能否互换位置?
不能, 否则P(z)失去限制作用
2.16.4 读者写者问题示例
示例一
情境描述
- 东西方向的独木桥,仅容一人过,不允许停留
- 东西方向都有人在等着过桥
请用P、V操作来实现东西两端行人过桥问题
分析
- 简单的写者互斥问题
- 多个写者共享数据
- 行人都是写者,桥是数据
- 信号量
- s:互斥信号量
伪代码
semaphore s = 1; //互斥信号量
void east_west( )
{
while (true) {
P(s); //互斥其他人过桥
walk across the bridge from east to west; //行人从东向西过桥
V(s); //允许其他人过桥
}
}
void west_east( )
{
while (true) {
P(s); //互斥其他人过桥
walk across the bridge from west to east; //行人从西向东过桥
V(s); //允许其他人过桥
}
}
示例二
情境描述
- 东西方向的独木桥,仅容一人过,不允许停留
- 东西方向都有人在等着过桥
- 同一方向的行人可以连续过桥,另一方的行人必须等待
请用P、V操作来实现东西两端行人过桥问题
分析
- 读者优先问题
- 行人首先上桥的一方为读者,另一方为写者,桥是数据
- 信号量
- x:互斥信号量,互斥读者写者
- countR:统计读者数目(同时在桥上的行人数目)
- mutexR:对变量countR加锁
伪代码
int countA=0, countB=0;
semaphore x=1, mutexA=1, mutexB=1;
void east_west() {
while (1) {
P(mutexA);
countA++;
if (countA==1) P(x);
V(mutexA);
walk across the bridge from east to west;
P(mutexA);
countA--;
if (countA==0) V(x);
V(mutexA);
}
}
void west_east() {
while (1) {
P(mutexB);
countB++;
if (countB==1) P(x);
V(mutexB);
walk across the bridge from west to east;
P(mutexB);
countB--;
if (countB==0) V(x);
V(mutexB);
}
}
示例三
情境描述
- 东西方向的独木桥,不允许停留
- 东西方向都有人在等着过桥
- 同一方向的行人可以连续过桥,另一方的行人必须等待
- 桥最大可承重四人
请用P、V操作来实现东西两端行人过桥问题
分析
- 读者优先问题
- 行人首先上桥的一方为读者,另一方为写者,桥是数据
- 与示例二不同在于不用一个一个过桥,只要桥没到承重限度就可以进入,这里承重限度就是临界区大小
- 信号量
- x:互斥信号量,互斥读者写者
- countR:统计读者数目(同时在桥上的行人数目)
- mutexR:对变量countR加锁
- count: 位于独木桥上的行人数目
伪代码
int countA=0, countB=0;
semaphore x=1, muteA=1, mutexB=1,count=4;
void east_west() {
while (1) {
P(mutexA);
countA++;
if (countA==1) P(x);
V(mutexA);
P(count);
walk across the bridge from east to west;
V(count);
P(mutexA);
countA--;
if (countA==0) V(x);
V(mutexA);
}
}
void west_east() {
while (1) {
P(mutexB);
countB++;
if (countB==1) P(x);
V(mutexB);
P(count);
walk across the bridge from west to east;
V(count);
P(mutexB);
countB--;
if (countB==0) V(x);
V(mutexB);
}
}
2.16.5 理发师睡觉问题
情境描述
- 理发店有一把理发椅子,五把等候椅子
- 无顾客时理发师睡觉,有则工作
- 顾客有空等候椅子则等候,否则离开
请用P、V操作来实现理发师睡觉问题
分析
- 同步过程:
- 理发师:有无顾客
- 顾客:有无理发椅子
- 客人:
- 判断有无空闲的等待椅子
- 等待顾客++
- 空闲椅子–
- 有无空闲理发椅子
- 空闲椅子++
- 唤醒理发师
- 理发
- 释放理发椅子
- 等待顾客–
- 顾客离开
伪代码
int n_customer= 0; //店里的顾客,含正在理发的人数
semaphore mutex = 1; //waiting的互斥信号量
semaphore bchair = 1; //理发椅的个数
semaphore wchair = 5; //空椅子的个数
semaphore ready = 0; //是否有顾客准备好
semaphore finish = 0; //理发师是否完成理发
main() { cobegin baber(); customer(); coend }
void baber() //理发师进程
{
while (true) {
P(ready); //有顾客准备好了
理发;
V(finish); //允许其他顾客理发
}
}
void customer()
{
P(mutex); //互斥waiting变量的操作
if (n_customer < 6) //店里顾客数没达上限
{ n_customer ++; //店里顾客数增1
V(mutex); //允许waiting变量的操作
P(wchair); //找一个空椅子坐下
P(bchair); //再找理发椅坐下
V(wchair); //释放一个空椅子
V(ready); //该顾客准备好了
P(finish); //等待理发师完成理发
V(bchair); //离开理发椅
P(mutex); //互斥waiting变量的操作
n_customer --; //等待顾客数减1
V(mutex); //允许waiting变量的操作
}
else
离开;
V(mutex);
}
2.16.6 银行叫号问题
情景描述
某银行提供一个服务窗口和10个供顾客等待的座位。顾客到达银行时,若有空座位,则到取号机上领取一个号,等待叫号。取号机每次仅允许一位顾客使用。当营业员空闲时,通过叫号选取一位顾客,并为其服务。顾客和营业员的活动过程描述如下:
cobegin{
process 顾客i{从取号机上获取一个号码;等待叫号;获取服务;}
process 营业员{while (true) {叫号;为顾客服务;}}
}
请添加必要的信号量和P、V(或wait()、signal())操作,实现上述过程中的互斥与同步。要求写出完成的过程,说明信号量的含义并赋初值。
伪代码
semaphore mutex = 1; //互斥使用取号机的信号量
semaphore empty = 10; //空座位的数量信号量
semaphore full = 0; //已占座位的数量信号量
semaphore service = 0; //等待叫号信号量
process 顾客i
{
P(empty);
P(mutex);
从取号机获得一个号;
V(mutex);
V(full);
P(service); //等待叫号
}
process 营业员
{
while (true) {
P(full);
V(empty);
V(service); //叫号
为顾客服务;
}
}
总结服务类问题:理发师、银行叫号
需要计算等待者的数量,并为这个统计量加锁
2.17 管程
管程是一个程序设计语言结构,采用了集中式的进程同步方法,提供了与信号量同样的功能,但更易于控制
概念
由一个共享数据结构(资源的抽象)和为并发进程执行的一组操作组成,这组操作可同步进程和改变管程中的数据
特点
- 局部变量只能被管程内的过程访问
- 进程通过调用管程的一个过程进入管程
- 无论何时仅有一个进程在管程中执行,仅当该进程结束或阻塞时管程才可供其他进程调用(非抢占)
管程实现的互斥是一种依靠数据结构的互斥,管程相当于一个互斥函数
管程实现同步
使用条件变量(局部变量)
操作条件变量的两个函数:
- cwait©:进程在c上阻塞,此时管程可共其他进程调用
- csignal©:恢复在c上阻塞的一个进程
管程解决生产者消费者问题
/* program producerconsumer * /
monitor boundedbuffer;
char buffer [N]; /*space for N items */
int nextin,nextout; /*buffer pointers */
int count; /* number of items in buffer */
cond notfull,notempty; /*condition variables for synchronization */
//定义append()
void append (char x){
if (count == N) cwait ( notfull); /*buffer is full; avoid overflow * /
buffer[nextin] = x;
nextin = (nextin + 1) % N;
count++;
/*one more item in buffer */
csignal(notempty); /*resume any waiting consumer * /
}
//定义take()
void take (char x){
if (count == 0 ) cwait(notempty); /* buffer is empty; avoid underflow */
x = buffer[ nextout];
nextout = ( nextout + 1)% N;
count--; /*one fewer item in buffer */
csignal (notfull); /*resume any waiting producer * /
}
{ /* monitor body */
nextin = 0; nextout = 0; count = 0; /* buffer initially empty */
}
void producer()
{
char x;
while (true) {
produce(x);
append(x);
}
}
void consumer
{
char x;
while (true) {
take(x);
consume(x);
}
}
void main
{
parbegin (producer,
consumer) ;
}
2.18 消息传递
消息传递可以提供同步和通信功能,这满足了进程互斥和合作的要求,可工作在分布式系统、共享内存的单核多核系统
两条通信原语
- Send(destination,message):进程以消息的形式给指定目标发送消息
- Receive(source,message):接受原语指明源和消息
消息格式
同步原理
-
只有发送方发送了消息,接收方才能接收到
-
消息传递的三种同步方式
- 阻塞发送,阻塞接收(最紧密方式,没收到就两边都阻塞)
- 不阻塞发送,阻塞接收(最有效方式,如TCP流水线,允许发送者累积ACK)
- 不阻塞发送,不阻塞接收(两边随缘,都不等待)
Send与Receive的寻址方式
-
直接寻址
-
Send直接包含目的进程id
-
Receive
- 显式的指明源进程:对于处理并发进程的合作有效
- 不可能指定源进程:如打印机服务进程,采用隐式寻址,接收到消息时将源地址保存下来
-
间接寻址
-
消息被发到共享的队列中:信箱
-
发送进程向信箱添加消息,接收进程从信箱取走消息
互斥的实现
- 信箱中初始包含一条空消息(就像进入临界区的令牌)
- 采用不阻塞发送、阻塞接收的方式
- 若信箱存在一条消息,则允许一个进程进入临界区,进入后信息置为空
- 若信箱为空,表明有一个进程位于临界区,其他试图进入的进程必须阻塞
- 当信箱最多只有一条消息时,即可实现只允许一个进程进入临界区,从而实现互斥
/* program mutualexclusion */
const int n = /* 进程数 */;
void main()
{
create_mailbox(box); /* 创建邮箱 */
send(box, null); /* 初始化,向邮箱发送一条空消息 */
parbegin(P(1), P(2), …, P(n));
}
void P(int i)
{
message msg;
while (true) {
receive(box, msg); /* 从邮箱接收一条消息 */
<临界区>;
send(box, msg); /* 将消息发回到邮箱 */
<其余部分>
}
}
使用消息传递实现生产者消费者问题
使用两个邮箱mayconsume和mayproduce,大小均为capacity
- mayproduce
- 该邮箱起初填满空消息(即允许生产的令牌)
- 只要该邮箱有消息,生产者就可生产
- 每次生产前取一条空消息,之后生产数据,并将数据作为消息发至mayconsume邮箱
- 消费者的每次消费使得该邮箱中的空消息数增加
- mayconsume
- 生产者产生的数据作为消息发送到该信箱(即允许消费的令牌)
- 只要该邮箱有数据消息,消费者就可消费
- 每次消费前,取一条消息,消费后,向mayproduce发送一条空消息
- 生产者的每次生产使得该邮箱的消息数增加
const int;
capacity = /* 缓冲区容量 */
null = /* 空消息 */
void producer()
{
message pmsg;
while (true) {
receive(mayproduce, pmsg);
pmsg = produce();
send(mayconsume, pmsg);
}
}
void consumer
{
message cmsg;
while (true) {
receive(mayconsume, cmsg);
consume(cmsg);
send(mayproduce, null);
}
}
void main()
{
create_mailbox(mayproduce);
create_mailbox(mayconsume);
for (int i = 1; i <= capacity; i++) send(mayproduce, null); //初始化信箱
parbegin(producer, consumer);
}