第一节 进程的同步与互斥
无关并发进程一定没有共享变量。
一、与时间有关的错误
一个共享资源再一个进程使用未结束时,另一个进程也开始使用;这样就会引起错误;这类错误统称为与时间有关的错误。
二、进程同步与进程互斥
进程互斥(Process Mutual Exclusion)是指在某一时刻,只允许一个进程访问共享资源(如共享内存、文件等),以防止多个进程同时访问时产生冲突或不一致。是一种现象。
进程同步(Process Synchronization)是指协调多个进程的执行顺序,以确保它们按照预期的顺序访问共享资源或完成特定任务。进程同步确保进程之间的相互依赖关系得到正确处理。是一种方法。
举个例子:
在餐馆里,厨师和服务员的工作需要协调进行:
- 厨师做饭:厨师负责准备和烹饪食物,这需要一定的时间。
- 服务员上菜:只有在厨师做好菜后,服务员才能将菜送到客人的桌子上。
这个过程需要同步,服务员必须等待厨师完成一道菜后才能上菜。否则,服务员就没有菜可以上,无法完成工作。
假设一个大学校园里有一辆共享自行车,学生们可以使用它从一个地方骑到另一个地方:
- 学生A骑车:学生A拿到自行车后开始骑行。
- 学生B等待:此时,学生B也想使用这辆自行车,但必须等待学生A骑完并归还自行车后才能使用。
这里,互斥的概念就是:同一时间只能有一个人使用自行车。学生A和学生B不能同时使用同一辆自行车。
同步:确保任务按正确的顺序进行,例如厨师和服务员的关系。一个任务完成后,另一个任务才能开始。
互斥:确保资源被独占访问,例如共享自行车的使用。同一时间只能有一个人(进程)使用资源,其他人(进程)必须等待。
解决进程同步与互斥的方法有两种:
- 竞争各方平等协商
- 引入进程管理者,由其协调对互斥资源的使用
临界资源:系统种需要互斥使用的软/硬件资源。
计算机系统中资源共享程度:
- 互斥(Mutual Exclusion):不能同时使用
- 死锁(Deadlock):因互不相让,导致都得不到满足
- 饥饿(Starvation)
对临界资源的访问分四个部分:
1.进入区:在进入区检查是否可以进入临界区,若可以进入则置"正在访问临界资源"标志。
2.临界区:进程中访问临界资源的一段代码
3.退出区:将"正在访问临界资源"的标记清除
4.剩余区:代码中的其余部分
OS采用的进程同步机制应遵循:
1.空闲则入
2.忙则等待
3.有限等待:避免死锁。
4.让权等待:在进入等待区而不能进入临界区的进程,应释放处理机转入阻塞状态。
1、进程互斥的软件方法
基本思路:在进入区检查和设置标识位,若已有进程在临界区,则在进入区通过循环检查等待;在退出区修改标志。
彼得松算法(Peterson's Algorithm):软件实现;先修改临界区标志,再检查,最后修改者等候。
2、进程互斥后的硬件方法
完全利用软件实现进程互斥有很大的局限性,不适用于进程数量很多的情况;现在已经很少使用。
硬件实现的思路:用一条指令完成读和写两个操作,因此保证读和写不被中断;依据采用的指令不同可以分为:
(1) TS指令(Test-and-Set)
读出指定标志后将其设置为True;为每个临界资源设置一个公共的布尔变量lock,表示资源的两种状态:
- TRUE:正在被占用
- FALSE:空闲(初始值)
在进入区中利用TS进行检查和修改标志lock;若有进程在临界区,则重复检查,直到其他进程退出时检查通过。大致的处理过程如下:
while TS(&lock); // 进入区
// 临界区
lock = false; // 剩余区
(2) Swap指令(或者Ecxhange)
功能:交换两个字的内容。大致的处理过程如下:
void SWAP(int *a, int *b){
int temp;
temp = *a; *a = *b; *b = temp;
}
每个临界资源设置一个公共布尔变量lock,其初值为false;每个进程设置一个私有布尔变量key,用于于lock作信息交换。
在进入区利用SWAP指令交换lock与key的值,然后检查key的状态;有进程在临界区时,重复交换和检查,直到其他进程退出,则检查通过。整个过程大致如下:
key = true;
do{
SWAP(&lock, &key);
}while(key)
// lock为true时,无论怎交换,结果都一样。
这种方式的优点:
- 适用范围广
- 简单
- 支持多个临界区
缺点:
- 进程在等待进入临界区时,处理机为"忙等"状态,不能"让权等待"。
- 由于进入临界区的进程是从等待进程中随机选择的,有可能导致"饥饿"现象。
三、信号量和P、V原语
前面都是从进程之间平等协商的角度来解决问题;
这部分主要是从OS作为进程管理者的角度来解决问题,这个角度主要通过信号量处理。
信号量(Semaphore;由Dijkstra提出)代表可用资源实体的数量,是一种同步机制;也是OS提供的管理公有资源的有效手段。
信号使用P、V原语,原语是指执行中不被进程调度和执行打断的语句,恰如一条指令。
每个信号量s有:
- 一个整数值s.count;计数。
- 一个进程等待队列s.queue;存放阻塞在该信号量上的各个进程的标志。
信号量只能通过初始化和两个标准的原语来访问;P、V原语是OS核心代码的一部分,很好地解决了操作的整体性问题。
信号量的值:
- 正整数:当前空闲的资源数目
- 负整数:当前等待临界区的进程数目
在此机制这种,P原语相当于进入区操作,V原语相当于退出区操作。
P原语的操作:
wait(s){
--s.count; // 表示申请一个资源
if(s.count < 0){ // 代表没有空闲资源
调用进程进入s.queue;
阻塞调用进程;
}
}
V原语的操作:
single(s){
++s.count; // 释放一个资源
if(s.count < 0){ // 还有进程处于阻塞状态
从s.queue中取出第一个进程p;
p进入就绪队列;
}
}
为临界资源设置一个信号量mutex(Mutex Exclusion;互斥),其初值为1;在每个进程中,将临界区代码置于P(mutex)和V(mutex)原语之间,它们必须成对使用。
利用信号量描述前驱关系(s是信号量):
第二节 经典进程同步问题
一、简单的生产者 — 消费者问题(Dijkstra)
设有一个生产者进程P和一个消费者进程Q,它们通过一个缓冲区联系起来。
处理问题的办法:
- 生产者进程 P
while(true){
P(empty);
生产一个产品;
送入缓冲区;
V(full)
}
empty和full是代表缓冲区空和满的信号量。
- 消费者进程 Q
while(true){
P(full);
取产品;
V(empty);
消费产品;
}
二、多个生产者 — 多个消费者的问题
1、缓冲区池
利用缓冲区池,池的大小为k,由k个大小相等的缓冲区构成;缓冲区磁是临界资源。
设置信号量empty,初值k:代表空闲缓冲区数目;
设置信号量full,初值0:代表已满缓冲区数目。
2、互斥问题
设置信号量mutex,初值1,用于实现缓冲区池的互斥;
整型i,初值0:无产品的空闲缓冲区的头指针;
整型j,初值0;有产品的已满缓冲区的头指针。
3、算法
生产者们:
p1, p2, ..., pn;
i := 0;
while(true){
生产产品;
P(empty);
P(mutex);
往buffer[i]中放产品;
i := (i + 1) mod k;
V(mutex);
V(full);
}
消费者们:
Q1, Q2, ..., Qn;
j := 0;
while(true){
P(full);
P(mutex);
从buffer[j]中取产品;
j := (j + 1) mod k;
V(mutex);
V(empty);
消费产品;
}
三、读者 — 写者问题
一个数据对象(比如一个文件)是可以由多个进程共享的。
1、读者 — 写者问题描述
设某共享文件F,系统允许若干进程对文件进行读或者写;将读进程称为"读者",写进程称为"写者";它们都遵循以下规定:
- 多个进程可以同时读F;
- 任一进程写F时,不允许其它进程读或者写;
- 当由进程读F时,不允许其他进程写。
read_count:记录正在读F的进程个数,它是一个共享变量,需要互斥使用,因此为其设置信号量mutex。
再设置write信号量,用于写着之间的互斥,或者第一个读者和最后一个读者与写者之间的互斥。
2、读者 — 写者问题的解决方案
读者进程:
while(true){
P(mutex);
read_count := read_count + 1;
if(read_count = 1){
P(write);
}
V(mutex);
读文件;
P(mutex);
read_count := read_count - 1;
if(read_count = 0){
V(write);
}
V(mutex);
}
写者进程:
while(true){
P(write);
写文件;
V(write);
}
第三节 死锁
一、死锁的定义
死锁并非计算机OS独有,生活中也很常见。
多道程序系统中,一组进程中的进程无限期地等待被另一进程占用的资源时,称系统处于死锁状态,简称死锁。
系统发生死锁时,死锁进程的数目至少为两个。
二、死锁产生的原因
原因1:竞争资源,分配失误。
原因2:多道程序运行时,进程推进顺序不合理。
1、资源的概念
- 永久性资源:可重用资源,比如:内存、外设、CPU、数据等等。
- 临时性资源:消耗性资源,比如:I/O和时钟中断信号、同步信号、消息等等。
它们都可能导致死锁。
2、死锁的例子
- 申请不同类型资源,互补相让。
- 申请同类找资源。
三、死锁产生的必要条件
对永久性资源产生死锁有4个必要条件(Coffman等人于1971年提出):
- 互斥条件:资源独占且排它,同一时刻只能给一个进程使用。
- 不可剥夺条件:进程所获得的资源在未使用完之前,不能被其他进程占用。
- 请求和条件保持:又称"部分分配"或"占有申请";进程先申请它所需的一部分资源,得到后再申请新资源,在申请新资源的同时,继续占有自己已分配到的资源。
- 循环等待的条件:又称"环路等待",形成一个进程等待的环路。
解决死锁问题的两种思路:
1.不让死锁发生,阻断可能产生死锁的条件。
2.检测死锁是否发生,若发生则干预解决。
四、死锁发生后的处理办法
1、死锁预防
进程限制最严,并发程度最低。
破坏产生死锁的必要条件。(前面条件之一即可。)
2、死锁避免
进程限制较严,并发程度较低。
思想:系统对进程发生的每一个系统能满足的资源申请进行动态检查,并根据结果决定是否分配;若可能发生死锁则不分配。
最著名的避免死锁的算法是由Dijkstra和Habermanner提出的银行家算法:
- 将OS比作是银行家;
- 资源比作是资金;
- 申请资源就是贷款。
在此基础上对贷款的还款进行预测,然后决定是否贷款给进程。
3、死锁的检测与解除
进程限制较松,并发程度较高。
原理:系统中设置检测机构,定时检测,并能确定与死锁有关的进程与资源,然后采取措施解决。
检测的实质是确定是否存在"循环等待条件";解除的实质是让某个进程释放一个或多个资源。
解除死锁方法的分类:
- 剥夺资源:恢复到某资源分配前。
- 撤销进程:考虑优先级和代价。
且解锁后都要再次进行检测。
4、忽略死锁
进程限制松,并发程度高。
一般用在概率低的情况下。
第四节 哲学家就餐问题及其他实例
一、哲学家就餐问题
五个哲学家坐在一张圆桌旁,每个哲学家面前有一个盘子,哲学家们只有两种活动:思考和吃饭。每个哲学家在吃饭时需要同时使用左右两边的叉子(每个叉子只能由相邻的哲学家共享)。
问题是如何设计一种算法,使得哲学家们能有效地吃饭而不会发生死锁(每个哲学家都拿起一个叉子后互相等待另一个叉子)或饥饿(某个哲学家无法拿到两个叉子而永远无法吃饭)。
避免死锁的办法:
使用资源的有序分配法实现;即规定每个哲学家想用餐是总是先拿编号小的餐具,拿到后再去拿编号较大的餐具。拿不到小的就不去拿大的。
- 哲学家1:先拿1后拿2;
- 哲学家2:先拿2后拿3;
- 哲学家3:先拿3后拿4;
- 哲学家4:先拿4后拿5;
- 哲学家5:先拿1后拿5。\star\star\star\star\star
二、其他示例
1、问题描述
集装箱中转枢纽,用于货物的的临时存储和中转,一次只能接收某一个方向的集装箱物流处理。
2、分析
抽象为两个生产者和两个消费者问题。
经过分析可知:
- 生产者之家是互斥的:不能同时使用枢纽。
- 生产者1和消费者1要同步:生产者1使用枢纽后紧接着消费者1要去处理货物。
- 生产者2和消费者2要同步:生产者2使用枢纽后紧接着消费者2要去处理货物。
因此,信号量的定义:
1.设置是是否允许进入枢纽的信号量site,其初值为1,代表允许进入。
2.设置生产者1是否达到的信号量arrive_A,其初值为0,代表未到达。
3.设置生产者2是否达到的信号量arrive_B,其初值为0,代表未到达。
3、算法
//生产者1
while(true){
生产者1准备卸货;
P(site);
生产者1卸货;
V(arrive_A);
};
//生产者2
while(true){
生产者2准备卸货;
P(site);
生产者2卸货;
V(arrive_B);
};
//消费者1
while(true){
P(arrive_A);
消费者1装货;
V(site);
};
//消费者2
while(true){
P(arrive_B);
消费者2装货;
V(site);
};
附录A 哲学家就餐问题
哲学家就餐问题(Dining Philosophers Problem)是计算机科学中一个经典的同步问题,由Edsger W. Dijkstra在1965年提出。这个问题用于说明和研究多线程编程中的同步和死锁问题。
问题描述
五个哲学家坐在一张圆桌旁,每个哲学家面前有一个盘子,哲学家们只有两种活动:思考和吃饭。每个哲学家在吃饭时需要同时使用左右两边的叉子(每个叉子只能由相邻的哲学家共享)。问题是如何设计一种算法,使得哲学家们能有效地吃饭而不会发生死锁(每个哲学家都拿起一个叉子后互相等待另一个叉子)或饥饿(某个哲学家无法拿到两个叉子而永远无法吃饭)。
解决方案
1. 简单的解决方案
最简单的解决方案是使用互斥锁,确保每个哲学家在吃饭时能拿到两个叉子,并在吃完后放下。这种方法容易导致死锁。
pthread_mutex_t forks[5];
void* philosopher(void* num) {
int id = *(int*)num;
while (1) {
// 思考
printf("Philosopher %d is thinking.\n", id);
// 拿起左边的叉子
pthread_mutex_lock(&forks[id]);
// 拿起右边的叉子
pthread_mutex_lock(&forks[(id + 1) % 5]);
// 吃饭
printf("Philosopher %d is eating.\n", id);
// 放下右边的叉子
pthread_mutex_unlock(&forks[(id + 1) % 5]);
// 放下左边的叉子
pthread_mutex_unlock(&forks[id]);
}
}
int main() {
pthread_t philosophers[5];
int ids[5];
for (int i = 0; i < 5; i++) {
pthread_mutex_init(&forks[i], NULL);
ids[i] = i;
}
for (int i = 0; i < 5; i++) {
pthread_create(&philosophers[i], NULL, philosopher, &ids[i]);
}
for (int i = 0; i < 5; i++) {
pthread_join(philosophers[i], NULL);
}
for (int i = 0; i < 5; i++) {
pthread_mutex_destroy(&forks[i]);
}
return 0;
}
2. 避免死锁的解决方案
为了避免死锁,可以采用以下策略:
- 资源有序分配法:规定一个资源分配顺序,哲学家总是先拿编号小的叉子再拿编号大的叉子,这样可以避免循环等待的情况。
void* philosopher(void* num) {
int id = *(int*)num;
int first = id;
int second = (id + 1) % 5;
// 确保按顺序拿起叉子
if (id == 4) {
first = (id + 1) % 5;
second = id;
}
while (1) {
// 思考
printf("Philosopher %d is thinking.\n", id);
// 拿起第一只叉子
pthread_mutex_lock(&forks[first]);
// 拿起第二只叉子
pthread_mutex_lock(&forks[second]);
// 吃饭
printf("Philosopher %d is eating.\n", id);
// 放下第二只叉子
pthread_mutex_unlock(&forks[second]);
// 放下第一只叉子
pthread_mutex_unlock(&forks[first]);
}
}
- Chandy/Misra 解决方案:这种解决方案使用信号量和资源分配图来避免死锁和饥饿。
在最简单的例子中,哲学家0先拿起左边的叉子(叉子0),然后再拿起右边的叉子(叉子1)。吃完后放下右边的叉子,再放下左边的叉子。这样做的好处是逻辑简单,但容易导致死锁。
通过资源有序分配法,哲学家总是先拿编号小的叉子再拿编号大的叉子。哲学家4会先拿起右边的叉子(叉子0),再拿起左边的叉子(叉子4)。这种方法确保了没有循环等待,从而避免了死锁。
附录B 资源分配图
资源分配图(Resource Allocation Graph,简称RAG)是一种用于描述和分析系统中进程和资源之间的关系的图形工具,特别是在研究死锁问题时非常有用。它是一种有向图,其中:
- 顶点(节点):分为两种类型:
- 进程节点:表示系统中的进程,通常用圆圈表示。
- 资源节点:表示系统中的资源,通常用方框表示。每个资源可以有多个实例(也称为资源单元),实例数量通常标在资源节点的内部。
- 边(弧):也分为两种类型:
- 请求边:由进程指向资源,表示进程请求该资源。通常用箭头指向资源节点。
- 分配边:由资源指向进程,表示资源已分配给该进程。通常用箭头指向进程节点。
资源分配图示例
下面是一个资源分配图的简单示例:
P1 P2 P3
o o o
| | |
R1 R2 R3
[1] [2] [1]
在这个例子中:
P1
,P2
,P3
是进程节点。R1
,R2
,R3
是资源节点。R1
有一个实例,R2
有两个实例,R3
有一个实例。- 箭头表示请求边和分配边。
死锁检测
在资源分配图中,如果存在一个环(循环),则可能会发生死锁。这是因为环表示一个闭合的等待链,各个进程相互等待彼此持有的资源,从而形成死锁。
实例说明
我们来详细说明一个包含死锁情况的资源分配图。
初始状态
P1 P2
o o
| |
R1[1] R2[1]
P1
请求R1
,而P2
请求R2
。
资源分配后
假设资源已经分配给进程,如下所示:
P1 ←──── R1[1]
→ P2 ←──── R2[1]
└───→
P1
持有R1
,并请求R2
。P2
持有R2
,并请求R1
。
检测死锁
在上述状态中,有一个循环:P1
等待R2
,R2
由P2
持有,P2
等待R1
,而R1
由P1
持有。这种循环表示一个死锁情况。
资源分配图的使用
资源分配图的主要用途包括:
- 死锁预防:通过设计资源分配策略,避免形成循环等待。
- 死锁检测:定期检测资源分配图中的环,以识别死锁。
- 死锁恢复:一旦检测到死锁,采取措施(如终止进程或撤销资源分配)来打破循环。
资源分配图的变化
在实际系统中,资源分配图会随着进程请求和释放资源而动态变化。例如:
- 请求资源:当进程请求资源时,添加请求边。
- 分配资源:当资源分配给进程时,转换请求边为分配边。
- 释放资源:当进程释放资源时,移除相应的分配边。
示例:哲学家就餐问题
假设有五个哲学家(P1, P2, P3, P4, P5)和五个叉子(R1, R2, R3, R4, R5),可以用资源分配图来表示他们对叉子的请求和分配情况。
初始状态
所有哲学家都在思考,没有请求或持有叉子。
请求叉子
假设哲学家P1请求R1和R2:
P1 P2 P3 P4 P5
o o o o o
| | | | |
R1[1] R2[1] R3[1] R4[1] R5[1]
分配叉子
假设R1和R2分配给P1:
P1 ←──── R1[1]
└────→ R2[1]
P2 P3 P4 P5
o o o o o
检测死锁
如果所有哲学家都请求并持有叉子,很可能形成循环等待,导致死锁。这可以通过资源分配图的环来检测。