第一节 进程的同步与互斥

无关并发进程一定没有共享变量。

一、与时间有关的错误

一个共享资源再一个进程使用未结束时,另一个进程也开始使用;这样就会引起错误;这类错误统称为与时间有关的错误。

二、进程同步与进程互斥

进程互斥(Process Mutual Exclusion)是指在某一时刻,只允许一个进程访问共享资源(如共享内存、文件等),以防止多个进程同时访问时产生冲突或不一致。是一种现象。

进程同步(Process Synchronization)是指协调多个进程的执行顺序,以确保它们按照预期的顺序访问共享资源或完成特定任务。进程同步确保进程之间的相互依赖关系得到正确处理。是一种方法。

举个例子:

在餐馆里,厨师和服务员的工作需要协调进行:

  1. 厨师做饭:厨师负责准备和烹饪食物,这需要一定的时间。
  2. 服务员上菜:只有在厨师做好菜后,服务员才能将菜送到客人的桌子上。

这个过程需要同步,服务员必须等待厨师完成一道菜后才能上菜。否则,服务员就没有菜可以上,无法完成工作。

假设一个大学校园里有一辆共享自行车,学生们可以使用它从一个地方骑到另一个地方:

  1. 学生A骑车:学生A拿到自行车后开始骑行。
  2. 学生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是信号量):

【操作系统】第八章 进程同步机制与死锁_互斥_03

第二节 经典进程同步问题

一、简单的生产者 — 消费者问题(Dijkstra)

设有一个生产者进程P和一个消费者进程Q,它们通过一个缓冲区联系起来。

【操作系统】第八章 进程同步机制与死锁_同步_04

处理问题的办法:

  • 生产者进程 P
while(true){
     P(empty);
     生产一个产品;
     送入缓冲区;
     V(full)
 }

empty和full是代表缓冲区空和满的信号量。

  • 消费者进程 Q
while(true){
     P(full);
     取产品;
     V(empty);
     消费产品;
 }

二、多个生产者 — 多个消费者的问题

1、缓冲区池

利用缓冲区池,池的大小为k,由k个大小相等的缓冲区构成;缓冲区磁是临界资源。

【操作系统】第八章 进程同步机制与死锁_同步_05

设置信号量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、忽略死锁

进程限制松,并发程度高。

一般用在概率低的情况下。

第四节 哲学家就餐问题及其他实例

一、哲学家就餐问题

五个哲学家坐在一张圆桌旁,每个哲学家面前有一个盘子,哲学家们只有两种活动:思考和吃饭。每个哲学家在吃饭时需要同时使用左右两边的叉子(每个叉子只能由相邻的哲学家共享)。

问题是如何设计一种算法,使得哲学家们能有效地吃饭而不会发生死锁(每个哲学家都拿起一个叉子后互相等待另一个叉子)或饥饿(某个哲学家无法拿到两个叉子而永远无法吃饭)。

【操作系统】第八章 进程同步机制与死锁_信号量_06

避免死锁的办法:

使用资源的有序分配法实现;即规定每个哲学家想用餐是总是先拿编号小的餐具,拿到后再去拿编号较大的餐具。拿不到小的就不去拿大的。

  • 哲学家1:先拿1后拿2;
  • 哲学家2:先拿2后拿3;
  • 哲学家3:先拿3后拿4;
  • 哲学家4:先拿4后拿5;
  • 哲学家5:先拿1后拿5。\star\star\star\star\star

二、其他示例

1、问题描述

集装箱中转枢纽,用于货物的的临时存储和中转,一次只能接收某一个方向的集装箱物流处理。

【操作系统】第八章 进程同步机制与死锁_同步_07

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. 避免死锁的解决方案

为了避免死锁,可以采用以下策略:

  1. 资源有序分配法:规定一个资源分配顺序,哲学家总是先拿编号小的叉子再拿编号大的叉子,这样可以避免循环等待的情况。
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]);
     }
 }
  1. 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等待R2R2P2持有,P2等待R1,而R1P1持有。这种循环表示一个死锁情况。

资源分配图的使用

资源分配图的主要用途包括:

  1. 死锁预防:通过设计资源分配策略,避免形成循环等待。
  2. 死锁检测:定期检测资源分配图中的环,以识别死锁。
  3. 死锁恢复:一旦检测到死锁,采取措施(如终止进程或撤销资源分配)来打破循环。

资源分配图的变化

在实际系统中,资源分配图会随着进程请求和释放资源而动态变化。例如:

  • 请求资源:当进程请求资源时,添加请求边。
  • 分配资源:当资源分配给进程时,转换请求边为分配边。
  • 释放资源:当进程释放资源时,移除相应的分配边。

示例:哲学家就餐问题

假设有五个哲学家(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

检测死锁

如果所有哲学家都请求并持有叉子,很可能形成循环等待,导致死锁。这可以通过资源分配图的环来检测。