目录
三、同步与互斥
同步与互斥的基本概念
1、临界资源
临界资源:一次仅允许一个进程使用的资源。
临界区:对临界资源的访问必须互斥地进行,每个进程中访问临界资源的代码被称为临界区。对临界资源地访问可分为四个部分:
- 进入区:检查是否能进入临界区;
- 临界区:进程中访问临界资源的代码,又称为临界段;
- 退出区:将正在访问临界区的标志清除;
- 剩余区:代码中的其他部分。
2、同步
同步关系,又称直接制约关系,指为了完成某种任务而建立的两个或多个进程,这些进程因为需要协调他们之间的运行次序而等待、传递信息所产生的制约关系。
同步关系源于进程之间的合作。
3、互斥
互斥关系,也称间接制约关系,指当前进程使用临界资源时,另一个进程必须等待。
实现临界区互斥必须遵守的准则:
- 空闲让进:临界区空闲时,可以允许一个请求进入临界区的进程立即进入临界区。
- 忙则等待:当已有进程进入临界区后,其他试图进入临界区的进程必须等待。
- 有限等待:对于请求访问临界区的进程,应该保证能在有限时间内进入临界区,防止无限等待。
- 让权等待:当进程不能访问临界区时,应当立即释放处理器,防止进程忙等。
其中让权等待原则上应该遵循,但不是必须的。
实现临界区互斥的基本方法
1、软件实现方法
(1)单标志法:设置一个公用整型变量turn:turn=0 表示允许进程0访问临界区,turn=1 允许进程1访问
- 进入区:while 语句循环等待,直到对方将访问权让给自己(退出区内容)
- 临界区:访问临界资源
- 退出区:自己访问完了,将标志设为对方,允许对方访问临界资源。
- 剩余区:剩余代码段。
单标志法特点:两个进程必须交替访问临界资源,若有一方不想访问,你们另一方也无法访问。
(2)双标志先检查法:设置一个布尔类型的数组 bool flag[2],用来标记各个进程想要进入临界区的意愿
- 进入区:检查对方是否想进入,若不想,则自己进入,并设置自己想进入临界区的标志;
- 临界区:访问临界资源;
- 退出区:将自己想要访问临界区的意愿设置为否
- 剩余区:剩余代码段。
双标志先检查法特点:两个进程有可能同时访问临界区。
原因:若P0执行完第一条语句,进入了临界区后,发生进程切换(进程调度),此时P1开始执行,因为P0还没来得及修改flag[0],所以P1也顺利进入了临界区。
(3)双标志先检查法:同(2)一致,但先设置标志后循环检查对方标志。
双标志后检查法的特点:两个进程有可能都访问不了临界区,两个进程同时发生饥饿。
原因:P0执行第一条语句修改完flag[0] = true后发生进程调度,此时P1上CPU运行,设置flag[1] = true,则此时flag[0] = flag[1] = true,P0、P1都会陷入while循环,导致两个进程都无法进入临界区。
(4)Peterson算法:利用flag[]解决互斥访问问题,同时利用turn解决饥饿问题。
flag[i] 表示进程 Pi 是否想进入临界区,turn 表示轮到谁进入临界区。
Peterson算法很好地遵守了,”空闲让进“、”忙则等待“、”有限等待“,但未实现“让权等待”。
2、硬件实现方法
计算机提供了特殊的硬件指令,允许对一个字节的内容进行检测和修正,或对两个字的内容进行交换。
(1)中断屏蔽方法
核心思想:当一个进程正在执行它的临界区代码时,为了防止其他进程进入临界区的最简单的方法是执行关中断原子指令,并在访问结束的时候,再执行开中断原子指令。
开中断和关中断是相对于单个CPU而言的,即对于多核计算机来说,开、关中断只是对单个CPU而言。其它CPU都正常运行。
- 缺点:
- 限制了CPU交替执行程序的能力,系统效率明显降低;
- 将开/关中断的权利交给用户很危险;
- 不适用于多处理器系统。(不能保证进程在其它CPU上不访问临界区)
(2)硬件指令方法——TestAndSet 指令
借助一条硬件指令(简称TS指令)实现互斥,这条指令是原子操作。
boolean TestAndSet(boolean *lock); //读出指定标志后,将该标志设置为true,并返回lock的旧值 | |
//利用TS指令实现互斥的过程 | |
while TestAndSet(&lock); // 检测并加锁 | |
临界区其它代码; | |
lock = false; // 退出区解锁 | |
进程的其它代码; |
(3)硬件指令方法——Swap指令
void Swap(Boolean *a, boolean *b); //交换a,b的值 | |
//利用Swap指令实现互斥的过程 | |
boolean key = true; // 设置局部布尔变量,若key = false,则允许进入临界区 | |
while(key) Swap(&lock, &key); // 交换key和lock,lock = false表示暂无其它进程在临界区里 | |
临界区代码; | |
lock = false; // 退出区,清除访问标志 | |
进程的其它代码; |
用硬件方法实现互斥特点:
- 优点:
- 简单、容易验证其正确性;
- 适用于任意数目的进程(不局限为2个);
- 支持系统中多个临界区。
- 缺点:
- 等待系统进入临界区的进程会占用CPU执行while循环(忙等待),不能实现让权等待。
- 若调用程序是从等待进程中随机选择一个进程,那么可能会导致饥饿。
互斥锁(mutex lock)
互斥锁:一个临界区拥有一把互斥锁。进程在进入临界区时会调用 acquire()函数,以尝试获取锁。若获取成功,则进入临界区,并在退出区调用 release()函数释放锁;若获取失败,则陷入循环,忙等待。
互斥锁常采用硬件机制实现,多用于多CPU系统。
每个互斥锁有一个名为 availab 的布尔类型变量,表示锁是否可用。调用 acquire() 后锁不可用,表示该进程上锁,尝试获取锁的进程会忙等待。
acquire() 和 release() 均是原子操作,采用硬件机制实现。
需要连续循环忙等待的互斥锁,都可称为自旋锁,如TS指令、Swap指令但标志法等。
互斥锁的特点:
满足“空闲让进“、”忙则等待“和”有限等待”,但不满足“让权等待”。
优点:等待期间不用切换CPU上下文,多核系统的上锁时间较短,等待代价不高。
缺点:忙等待。在进入临界区前要循环调用 acquire() 函数,浪费CPU周期。
信号量
信号量机制是一种功能较强的机制,他只能被两个标准原语访问(wait()和signal()),也可以简写为P()和V(),或者简称P、V操作。
原语功能的不被中断特性,在单CPU上可以由软件通过屏蔽中断的方法实现。
1、整型信号量
设置一个用于表示资源数量的整型 S。并且对于这个整型的操作只有三种:初始化和P、V操作。
// P操作 | |
wait(S){ // 相当于进入区 | |
while(S <= 0); // 当资源数目不够的时候,循环等待 | |
S -= 1; // 资源数目够的时候,占用一个 | |
} | |
// V操作 | |
signal(S){ // 相当于退出区 | |
S += 1; // 使用完之后释放资源,资源的数目加一 | |
} |
整型信号量中的 wait() 操作,只要资源不够的时候会不断测试,不遵循“让权等待”原则。
2、记录型信号量
这是一种不存在进程忙等现象的进程同步机制,除了需要使用一个用于表示资源数目的value变量外,还要再增加一个进程链表,用于链接所有等待该资源的进程。
// 记录型信号量的描述 | |
typedef struct{ | |
// 记录该资源的最大可用数目 | |
int value; | |
// 记录等待该资源的进程链表(队列) | |
struct process *L; | |
}semaphore; | |
// P、V操作 | |
void wait(semaphore S){ | |
// 获取一个资源,资源数目减一 | |
S.value --; | |
// 若获取资源后,资源数目小于0,那么表示当前资源可用数目为0 | |
if(S.value < 0){ | |
// 将该进程加入这个资源的等待链表(队列) | |
add this process to S.L; | |
// 阻塞这个进程,释放CPU | |
block(this process); | |
} | |
} | |
void signal(semaphore S){ | |
// 释放一个资源,资源数目加一 | |
S.value ++; | |
// 若释放资源后,资源数目仍未大于0,那么表示有进程在等待队列中 | |
if(S.value <= 0){ | |
// 将一个进程从等待链表中取出 | |
remove a process P from S.L; | |
// 唤醒这个取出的进程(加入就绪队列) | |
wakeup(P); | |
} | |
} |
记录型信号量中的wait()操作遵循“让权等待”原则(当获取不到资源的时候会主动放弃CPU)。
3、利用信号量实现互斥
为了使多个进程能够互斥地访问某个临界资源,需要为该资源设置一个互斥信号量S,其初始值为1(可用资源数为1),然后将各个进程置于P(S)和V(S)之间。
申请资源执行P操作,释放资源执行V操作。
semaphore S = 1; // 初始化 | |
P1(){ | |
... | |
P(S); // 加锁 | |
P1 enter critical section; | |
V(S); // 解锁 | |
... | |
} | |
P2(){ | |
... | |
P(S); // 加锁 | |
P2 enter critical section; | |
V(S); // 解锁 | |
... | |
} |
- 对不同资源需要设置不同的互斥信号量;
- P(S)、V(S)必须成对出现
- 缺少P(S)不能保证对临界资源的互斥访问;
- 缺少V(S)会使临界资源永远不被释放;
- 有多少资源就将信号量的初始值设为多少。
4、利用信号量实现同步
同步源于进程之间的合作,需要让本来异步的并发进程相互配合。为了实现这种同步关系,需要设置一个同步信号量S,其初值为0(刚开始没有这种资源,P2需要使用这种资源,而它又只能有P1产生)。
semaphore S = 0; // 初始化 | |
P1(){ | |
x; | |
V(S); // 产生资源 S.value ++; | |
} | |
P2(){ | |
P(S); // 消耗资源 S.value --; | |
y; | |
} |
实现同步时信号量的初始值要视情况而定:若期望数据未出现,则设为0;若期望数据已出现,则设为非0整数。
5、利用信号量实现前驱关系
信号量也可以用来描述程序或语句之间的前驱关系。
经典同步问题
1、生产者消费者问题
生产者与消费者共享大小为n的缓冲区,初始为空。当缓冲区不满的时候,生产者才能生产;当缓冲区不空时,消费者才能消费。生产与消费互斥进行。
semaphore mutex = 1; | |
semaphore empty = n; | |
semaphore full = 0; | |
Producer(){ Consumer(){ | |
while(1){ while(1){ | |
p(empty); P(full); | |
P(mutex); P(mutex); | |
produce; consume; | |
V(mutex); V(mutex); | |
V(full); V(empty); | |
} } | |
} } |
对于P(empty)与P(mutex)和P(full)与P(mutex)均不可交换位置,否则可能会发生死锁。
2、读者、写者问题
- 允许多个读者同时读文件;
- 只允许一个写者往文件中写东西;
- 写者完成写之前不再允许读者读或者其它写者写;
- 写者执行写操作前应该让所有读者退出。
semaphore mutex = 1; // 使读者之间互斥进行count变量的修改 | |
semaphore write = 1; // 保证写进程优先,避免写进程被饿死 | |
semaphore read = 1; // 使读写进程之间互斥进行,read = 1 表示当前有读进程在运行 | |
int count = 0; // 当前有多少个读进程 | |
Reader(){ Writer(){ | |
while(true){ while(true){ | |
P(write); P(write); | |
P(mutex); P(read); | |
if(count == 0) P(read); writing; | |
count ++; V(read); | |
V(mutex); V(write); | |
V(write); } | |
reading; } | |
P(mutex); | |
if(count == 1) V(read); | |
count --; | |
V(mutex) | |
} | |
} |
3、哲学家进餐问题
5名哲学家围坐在圆桌旁,两名哲学家之间有一支筷子。哲学家做的事情只有两件:吃 and 思考,吃饭的时候需要一双筷子,哲学家只能一支一支地拿起筷子。
semaphore chopsticks[5] = {1, 1, 1, 1, 1}; | |
semaphore mutex = 1; | |
Pi(){ | |
while(true){ | |
P(mutex); | |
P(chopsticks[i]); | |
p(chopsticks[(i+1)%5]); | |
V(mutex); | |
eating; | |
V(chopsticks[iV]); | |
V(chopsticks[V(i+1)%5]); | |
thinking; | |
} | |
} |
4、吸烟者问题
- 系统中有三个抽烟者和一个供应者,抽烟者轮流抽烟;
- 三个抽烟者自己制作卷烟,但是他们都缺少材料,但缺少的材料各不相同;
- 抽烟者拿到供应者提供的材料后,制作并抽掉卷烟,同时通知供应者继续供应。
semaphore material[3] = {0, 0, 0} | |
semaphore smoke = 0; | |
Supply(){ | |
while(true){ | |
for(int i = 0; i < 3; i++){ | |
V(material[i]); | |
P(somke); | |
} | |
} | |
} | |
Smokeri(){ | |
while(true){ | |
P(material[i]); | |
V(smoke); | |
} | |
} |