前言
协作进程
能与系统内的其他执行进程互相影响。协作进程或能共享逻辑地址空间,或能通过文件或消息来共享数据。
然而,共享数据的并发访问可能会导致数据的不一致。本章讨论多种机制
,以便确保共享同一逻辑地址空间的协作进程能有序执行,从而维护数据的一致性
。
一、背景
如果没有进程间的同步保障机制会发⽣什么样的问题?
发生竞争条件
问题,即多个进程并发访问和操作同一数据并且执行结果与特定的访问顺序有关
。
那么,避免发⽣竞争条件的关键问题是什么?
答: 确保操作共享数据的代码段的执⾏同步
(互斥运⾏),不能让多个进程同时运⾏操作共享数据的代码段
二、临界区问题
每个进程拥有操作共享数据的代码段
(程序段),⽽这代码段称为临界区
当一个进程在临界区内执行时,其他进程不允许在他们的临界区内执行
解决竞争条件问题的关键是:
- 确保单个进程在临界区内执⾏
- 确保其他进程也可以进⼊临界区
在进入临界区前,每个进程应请求许可,实现这一请求的代码区称为进入区
。
临界区之后可以有退出区
,其他代码为剩余区

临界区问题
:设计一个协议以便协作进程
解决临界区问题需要满⾜如下条件:
互斥
(Mutual Exclusion )
某⼀个进程进⼊了临界区,其他进程就不能进⼊进步
(Progress)
如果没有进程在临界区执⾏,且有进程需要进入临界区,那么只有那些不在剩余区的进程可参加选择,且必须确保⼀个进程进⼊临界区(选择不能无限推迟)有限等待
(Bounded Waiting)
⼀个进程从请求进⼊临界区,直到该请求被允许,必须是等待有限的时间
三、Peterson解决方案
Peterson方案只能处理2个进程
设置两个变量
int turn //turn=i,表⽰i进程准备进⼊其临界区
boolean flag[2]
//表⽰哪个进程想要进⼊其临界区. flag[i] = true 表⽰i进程准备进⼊临界区。
前提条件
是加载和存储指令是原⼦指令,即加载和存储指令是不可被中断的指令。
//全局
flag[2] = {
false; false};
turn = 0;
//PROCESS 0
do{
flag[0] = true;
turn = 1;
while(flag[1] && turn == 1);
//critical section
flag[0] = false;
} while(true)
//PROCESS 1
do{
flag[1] = true;
turn = 0;
while(flag[0] && turn == 0);
//critical section
flag[1] = false;
} while(true)
如果没有turn,两者可能都会进入循环
四、硬件同步
下面我们探讨临界区问题的多种解答,所有解答都是基于加锁
为前提,即通过锁来保护临界区。
我们可不可以⽤禁⽤中断的⽅式解决临界区问题?
- 单处理器系统:可以
- 多处理系统:不可行,会非常耗时,降低系统效率
因此,许多现代系统提供特殊的硬件指令,用于检测和修改字的内容,或者用于原子地
交换两个字,采用这些指令能相对简单地解决临界区问题
现代计算机系统提供特殊指令叫原⼦指令
(atomic instructions)(不可中断的指令
)
- TestAndSet():检查和设置字的内容
- swap():交换两个字的内容不可中断的指令
1. TestAndSet()
boolean TestAndSet(boolean *target) {
boolean rv = *target;
*target = TRUE;
return rv;
}
boolean lock = false//全局变量
//PROCESS 0
do{
while(TestAndSet(&lock));
//critical section
lock = false;
//remainder section
}while (true);
//PROCESS 1
do{
while(TestAndSet(&lock));
//critical section
lock = false;
// remainder section
}while (true);
2.swap()
void swap (boolean *a, boolean *b){
boolean temp = *a;
*a = *b;
*b = temp;
}
boolean lock = false//全局变量
//PROCESS 0
do{
key = true;//局部变量
while ( key == true)
swap (&lock, &key );
// critical section
lock = false;
// remainder section
}while (true);
//PROCESS 1
do{
key = true;
while ( key == true)
swap (&lock, &key );
// critical section
lock = false;
// remainder section
}while (true);
Peterson算法、TestAndSet( ) 和swap( ) 原⼦指令满足了互斥要求,但并未满足有限等待
要求,操作会存在忙等待
的问题(即等待的线程一直在while中循环,浪费了CPU周期,这本来可以有效用于其他线程)
有限等待原子指令
声明⼀个布尔变量waiting 确保有限的等待
- 每个进程的局部变量key,初始化为true
- 全局变量lock,初始化为false(当有进程进入临界区后设为true)
- ⽤变量waiting[i] 表⽰等待进⼊临界区的进程,初始化为false,表示i进程没有要进入临界区。当i进程想要进入临界区时,设为true
可以进⼊临界区的条件:当lock 或waiting[i] 为false时
。
do {
waiting[i] = true;//该进程等待进入临界区
key = true;
//当lock为false,即可以进入等待区
//或当waiting[i]为false,即该进程不再等待
//跳出while,进入临界区
while(waiting[i] && key)
key = TestAndSet(&lock);
waiting[i] = false;//设为不再等待
// critical section
//依次检查是否还有等待进入的线程,为true
j = (i + 1) % n;
while(( j != i) && !waiting[j])
j = (j + 1) % n;
//没有找到,释放锁
if (j == i) // entered
lock = false;
//找到了,j进程不再等待,跳出while进入临界区
else // waiting
waiting[j] = false;
// remainder section
} while (true);
注意
:
假设进程P执行完了临界区,那么它的waiting是多少?
- P还没有进入下一次循环,waiting=false
- P进入了下一次循环,waiting=true
五、互斥锁
临界区问题基于硬件解决方案不但复杂,而且程序员不能直接使用,因此,操作系统设计人员构建软件工具
,来解决临界区问题。
最简单的工具就是互斥锁
。
采用互斥锁保护临界区,从而防止竞争条件。也就是说,一个进程进入临界区应得到锁,在退出时释放锁。
六、信号量
本节讨论一个更鲁棒的工具,它的功能类似互斥锁,但其提供了更高级的方法,以便进程能同步活动。
一个信号量
S是一个整型变量,除初始化外,只能通过两个标准原⼦操作来访问信号量
- wait(S) : P(S) --> S- -
wait (S) {
while (S<=0);//wait
//no operation
S--;
}
- signal(S) : V(S) --> S++
signal (S) {
S++;
}
信号量的使用
⼆进制信号量
(binary semaphore),类似于互斥锁,实际上在没有互斥锁的系统上,可用来提供互斥。
- 适⽤于单资源的共享, mutex值为资源数量,初始化为1
- 信号量的值只能为0 或1
//PROCESS 1
do{
waiting (mutex);
//critical section
signal(mutex);
//remainder section
} while (TRUE);
//PROCESS 2
do{
waiting (mutex);
//critical section
signal(mutex);
//remainder section
} while (TRUE);
计数信号量
(counting semaphore)
- 适⽤于多资源共享,共享资源的数量为n
- wait( n ) 操作为减,signal( n ) 操作为加,当n为0时表明所有资源都被占⽤
可以适⽤于优先约束
(precedence constraint)例⼦,假设要求P1的语句S1完成之后,执⾏P2的语句S2, 共享信号量synch,并初始化为0
P1:
S1;
single(synch);
P2:
wait(synch);
S2;
信号量的实现
信号量的实现关键是保障
wait( ) 和signal( ) 操作的原⼦执⾏
,即必须保障没有两个进程能同时对同⼀信号量执⾏wait( )和signal( )操作。
保障⽅法
- 单处理器环境下,禁⽌中断
- 多处理器环境下,禁⽌每个处理器的中断。但这种⽅法即困难⼜危险
问题
:忙等待(busy waiting),⾃旋锁(spinlock)(与忙等待类似)
⽆忙等待的信号量实现
为了解决忙等待的问题,让忙等待的进程挂起
(blocking),可以进⼊临界区时,让进程重新启动
(wakeup)。
挂起的含义
是进程从运⾏状态(running)转换成等待状态(waiting)
重启的含义
是进程从等待状态转换成就绪状态
//定义信号量结构
typedef struct {
int value; //是整数值,是资源数量
struct process *list;//等待队列
} semaphore
wait (semaphore *S) {
S->value--;
if (S->value < 0) {
add this process to S->list;//加入队列
block();//挂起
}
}
signal (semaphore *S) {
S->value++;
if (S->value <=0) {
//这里的<=意味着有进程在等待队列中
remove a process P from S->list;//移出队列
wakeup(P);//重启
}
}
比较
:
忙等待 vs
挂起-重启
答:没有谁好谁坏之分,这取决于上下文切换
和临界区长度
。
死锁
死锁
:两个或多个进程⽆限地等待⼀个事件,⽽该事件只能由这些等待进程之⼀来产⽣
讥饿
饥饿
:⽆限期的等待,即进程在信号量内⽆限期的等待
什么时候会发生?
在信号量的先进后出
等待队列中可能发生
七、经典同步问题
本节给出多个同步问题,举例说明大量并发控制问题,这些问题用于测试几乎所有新提出的同步方案。
在我们的解决方案中,使用信号量,因为这是讨论这类解决方案的传统方式。
1.有限缓冲问题(bounded buffer problem)
有界缓冲问题常用于说明同步原语(方案)能力,这里给出该解决方案的一种通用结构,而不是局限于特定实现。
假定缓冲池中有n 个缓冲项, 每个缓冲项能存⼀个数据项
- 当缓冲池满的时候,不能生产(full)
- 当缓冲池控的时候,不能消费(empty)
- 消费的时候不能生产,生产的时候不能消费(互斥)
那么设信号量
- 信号量empty: 表⽰空缓冲项的个数,初始化为n
- 信号量full :表⽰满缓冲项的个数,初始化为0
- 信号量mutex : 提供对缓冲池的生产和消费的互斥,初始化为1,作为互斥锁使用
//生产者
while (true) {
// produce an item
wait (empty);//empty>0, 只要有空缓冲项,就生产
wait (mutex);//互斥锁
// add the item to the buffer
signal (mutex);
signal (full);
}
//消费者
while (true) {
wait (full); //full > 0, 只要缓冲项里有数据,就消费
wait (mutex);
// remove an item from buffer
signal (mutex);
signal (empty);
// consume the removed item
}
2.读者–作者问题(reader and writer problem)
假设一个数据库为多个并发进程所共享,有的进程只能读数据库,其他进程可读可写数据库。为区分这两类进程,我们称前者为读者
,后者为作者
显然一个作者和其它进程同时访问数据库时,会发生混乱。
这一同步问题称为读者-作者问题
,被提出后一直作为测试几乎所有新的同步原语
读者-作者问题,如何确保同步?
- 读的时候不能写,写的时候不能读
- 多位读者可以同时访问数据,需要知道读者数量
- 只能由⼀个作者写数据,不能多个作者写数据
解决
:
- 作者与作者之间互斥,以及读和写互斥(信号量rw_mutex,初始化为1,读者和作者进程共享)
- 跟踪正在读的读者(信号量readcount,初始化为0)
- 保证更新readcount时的互斥(信号量mutex,初始化为1)
//作者进程
while (true) {
wait (rw_mutex) ;
// writing is performed
signal (rw_mutex) ;
}
//读者进程
while (true)
wait (mutex) ;//保证readcount更新的原子性
readcount ++ ;
if (readcount == 1)//第一位读者加锁,作者不能再写
wait (rw_mutex) ;
signal (mutex);
// reading is performed
wait (mutex) ;
readcount -- ;
if (readcount == 0)//没有读者,可以释放
signal (rw_mutex) ;
signal (mutex) ;
}
3.哲学家就餐问题(dining philosophers problem)
- 5个哲学家们⽤⼀⽣来思考和吃饭
- 一人⼀碗⽶饭
- 5只筷⼦
- 同时有两只筷⼦才能吃饭
哲学家就餐问题
是一个经典同步问题,这个代表性的例子满足:在多进程之间分配多个资源,而且不会出现死锁和饥饿
semaphore chopstick[5];//共享数据
//哲学家i
do {
wait(chopstick[i]) //左手边筷子
wait(chopstick[(i+1) % 5]) //右手边筷子
// eat
signal(chopstick[i]);
signal(chopstick[(i+1) % 5]);
// think
} while (true);
会出现死锁的问题!
八、管程
信号量虽然是一种方便有效的进程同步机制,但
由于发⽣如下操作错误,可能会出现死锁、饥饿
- 交换wait() 和signal() 操作顺序
- ⽤wait() 替代了signal() 操作
- 省略了wait() 或signal() 操作
- …
为处理这种错误,研究人员开发了一些高级语言工具。
本节介绍一种重要的、高级的同步工具,管程
抽象管程的语法
//管程类型表示不能有各种进程使用,只有内部函数才能访问内部变量和参数
monitor monitor_name
{
// shared variable declarations
condition x;//可以有多个条件变量
function P1 (…) {
…. }//方法
…
…
…
function Pn (…) {
……}
initialization code ( ….) {
… }//初始化代码
}
管程
管程
是⼀种⽤于多线程互斥访问共享资源的程序结构
- 采⽤⾯向对象⽅法,简化了进程间的同步控制
任⼀时刻最多只有⼀个线程执⾏管程代码
- 正在管程中的线程可临时放弃管程的互斥访问,等待事件出现时恢复
为什么要引⼊管程?
- 把分散在各进程中的临界区集中起来进⾏管理
- 防⽌进程有意或⽆意的违法同步操作
- 便于⽤⾼级语⾔来书写程序,也便于程序正确性验证。
管程的使⽤
- 在对象/模块中, 收集相关共享数据
- 定义访问共享数据的⽅法
管程的组成
- ⼀个锁:控制
管程代码的互斥
访问 - 0或多个
条件变量
:管理共享数据的并发访问
- 每个条件变量
对应
于⼀个等待队列
,每个条件变量只有
wait()和signal() 操作
条件变量
条件变量
是当调用管程过程的进程无法运行时,用于阻塞进程的一种信号量
- 当进程在管程过程中发现⽆法继续时,它在某些条件变量condition 上执⾏
wait 操作
,调用这一操作的进程会被挂起
(加入condition的等待队列) - 另⼀个进程可以通过对其伙伴在等待的同⼀个条件变量condition上执⾏
signal操作
来唤醒等待的进程
,如果没有,则如同没有操作
哲学家就餐问题的管程解决方案
下面通过哲学家就餐问题的无死锁解决
方案说明管程概念。
这个解答强加了限制
:只有一个哲学家的两根筷子都可用时,才能拿起使用。
为解决,制定如下规定
- 区分哲学家所处的
三个状态
THINKING, HUNGRY, EATING
enum { THINKING; HUNGRY, EATING) state [5]
采用枚举节省空间 - 哲学家i 只有在其
两个邻居不进餐
时,才能拿起筷⼦进餐
(state[(i+4)%5] != EATING) and (state[(i+1)%5] != EATING) - 声明条
件变量-哲学家
condition self[5],提供wait() 和signal()操作
monitor DP {
//5个哲学家的状态
enum {
THINKING; HUNGRY, EATING) state [5] ;
//条件变量,筷子
condition self [5];
//拿筷子操作
void pickup (int i) {
//状态为HUNGRY
state[i] = HUNGRY;
test(i);//测试是否能拿
if (state[i] != EATING)//不能拿就等待
self[i].wait();
}
//放筷子
void putdown (int i) {
state[i] = THINKING;
// test left and right neighbors
test((i + 4) % 5); // 尝试唤醒左边
test((i + 1) % 5); // 尝试唤醒右边
}
void test (int i) {
if ( (state[(i + 4) % 5] != EATING) && (state[i] == HUNGRY) &&(state[(i + 1) % 5] != EATING) ){
state[i] = EATING ;
self[i].signal() ;//唤醒进程
}
}
//初始化代码,都在思考
initialization_code() {
for (int i = 0; i < 5; i++)
state[i] = THINKING;
}
}
哲学家i应按如下调用操作
diningPhilosophers.pickup(i);
/*eat*/
diningPhilosophers.putdown(i);
注意:这确保了不会死锁
,但哲学家可能饿死
参考
《操作系统概念》