![582716c3c1e4b7d113cb25a5515f767e.png](https://i-blog.csdnimg.cn/blog_migrate/64ac968d3ea6e827eedf9a3f31205634.png)
本文目录:
- 1 进程同步基本概念
- 1.1 进程同步的背景
- 1.2 进程同步的概念
- 2 进程同步实现方法
- 2.1 软件方法
- 2.2 硬件方法
- 2.3 信号量
- 2.3.1 整型信号量
- 2.3.2 记录型信号量
- 2.3.3 信号量-实现同步
- 2.3.4 信号量-实现互斥
- 2.3.5 信号量-前驱图
- 3 典型同步问题
- 3.1 生产者--消费者问题
- 3.2 读者--写者问题
- 3.3 哲学家进餐问题
1 进程同步基本概念
1.1 进程同步的背景
假设在没有进程同步机制制约的场景下,有一环形缓冲池,包含n个缓冲区(0~n-1)。有两类进程:一组生产者进程和一组消费者进程,生产者进程向空的缓冲区中放产品,消费者进程从满的缓冲区中取走产品。
生产者代码:
while
消费者代码:
while (true) {
while (count == 0) ; // 缓冲池无产品,无法消费,阻塞在这里
nextConsumed = buffer[out];
// 从缓冲池拿出一个产品进行消费
out = (out + 1) % BUFFER_SIZE;
// 因为是环形,所以使用取余数得到缓冲池下一个索引
count--;
// 缓冲池占用数-1
}
那么,在上述代码中,其中 count++ 和 count-- 可能会出现问题。
count++ 的执行过程如下(机器指令)
register1 = count
register1 = register1 + 1
count = register1
count-- 的执行过程如下(机器指令)
register2 = count
register2 = register2 - 1
count = register2
分析:
假设初始状态 count=5,则生产者和消费者两个进程,对应内部的机器指令的执行如下(注意:① 这里是在没有进程同步机制制约的场景下。② 两个进程正在随机占用一个CPU):
- S0: producer execute register1 = count {register1 = 5}
- S1: producer execute register1 = register1+1 {register1 = 6}
- S2: consumer execute register2 = count {register2 = 5}
- S3: consumer execute register2=register2-1 {register2 = 4}
- S4: producer execute count = register1 {count = 6 }
- S5: consumer execute count = register2 {count = 4} // 这个为最终结果了
正确结果应该是: “count = 5”
现在的结果是: “count = 4”
看到这里,读者朋友应该明白了,这就是因为进程同步没进行制约导致的结果。
1.2 进程同步的概念
多道程序环境下,进程并发执行,不同进程存在相互制约关系。为协调该关系,避免进程冲突,引入进程同步。
一些名词解释:
- 临界资源:一次仅允许一个进程使用的资源称为临界资源。比如:打印机、共享变量等。
- 临界区:是指并发进程中访问临界资源的程序段。
- 进入区:检查是否可以进入临界区,若可以,设置正在访问临界区标志。
- 退出区:清除正在访问临界区标志。
do {
进入区
critical section; // 代码执行部分
退出区
remainder section // 剩余部分
}
- 进程的互斥:是指若干个进程要使用同一共享资源时,任何时刻最多允许一个进程去使用,其它要使用该资源的进程必须等待,直到占有资源的进程释放该资源。
- 进程的同步:是解决进程间协作关系的手段。指一个进程的执行依赖于另一个进程的消息,当一个进程没有得到来自于另一个进程的消息时则等待,直到消息到达才被唤醒。
- 进程互斥关系是一种特殊的进程同步关系。
临界区访问原则:
- 互斥访问– 若已有进程进入临界区,则其他的进程必须等待其离开临界区,释放临界资源。 (忙则等待)
- 前进 – 若没有进程处于其临界区,应允许一个请求进入临界区的进程立即进入临界区,访问临界资源。(空闲让进)。
- 有限等待 – 对请求访问的进程,应保证能在有限的时间内进入临界区,避免进入“死等”。
- 让权等待 – 当进程不能进入临界区时,该进程应释放处理机,以免进入“忙等”状态。
2 进程同步实现方法
2.1 软件方法
解释:在进入区设置和检查一些标志来标明是否有进程在临界区,若有则在进入区循环检查进行等待,进程在退出区修改标志,以允许别的进程进入临界区。
Peterson 算法
1981年,由Peterson提出,满足临界区访问的原则。
- 设有两个进程Pi和Pk,且 LOAD 和 STORE 指令是原子操作。 Pi和Pk共享两个变量:
- int turn;
- Boolean flag[2] ;
- 变量 turn:turn==i 表示Pi可进入其临界区。
- 数组 flag : flag[i] = true 表示进程Pi请求进入临界区!
例子:
// Pi进程:
while (true) {
flag[i] = TRUE; // Pi请求进入临界区
turn = k; // Pk可以进入
while ( flag[k] && turn == k); // 若 (flag[k] && turn == k)为true,说明Pk随后也请求进入了临界区,Pi堵塞,Pk进入临界区了。
CRITICAL SECTION // 代码执行部分
flag[i] = FALSE; 给 Pk 信号,Pi随后可以执行
REMAINDER SECTION // 剩余部分
}
// Pk进程:
while (true) {
flag[k] = TRUE; // Pk请求进入临界区
turn = i; // Pi可以进入
while (flag[i] && turn ==i);
CRITICAL SECTION // 代码执行部分
flag[k] = FALSE; // 给 Pi 信号,Pi随后可以执行
REMAINDER SECTION // 剩余部分
}
分析:
若pi和pk同时请求进入临界区,while中的turn变量可保证只允许一个进入临界区,从而实现了互斥。
考虑Pi进程的代码,flag[i] = true; 意味着Pi想进入临界区,同时将turn设置为k, 若Pk在临界区,则Pi的while条件为真,Pi等待。
若PK不在临界区,则flag[k]为false,Pi进入临界区,从而避免了死等。
2.2 硬件方法
很多系统都提供了解决临界区问题的硬件支持。
方法1
对于单处理器环境 – “禁止中断”
- 并发进程可以无预设地直线运行
- 限制了交替执行程序的能力,执行效率明显降低。
方法2
许多现代计算机系统提供了特殊的原子(执行该代码时不允许被中断)机器指令:
- TestAndSet 指令:读出标志并把该标志设置为TRUE。
- Swap 指令:交换两个内存字的内容。
方法2.1 利用TestAndSet解决临界区访问算法。
- 声明一个布尔变量互斥锁 lock, 初值为false。
boolean TestAndSet (boolean *target)
{
boolean old = *target;
*target = true;
return old;
}
while (true) {
while (TestAndSet(&lock)); // 阻塞
// critical section
lock = FALSE;
// remainder section
}
方法2.2 利用Swap解决同步算法
- 共享全局布尔变量 lock,初值为FALSE;
- 每个进程定义一个局部布尔变量 key.
void Swap (bool *a, bool *b)
{
bool temp = *a;
*a = *b;
*b = temp;
}
while (true) {
key = TRUE;
while (key == TRUE)
Swap(&lock, &key);
// critical section
lock = FALSE;
// remainder section
}
2.3 信号量
Dijkstra发明了两个信号量操作原语:P操作原语和V操作原语。
常用的其他符号有:wait和signal;up和down等。
—— 原语是操作系统内核中执行时不可中断的过程,即原子操作。
除赋初值外,信号量仅能由同步原语对其进行操作,没有任何其他方法可以检查和操作信号量。
利用信号量和P、V操作既可以解决并发进程的竞争问题,又可以解决并发进程的协作问题。
2.3.1 整型信号量
数据类型:整型信号量S
对信号量S的操作:
- 初始化、
- 原子操作wait(S)
- 原子操作signal (S)
wait(S) 和 signal (S) 操作的定义 :
wait(S){
while (S<=0) ;//无资源,则等待
S:=S-1;
}
signal(S){
S:=S+1;
}
其中 S 初值:一般取可用资源数目。
缺陷:未遵循“让权等待”原则。若S=0,则进程便在while (S<=0) ;无限循环进行等待,白白浪费CPU时间。
2.3.2 记录型信号量
要解决的问题:整型信号量的“让权等待”
办法:等待时进程应阻塞自己。
记录型信号量的定义:
typedef struct {
int value ; //可用资源数
struct process *L ; //阻塞队列
} semaphore ;
Wait(S)和signal(S)中的S为两个过程的共享变量。
通常,P操作意味着请求一个资源,V操作意味着释放一个资源。
Wait(S) - P操作:
semaphore S;
{
S.value --;
if S.value < 0
block(S.L)
}
Signal(S) - V 操作:
semaphore S;
{
S.value ++ ;
if S.value <= 0
wakeup(S.L);
}
Wait(S)相当于P操作;Signal(S) 相当于 V 操作。
2.3.3 信号量-实现同步
例子:
设S为实现进程P1、P2同步的公共信号量,初始值为0。若进程P2中的y要使用进程P1中的x,则同步代码如下:
Semaphore S=0;
P1(){
........
x;
V(S);
........
}
P2(){
.........
P(S);
y;
.........
}
解析:
- P2进程运行,因为P2进程需要P1进程的x,所以第一步先执行了原语操作P(S)
- P(S) 内的操作为:Semaphore S=0 可知value初始0,S.value -- 为 -1,命中条件 S.value < 0 ,所以进行自我阻塞操作block(S.L),即P2进程阻塞。
- P1进程过了好久,进来开始执行:x生成,紧接着执行了原语操作 V(S);
- V(S)内的操作:S.value ++(S.value值是共享的),所以 S.value 为0,命中条件value <= 0,然后P2进程被唤醒wakeup(S.L);
- P2进程被唤醒执行,有了x变量,正确执行完。
2.3.4 信号量-实现互斥
例子:
设S为实现进程P1、P2互斥的信号量,由于每次只允许一个进程进入临界区,则初始值为1。则互斥代码如下:
Semaphore S=1;
P1(){
........
P(S); //加锁
P1的临界区
V(S); //解锁
........
}
P2(){
........
P(S); //加锁
P2的临界区
V(S); //解锁
........
}
这个的解析思路跟上面实现同步的一样。或者这里可以直接抽象化,忽略 P(S) 中的底层细节,直接看成一把锁,这两个进程在同一时间内,只能由其中一个进程获取到一把锁并往下执行。
可以确保每次只允许一个进程进入临界区。
2.3.5 信号量-前驱图
例子:
语句S1~S6之间的前趋关系图如下,写出一个可并发执行的程序:
![c75509395ea9b60932114b3ce1508445.png](https://i-blog.csdnimg.cn/blog_migrate/cd1ac7bb9726cd68d6f84af8411f4f1d.png)
解:
semaphore a=0,b=0,c=0,d=0,e=0,f=0,g=0;
//其他代码
{ S1; signal(a); signal(b); }
{ wait(a); S2; signal(c); signal(d); }
{ wait(b); S3; signal(e); }
{ wait(c); S4; signal(f); }
{ wait(d); S5; signal(g); }
{ wait(e); wait(f); wait(g); S6; }
//其他代码
3 典型同步问题
3.1 生产者--消费者问题
问题描述:
一组生产者进程和消费者进程共享一个初始为空的缓冲池,含N个缓冲区,每个缓冲区可以存放一个消息。缓冲池未满时,生产者才能把消息放到缓冲池,否则,生产者等待。缓冲池不空时,消费者才能取出消息,否则,消费者等待。
注:
- 这个缓冲池是临界资源,同一时间,只允许一个生产者放消息,或者一个消费者取消息。所以,生产者和消费者进程对缓冲池的访问是互斥关系。
- 只有生产者生产之后消费者才能消费,它们也是同步关系。
信号量设置:
- 互斥信号量 mutex,初值为 1,控制互斥访问缓冲池。
- 同步信号量 full,初值为 0。记录“满”缓冲区的个数。
- 同步信号量 empty,初值为 N。记录“空”缓冲区的个数
代码例子:
Semaphore mutex=1; //缓冲池互斥信号量
Semaphore empty =n; //缓冲池中空闲缓冲区个数,初始值为n
Semaphore full=0; //缓冲池中满缓冲区个数,初始值为0
生产者进程
While(1){
produce an item;
p(empty); //申请空缓冲区
p(mutex);//申请进入临界区
数据放入缓冲池中;
V(mutex);//释放互斥信号量
V(full);//满缓冲区数加1
}
消费者进程
While(1){
p(full); //申请一个满缓冲区
p(mutex);//申请进入临界区
从缓冲池中取消息;
V(mutex);//释放互斥信号量
V(empty);//空缓冲区数加1
消费数据;
}
注意:对信号量的PV操作成对出现。另多个P操作顺序不能颠倒,否则容易引起死锁。
3.2 读者--写者问题
描述:
读者比较复杂。与其他读者是同步,与写者是互斥。这里用一个计数器 ReadCount ,用来记录当前读者数目。
- 如果读者数目ReadCount为0,则可能有也可能没有写者在写;
- 如果读者数目ReadCount不为0,则不会有写者在写,请求读的读者便可读;
- 如果读者数目ReadCount为0,又没有写者在写,则请求写的写者能写。
代码:
int count=0; //记录读者数
semaphore mutex=1; //更新count值时的互斥信号量
semaphore rw=1; //读者写者互斥信号量
写者进程:
While(1){
p(rw); //申请访问共享文件
writing
V(rw); //释放共享文件
}
读者进程:
While(1){
p(mutex); //申请访问count
//若为第一个读者,需申请访问共享文件
if(count ==0)
p(rw);
count++; //读者数加1
V(mutex); //释放访问count
reading;
P(mutex);
count --;
//最后一个读者,需释放共享文件
if(count ==0)
V(rw);
V(mutex);
}
3.3 哲学家进餐问题
问题描述:
5个哲学家致力于思考和进餐,思考不影响其他人,饥饿时拿起左右两边的筷子(一根一根地拿起)。若筷子在别人手中,等待。进餐完,放下筷子继续思考。
![e7d68e72929c70d1c2771d754a0f61df.png](https://i-blog.csdnimg.cn/blog_migrate/f9002c00ef75a4c879e092bfa52fefac.png)
- 关键是如何让一个哲学家拿到左、右两根筷子而不造成死锁或者饥饿现象的发生。
- 设置一个互斥信号量数组chostick[5]={1,1,1,1,1},用于实现对5根筷子的互斥访问。
Pi 进程: 第i个哲学家的进程(i:0~4)
While (true) {
p( chopstick[i] );
p( chopStick[ (i + 1) % 5] );
// eat
V( chopstick[i] );
V(chopstick[ (i + 1) % 5] );
// think
}
若5个哲学家都要进餐,同时拿起左边的筷子 p( chopstick[i] ); 筷子都被拿光了,等到想拿右边得筷子 p( chopStick[ (i + 1) % 5] ); 时,全都阻塞,造成死锁。
解决方法:
- 至多只允许有四位哲学家同时去拿左边的筷子,最终能保证至少有一位哲学家能够进餐,并在用毕时能释放出他用过的两只筷子,从而使更多的哲学家能够进餐。
- 仅当哲学家的左、右两只筷子均可用时,才允许他拿起筷子进餐。
- 规定奇数号哲学家先拿他左边的筷子,然后再去拿右边的筷子;而偶数号哲学家则相反。按此规定,将是1、 2号哲学家竞争1号筷子;3、4号哲学家竞争3号筷子。即五位哲学家都先竞争奇数号筷子,获得后,再去竞争偶数号筷子,最后总会有一位哲学家能获得两只筷子而进餐。
Semaphore chopstick[5]={1,1,1,1,1};
Semaphore mutex =1; //取筷子信号量
Pi进程:
While(1){
P(mutex);
P(chopstick[i]);
P(chopstick[(i+1)%5];
eat;
V(chopstick[i]);
V(chopstick[(i+1)%5];
V(mutex);
think;
}
信号量机制的不足:
不正确地使用信号量,可能带来很多问题: 顺序错误: V (mutex) …. P (mutex) 错误使用: V (mutex) … V (mutex) 忘记信号量操作:忘记V (mutex)或 P(mutex) 或两者
需要程序员亲自控制信号量的使用过程,也为编程带来困难。管程是一种高层次的抽象,提供了方便、高效的进程同步机制。