您的当前位置:首页正文

操作系统——进程同步

2024-11-28 来源:个人技术集锦


进同步和互斥

1.什么是进程同步和进程互斥?

进程同步

进程具有异步性,异步性是指,各个并发执行的进程以各自独立的、不可预知的速度向前推进。

比如说进程间通信的管道,读进程和写进程并发地运行,由于并发必然导致异步性,因此“写数据”和“读数据”两个操作执行的先后顺序是不确定的。而实际应用中,又必须按照“写数据—>读数据”的顺序来执行的。

同步亦称直接制约关系,它是指为完成某种任务而建立的两个或多个进程,这些进程因为需要在某些位置上协调它们的工作次序而产生的制约关系。进程间的直接制约关系就是源于它们之间的相互合作。

进程互斥

进程的“并发”需要“共享”的支持。各个并发执行的进程不可避免的需要共享一些系统资源(比如内存,又比如打印机、摄像头这样的I/O设备)

  • 互斥共享方式:系统中的某些资源,虽然可以提供给多个进程使用,但一个时间段内只允许一个进程访问该资源
  • 同时共享方式:系统中的某些资源,允许一个时间段内由多个进程“同时”(宏观上)对它们进行访问

我们把一个时间段内只允许一个进程使用的资源称为临界资源。许多物理设备(比如摄像头、打印机)都属于临界资源。此外还有许多变量、数据、内存缓冲区等都属于临界资源。
对临界资源的访问,必须五斥地进行。互斥,亦称间接制约关系。进程互斥指当一个进程访问某临界资源时,另一个想要访问该临界资源的进程必须等待。当前访问临界资源的进程访问结束,释放该资源之后,另一个进程才能去访问临界资源。

对临界资源的互斥访问,可以在逻辑上分为如下四个部分:

  • 进入区:负责检查是否可进入临界区,若可进入,则应设置正在访问临界资源的标志(可理解为“上锁”) ,以阻止其他进程同时进入临界区
  • 临界区:访问临界资源的那段代码
  • 退出区:负责解除正在访问临界资源的
    标志(可理解为“解锁”)
  • 剩余区:做其它处理,除了进入区、临界区以及退出区以外的代码。

类似于Java中的加锁,伪代码。

// 锁
ReentrantLock lock= new ReentrantLock();
lock.lock();// 进入区
一些代码......  // 临界区
lock.unlock();// 退出区
// 剩余区
//其它处理代码......

注意:
临界区是进程中访问临界资源的代码段
进入区退出区负责实现互斥的代码段
临界区也可称为“临界段”

为了实现对临界资源的互斥访问,同时保证系统整体性能,需要遵循以下原则:

2.进程互斥的软件实现方式

如果没有注意进程互斥会发生什么?

假设进程A和进程B在系统中并发执行。

先调度A上处理机运行
当A在使用打印机的过程中,分配给它的时间片用完了,接下来操作系统调度B让它上处理机运行
进程B也在使用打印机
结局:A、B的打印内容混在一起了

单标志法

算法思想:两个进程在访问完临界区后会把使用临界区的权限转交给另一个进程。也就是说每个进程进入临界区的权限只能被另一个进程赋予

全局变量

int turn = 0; // turn表示当前允许进入临界区的进程号

P0进程

while (turn != 0) {}//1 进入区
critical section;//2 临界区
turn = 1;//3 退出区
reaminder section;//4 剩余区

P1进程

while (turn != 1) {}//5 进入区
critical section;//6 临界区
turn = 1;//7 退出区
reaminder section;//8 剩余区

turn 的初值为0,即刚开始只允许0号进程进入临界区。
若 P1先上处理机运行,则会一直卡在5。直到 P1 的时间片用完,发生调度,切换 P0 上处理机运行代码1不会卡住 P0,P0 可以正常访问临界区,在 P0访问临界区期间即时切换回 P1,P1依然会卡在6只有 P0在退出区将 turn 改为1后,P1才能进入临界区。
因此,该算法可以实现“同一时刻最多只允许一个进程访问临界区”

双标志检查法

算法思想:设置一个布尔型数组 flag,数组中各个元素用来标记各进程想进入临界区的意愿,比如“flag[0]= ture”意味着0号进程 P0 现在想要进入临界区。每个进程在进入临界区之前先检查当前有没有别的进程想进入临界区,如果没有,则把自身对应的标志 flag[i] 设为 true,之后开始访问临界区

bool flag[2]; // 表示进入临界区意愿的数组
flag[0] = false; // 刚开始设置为两个进程都不想进入临界区
flag[1] = false;

P0进程

while (flag[1]){}//1
flag[0] = true; // 2 标记为 P0想要金灿荣临界区
critical section; //3 访问临界区
flag[0] = false; // 4 访问完临界区,修改标记为P0不想使用临界区
remainder section;

P1进程

while (flag[0]){}//5
flag[1] = true; // 6
critical section; //7
flag[1] = false; // 8
remainder section;

若按照1/5/2/6/3/7…的顺序执行,P0 和 P1将会同时访问临界区
因此,双标志先检查法的主要问题是:违反“忙则等待”原则
原因在于,进入区的“检查”和“上锁”两个处理不是一气呵成的
“检查”后,“上锁”前可能发生进程切换。

双标志后检查法

算法思想:双标志先检查法的改版。前一个算法的问题是先“检查”后“上锁”,但是这两个操作又无法一气呵成,因此导致了两个进程同时进入临界区的问题。因此,人们又想到先“上锁”后“检查的方法,来避免上述问题。

bool flag[2]; // 表示进入临界区意愿
flag[0] = false;
flag[1] = flase;

P0进程:

flag[0] = true; // 1 标记为P1进程想要进入临界区
while (flag[1]){}//2 如果P1也想进入临界区,则P0循环等待
critical section; // 3  访问临界区
flag[0] = false; // 4  访问完临界区,修改标记为P1不想使用临界区
remainder section;

P1进程:

flag[0] = true; // 5
while (flag[1]){}// 6
critical section; // 7
flag[0] = false; // 8
remainder section;

若按照1、5、2、6…的顺序执行,P0和P1将都无法进入临界区
双标志后检查法虽然**解决了“忙则等待”问题,但是又违背了“空闲让进”和“有限等待“原则,
因此,会因各进程都长期无法访问临界资源而
产生“饥饿”**现象。

Peterson算法

算法思想:结合双标志法、单标志法的思想。如果双方都争着想进入临界区,那可以让进程尝试“孔融让梨”(谦让)。做一个有礼貌的进程。

bool flag[2]; // 表示进入临界区意愿的数组,初始值都是false
int turn = 0; // turn表示优先那个进程进入临界区

P0进程

flag[0] = true;//1
turn = 1;//2
while (flag[1] && turn == 1);//3
critical section;//4
flag[0] = false;//5
remainder section;

P1进程:

flag[1] = true;//6 表示自己想进入临界区
turn = 0;//7 可以优先让对方进入临界区
while (flag[0] && turn == 0);//8 对方想进,且最后一次是自己谦让对方,那么自己就得等待
critical section;//9
flag[1] = false;//10 访问完临界区,表示自己已经不想访问临界区了
remainder section;

有进程的调度,1~10可能会有多种调度顺序

比如说:

  • 123678
  • 1623
  • 13678
  • 16278

turn和flag都是全局变量,看两进程谁最后修改就表示谁谦让,就要循环等待执行。

Peterson 算法用软件方法解决了进程互斥问题,遵循了空闲让进、忙则等待、有限等待 三个原则,但是依然未遵循让权等待的原则。

3. 进程互斥硬件实现方法

中断屏蔽方法

利用“开/关中断指令”实现(与原语的实现思想相同,即在某进程开始访问临界区到结束访问为止都不允许被中断,也就不能发生进程切换,因此也不可能发生两个同时访问临界区的情况)

关中断后即不允许当前进程被中断,也必然不会发生进程切换。

直到当前进程访问完临界区,再执行开中断指令,才有可能有别的进程上处理机并访问临界区

优点:简单、高效
缺点:不适用于多处理机,只适用于操作系统内核进程,不适用于用户进程(因为开/关中断指令只能运行在内核态,这组指令如果能让用户随意使用会很危险)

TestAndSet指令

简称TS 指令,也有地方称为 TestAndSetLock 指令,或TSL 指令TSL 指令是用硬件实现的,执行的过程不允许被中断,只能一气呵成。

以下是用C语言实现的伪代码逻辑:

//布尔类型共享变量,表示当前临界区是否被加锁
//true 表示已经加锁,false 表示未加锁
bool TestAndSet(bool *lock)
{
    bool old;
    old = *lock; //old用来存放lock原来的值
    *lock = true;// 无论之前是否已经加锁,都将lock设为true
    return old;
}
// 以下是使用TSL指令实现互斥的算法逻辑
while (TestAndSet(&lock)) { // 上锁并检查
   
}
临界区代码
lock = false; // 解锁
剩余区代码段...

若刚开始lock是 false,则TSL 返回的old 值为falsewhile 循环条件不满足,直接跳过循环,进入临界区。若刚开始lock 是 true,则执行TLS 后old 返回的值为 rue,while 循环条件满足,会一直循环,直到当前访问临界区的进程在退出区进行“解锁”。
相比软件实现方法,TSL 指令把“上锁”和“检查”操作用硬件的方式变成了一气呵成的原子操作
优点:实现简单,无需像软件实现方法那样严格检查是否会有逻辑漏洞:适用于多处理机环境

缺点:不满足“让权等待”原则,暂时无法进入临界区的进程会占用CPU并循环执行TSL指令,从而导致“忙等”

Swap指令

有的地方也叫 Exchange 指令,或简称 XCHG 指令。Swap 指令是用硬件实现的,执行的过程不允许被中断,只能一气呵成。

以下是用C语言实现的伪代码逻辑:

//Swap指令的作用是交换两个变量的值
Swap(bool* a,bool *b)
{
    bool temp;
    temp = *a;
    *a = *b;
    *b = temp;
}
// 以下是用Swap指令实现互斥的算法逻辑
//lock表示当前临界区是否被加锁
bool old = true;
while (old == true)
{
    Swap(&lock,&old);
}
临界区代码段...
lock = false;
剩余区代码段...

逻辑上来看 Swap 和 TSL 并无太大区别,都是先记录下此时临界区是否已经被上锁(记录在 old 变量上),再将上锁标记lock 设置为 true,最后检查 d,如果 old 为 false 则说明之前没有别的进程
对临界区上锁,则可跳出循环,进入临界区。

  • 优点:实现简单,无需像软件实现方法那样严格检查是否会有逻辑漏洞:适用于多处理机环境
  • 缺点:不满足“让权等待”原则,暂时无法进入临界区的进程会占用CPU并循环执行TSL指令,从而导致“忙等”。

4. 互斥锁

解决临界区最简单的工具就是互斥锁 (mutex lock)。一个进程在进入临界区时应获得锁
在退出临界区时释放锁。函数 acquire()获得锁,而函数 release()释放锁。
每个瓦斥锁有一个布尔变量 available,表示锁是否可用。如果锁是可用的,调用 acqiure()会
成功,且锁不再可用。当一个进程试图获取不可用的锁时,会被阻塞,直到锁被释放。

acquire() 
{
    while (!available)
    {
        // 忙等
    }
    available = false;
}
release() 
{
    available = true; // 释放锁
}

acquire()或 release()的执行必须是原子操作,因此互斥锁通常采用硬件机制来实现。
互斥锁的主要缺点是忙等待,当有一个进程在临界区中,任何其他进程在进入临界区时必须
连续循环调用 acquire()。当多个进程共享同一 CPU时,就浪费了CPU 周期。因此,互斥锁通常
用于多处理器系统,一个线程可以在一个处理器上等待,不影响其他线程的执行。

需要连续循环忙等的互斥锁,都可称为自旋锁 (spin lock),如 TSL指令、swap指令、单标志法。

特性:

  • 需忙等,进程时间片用完才下处理机,违反“让权等待”
  • 优点:等待期间不用切换进程上下文,多处理器系统中,若上锁的时间短,则等待代价很低
  • 常用于多处理器系统,一个核忙等,其他核照常工作,并快速释放临界区
  • 不太适用于单处理机系统,忙等的过程中不可能解锁

Java的伪代码

// 锁
ReentrantLock lock= new ReentrantLock();
lock.lock();// 进入区,加锁
一些代码......  // 临界区
lock.unlock();// 退出区,解锁
// 剩余区
//其它处理代码..

5. 信号量机制

在双标志先检查法中,进入区的“检查”、“上锁”操作无法一气呵成,从而导致了两个进程有可能同时进入临界区的问题;

1965年,荷兰学者Diikstra提出了一种卓有成效的实现进程互斥、同步的方法–信号量机制

用户进程可以通过使用操作系统提供的一对原语对信号量进行操作,从而很方便的实现了进程互斥、进程同步。
信号量其实就是一个变量 (可以是一个整数,也可以是更复杂的记录型变量),可以用一个信号量来表示系统中某种资源的数量,比如: 系统中只有一台打印机,就可以设置一个初值为1的信号量

一对原语: wait(S) 原语和 signal(S) 原语,可以把原语理解为我们自己写的函数,函数名分别为 wait和 signal,括号里的信号量 S 其实就是函数调用时传入的一个参数。
wait、signal 原语常简称为 P、V操作 (来自荷兰语 proberen 和 verhogen)。因此,做题的时候常把wait(S)、signal(S) 两个操作分别写为 P(S)、V(S)

信号量的值=这种资源的剩余数量(信号量的值如果小于0,说明此时有进程在等待这种资源)

  • P(S)–申请一个资源S,如果资源不够就阻塞等待
  • V(s)–释放一个资源S,如果有进程在等待该资源,则唤醒一个进程

整形信号量

用一个整形的变量作为信号量,用来表示操作系统中某种资源的数量

比如说有一个打印机资源只有一个,而有多个进程同时需要进行使用,就会进行抢占,但整形信号量的检查和上锁两个操作是一气呵成的,避免发并发场景下的问题。

int S = 1; //资源数量,比如说打印机
void wait(int S) {// wait原语,相当于进入区
    while (S <= 0) { // 资源不够就一直忙等
        S = S-1;//申请资源
    }
}
void signal (int S) {//signal原语,相当于退出区
    S = S+1; // 使用后释放资源
}

P0进程

wait(S);// 进入临界区
//使用打印机资源   临界区,访问资源
signal(S);//退出区,释放资源

P1进程、

wait(S);// 进入临界区
//使用打印机资源   临界区,访问资源
signal(S);//退出区,释放资源

Pn进程

wait(S);// 进入临界区
//使用打印机资源   临界区,访问资源
signal(S);//退出区,释放资源

需要注意的是,操作系统的整形信号量在获取不到打印机资源的时候会循环获取资源,会有忙等问题

整形信号量存在的问题:不满足“让权等待”原则,会发生“忙等”

记录型信号量

整型信号量的缺陷是存在“忙等”问题,因此人们又提出了**“记录型信号量”**,即用记录型数据结构表示的信号量。

S.value 的初值表示系统中某种资源的数目
对信号量 S 的一次 P操作意味着进程请求一个单位的该类资源,因此需要执行 S.value–,表示资源数减1,当
s.value<0时表示该类资源已分配完毕,因此进程应调用 block 原语进行自我阻塞(当前运行的进程从运行态–>阻塞态),主动放弃处理机,并插入该类资源的等待队列 S.waitQueue中。可见,该机制遵循了“让权等待”原则,不会出现“忙等”现象。

伪代码:

typedef Semaphore 
{
    int val; // 剩余资源数量
    struct Queue* waitQueue;// 等待队列
}

假设某进程需要使用资源时,使用原语wait申请

如果剩余资源数不够使用block原语使进程从运行态进入阻塞态,并把挂到信号量 S 的等待队列(即阻塞队列) 中

void wait(semaphore S)
{
	S.value--;
	if (S.value < 0)
	{
	
		block(S.waitQueue);
	}
}

进程使用完毕后,通过signal原语释放

释放资源后,若还有别的进程在等待这种资源,则使用wakeup 原语唤醒等待队列中的一个进程,该进程从阻塞态为就绪态

void signal(semaphore S)
{
    S.value++;
    if (S.value <= 0)
    {
        wakeup(S.waitQueue)
    }
}

对信号量 S的一次 V操作意味着进程释放一个单位的该类资源,因此需要执行 s.velue++,表示资源数加1若加1后仍是 s.value<= 0,表示依然有进程在等待该类资源,因此应调用 wakeup 原语唤醒等待队列中的第一个进程(被唤醒进程从阻塞态—>就绪态)

记录型型号量遵循了让权等待原则,因为当进程获取不到资源时,就会进行阻塞,当操作系统中有其它进程释放了对应资源,那么调用wakeup原语唤醒等待队列中第一个进行,让其从阻塞态变为就绪态,让进程获取对应资源。

用信号量实现进程同步互斥

实现进程互斥
  1. 分析并发进程的关键活动,划定临界区(如:对临界资源打印机的访问就应放在临界区
  2. 设置互斥信号量 mutex,初值为 1
  3. 在进入区P(mutex):申请资源
  4. 在退出区V(mutex):释放资源
P1() {
    //...
    P(mutex);// 使用临界资源前需要加锁
    //临界区代码段...
    V(mutex);// 使用临界区后需要解锁
}
P2() {
    P(mutex);
    //临界区代码段...
    V(mutex);
}

注意:对不同的临界资源需要设置不同的互斥信号量
P、V操作必须成对出现。缺少P(mutex) 就不能保证临界资源的互斥访间。缺少 V(mutex) 会导致资源永不被释放,等待进程永不被唤醒。

进程的互斥:

  • 在进入临界区之前对信号量执行P操作
  • 在退出临界区值后对信号量执行V操作
实现进程同步

进程同步:要让各个并发进程要求有序的推进

比如,P1、P2 并发执行,由于存在异步性,因此二者交替推进的次序是不确定的。若 P2 的“代码4”要基于 P1 的“代码1”和“代码2”的运行结果才能执行,那么我们就必须保证“代码4”一定是在“代码2”之后才会执行。

P1()
{
    代码1;
    代码2;
    代码3;
}
P2()
{
    代码4;
    代码5;
    代码6;
}
  1. 设置同步信号量为S,初始值为0
semaphore S = 0;
  1. 如果要实现进程同步
    • 在“前操作”之后执行V操作
    • 在“后操作”之前执行P操作
P1()
{
    代码1;
    代码2;
    V(S);// V表示释放资源,执行V操作后S会+1
    代码3;
}
P2()
{
    P(S);//执行P操作申请资源,如果S为0,P就会阻塞
    代码4;
    代码5;
    代码6;
}

如下图:

用P、V操作表示同步操作

1.利用信号量表示进程是否执行完成

设置3个信号量对应T1、T2、T3

S 1 = 0 、 S 2 = 0 、 S 3 = 0 S1=0、S2=0、S3=0 S1=0S2=0S3=0

P1() {
    T1;
    V(S1);
    V(S1);
}
P2() {
    P(S1);
    T2;
    V(S2);
}
P3(){
    P(S1);
    T3;
    V(S3);
}
P4() {
    P(S2);
    P(S3);
    T4;
}
  1. 先找出所有的直接前驱关系,对每种直接前驱关系设置信号量

    根据前驱关系设置,信号量, a = 0 , b = 0 , c = 0 , d = 0 a=0,b=0,c=0,d=0 a=0,b=0,c=0,d=0

P1() {
    T1;
    V(a);
    V(b);
}
P2() {
    P(a);
    T2;
    V(c);
}
P3() {
    P(b);
    T3;
    V(d);
}
P4() {
    V(c);
    V(b);
    T4;
}

生产者消费者问题

系统中有一组生产者进程和一组消费者进程,生产者进程每次生产一个产品放入缓冲区,消费者
进程每次从缓冲区中取出一个产品并使用。

  • 生产者、消费者共享一个初始为空、大小为n的缓冲区

  • 只有缓冲区没满时,生产者才能把产品放入缓冲区,否则必须等待

  • 只有缓冲区不空时,消费者才能从中取出产品,否则必须等待。

  • 缓冲区是临界区,各个进程必须互斥访问

semaphore mutex = 1;//互斥信号量,实现对缓冲区的互斥访问
semaphore empty = n;//同步信号量,表示空闲缓冲区的数量
semaphore full =0://同步信号量,表示产品的数量,也即非空缓冲区的数量

生产者

producer() {
    while(1) {
        // 生产一个产品
        P(empty);//消耗一个空闲缓冲区
        P(mutex);//实现互斥是在同一个进程进程PV操作
        //把产品放入缓冲区
        V(mutex);
        V(full);//增加一个产品
    }

消费者

consumer(){
    while (1) {
        P(full);// 消耗一个产品(非空缓冲区)
        P(mutex);
        // 从缓冲区取出一个产品
        V(mutex);
        V(empty);// 增加一个空闲缓冲区
        // 使用产品
    }
}
信号量死锁问题

如果改变上面代码相邻P的顺序就会发生死锁。

假设此时缓冲区已经满了

假设有两个进程,生产者进程执行完P(mutex)后将互斥量改为了0,再执行完第二个P想消耗空闲缓冲区数量,但是由于缓冲区已经满了就会发生阻塞。

接着发生进程调度,消费者进程想获取互斥资源,发现此是已经被获取,就也会阻塞。

此时两个进程就出现了相互等待的问题,导致了死锁。

producer() {
    while(1) {
        // 生产一个产品
        P(mutex);//实现互斥是在同一个进程进程PV操作   1
        P(empty);//消耗一个空闲缓冲区   2
        
        //把产品放入缓冲区
        V(mutex);
        V(full);//增加一个产品
    }
consumer(){
    while (1) {
        P(mutex);  // 3 
        P(full);// 消耗一个产品(非空缓冲区)  4
        
        // 从缓冲区取出一个产品
        V(mutex);
        V(empty);// 增加一个空闲缓冲区
        // 使用产品
    }
}

因此,实现互斥的P操作一定要在实现同步的P操作之后。如果颠倒就会可能会发生死锁

经典同步问题

读者写者问题

有读者和写者两组并发进程,共享一个文件,当两个或两个以上的读进程同时访问共享数据时不
会产生副作用,但若某个写进程和其他进程(读进程或写进程) 同时访问共享数据时则可能导致
数据不一致的错误,又或者两写进程同时写,就会出现数据覆盖问题
。因此要求:

  1. 允许多个读者可以同时对文件执行读操作:
  2. 只允许一个写者往文件中写信息
  3. 任一写者在完成写操作之前不允许其他读者或写者工作
  4. 写者执行写操作前,应让已有的读者和写者全部退出。

解决这类问题:

方案1

对读者进行计数,如果有读进程像要读取文件,并要求第一读进程进程加锁操作,并让计数器加一,后续发现读者计数器数量大于0就直接读取,每当进程读取完后就将计数器减一,当一个进程读取完后发现计数器变成了0,也就是最后一个读进程,就将文件解锁。

semaphore rw = 1; // 用于实现对共享文件的互斥访问
int count = 0; // 记录当前有几个读进程在访问文件
semaphore mutex = 1;// 用于保证对count变量的互斥访问
writer() {
    while (1) {
        P(rw); // 写之前"加锁"
        写文件......
        V(rw); // 写完后解锁
    }
}
reader() {
    while (q) {
        P(mutex);
        if (count == 0) {//由第一个读进程负责加锁
            P(rw);// 读之前加锁
        }
        count++;// 访问文件的读进程数+1
        V(mutex);
        读文件......
        count--;// 访问文件的读进程-1
        if (count == 0){
            V(rw);//读完后“解锁”,这是最后一个读进程
        }
    }
}

这样就可以让多个读者进程同时读取,且注意对count这个值操作需要互斥。

潜在的问题:只要有读进程还在读,写进程就要一直阻塞等待,可能“饿死”。因此,这种算法中,读进程是优先的。

方案2

为了解决以上问题就可以再添加一个互斥信号

semaphore rw = 1; // 用于实现对共享文件的互斥访问的
int count = 0; // 记录当前有几个读进程在访问文件
semaphore mutex = 1;// 用于保证对count变量的互斥访问
semaphore w = 1;// 用于实现写优先
writer() {
    while (1) {
        P(w);
        P(rw); // 写之前"加锁"
        写文件......
        V(rw); // 写完后解锁
        V(w);
    }
}
reader() {
    while (q) {
        P(w);
        P(mutex);
        if (count == 0) {//由第一个读进程负责加锁
            P(rw);// 读之前加锁
        }
        count++;// 访问文件的读进程数+1
        V(mutex);
        V(w);
        读文件......
        P(mutex)
        count--;// 访问文件的读进程-1
        if (count == 0){
            V(rw);//读完后“解锁”,这是最后一个读进程
        }
        V(mutex);
    }
}

结论:在这种算法中,连续进入的多个读者可以同时读文件;写者和其他进程不能同时访问文件;写者不会饥饿,但也并不是真正的“写优先”,而是相对公平的先来先服务原则。

哲学家进餐问题

一张圆桌上坐着5名哲学家,每两个哲学家之间的桌上摆一根筷子,桌子的中间是一碗米饭。哲学
家们倾注毕生的精力用于思考和进餐,哲学家在思考时,并不影响他人。只有当哲学家饥饿时,
才试图拿起左、右两根筷子(一根一根地拿起)。如果筷子已在他人手上,则需等待。饥饿的哲
学家只有同时拿起两根筷子才可以开始进餐,当进餐完毕后,放下筷子继续思考。

  • 关系分析:系统中有5个哲学家进程,5位哲学家与左右邻居对其中间筷子的访问是互斥关系

  • 整理思路:这个问题中只有互斥关系,但与之前遇到的问题不同的事,每个哲学家进程需要同时持有两个临界资源才能开始吃饭。如何避免临界资源分配不当造成的死锁现象,是哲学家问题的精髓。

  • 信号量设置:信号量设置。定义互斥信号量数组chopstick[5]={1,1,1,1,1}用于实现对5个筷子的互斥访问。并对哲学家按0~4编号,哲学家i左边的筷子编号为i,右边的筷子编号为 ( i + 1 ) (i+1) (i+1)% 5 5 5

semaphore chopstick[5]={1,111,1};
Pi (){//i号哲学家的进程
    while(1){
        P(chopstick[il);//拿左
        P(chopstick[(i+1)%5]);//拿右
        吃饭...
        V(chopstick[il);//放左
        V(chopstick[(i+1)%5]);//放右
        思考...
    }
}

上诉这种解决办法存在着一个问题,假设设所有这哲学家同时拿起左手的筷子就会出现死锁问题,他们都在等待拿起右手边的筷子。

那么如何避免死锁呢?

  1. 可以对哲学家进程施加一些限制条件,比如最多允许四个哲学家同时进餐。这样可以保证至少有一个哲学家是可以拿到左右两只筷子的

代码实现:

semaphore chopstick[5]={1,111,1};
semaphore mutex = 1;// 互斥信号量
int count = 0;// 已经拿了筷子的哲学家数量
Pi (){//i号哲学家的进程
    while(1){
        P(mutex);
        if (count < 4) {// 手上有筷子的人数
            count++;// 可以开始拿筷子
            V(mutex);
            P(chopstick[il);//拿左
            P(chopstick[(i+1)%5]);//拿右
            吃饭...
            V(chopstick[il);//放左
            V(chopstick[(i+1)%5]);//放右
            count--; // 手里已经没有筷子了
        }
        思考...
    }
}
  1. 要求奇数号哲学家先拿左边的筷子,然后再拿右边的筷子,而偶数号哲学家刚好相反。用这种方法可以保证如果相邻的两个奇偶号哲学家都想吃饭,那么只会有其中一个可以拿起第一只筷子,另一个会直接阻塞。这就避免了占有一支后再等待另一只的情况。

代码实现:

semaphore chopstick[5]={1,111,1};
semaphore mutex = 1;// 互斥信号量

Pi (int i){//i号哲学家的进程
    while(1){
        if (i%2==1) {
            P(chopstick[il);//拿左
            P(chopstick[(i+1)%5]);//拿右
        } else {
            P(chopstick[(i+1)%5]);//拿右
            P(chopstick[il);//拿左
        }
        吃饭...
        V(chopstick[il);//放左
        V(chopstick[(i+1)%5]);//放右
        思考...
    }
}

哲学家进餐问题的关键在于解决进程死锁。这些进程之间只存在互斥关系,但是与之前接触到的互斥关系不同的是,每个进程都需要同时持有两个临界资源,因此就有“死锁”问题的隐患。

6. 死锁

什么是死锁

最好理解的生活例子就是:车钥匙锁锁家里了,家里的钥匙锁车了,这就是死锁。

又或者是在编程语言中,假设该编程语言的锁不是可重入锁的,直接对一个代码加两把锁,这也是经典的死锁。

void func(){
    lock1();// 加锁
    /**
    代码逻辑
    */
    lock2(); //再次加锁
    
    unlock();// 解锁
}
  • 死锁:各进程互相等待对方手里的资源,导致各进程都阻塞,无法向前推进的现象。至少有两个或两个以上的进程
  • 饥饿:由于长期得不到想要的资源,某进程无法向前推进的现象。比如: 在短进程优先(SPF)算法中,若有源源不断的短进程到来,则长进程将一直得不到处理机,从而发生长进程“饥饿”。可能只有一个进程发生饥饿
  • 死循环:某进程执行过程中一直跳不出某个循环的现象。有时是因为程序逻辑 bug 导致的,有时是程序员故意设计的。

产生死锁的必要条件

产生死锁必须同时满足以下下四个条件,只要其中任一条件不成立,死锁就不会发生。

  1. 互斥条件:只有对必须互斥使用的资源的争抢才会导致死锁(如哲学家的筷子、打印机设备)
    像内存、扬声器这样可以同时让多个进程使用的资源是不会导致死锁的(因为进程不用阻塞等待这种资源)
  2. 不剥夺条件:进程所获得的资源在未使用完之前,不能由其他进程强行夺走,只能主动释放。
  3. 请求和保持条件:进程已经保持了至少一个资源,但又提出了新的资源请求,而该资源又被其他进程占有,此时请求进程被阻塞,但又对自己已有的资源保持不放
  4. 循环等待条件:存在一种进程资源的循环等待链,链中的每一个进程已获得的资源同时被下一个进程所请求。比过哲学家进餐问题中,所欲哲学家同时拿起左手筷子场景。

注意!发生死锁时一定有循环等待,但是发生循环等待时未必死锁(循环等待是死锁的必要不充分条件)

如果同类资源数大于1,则即使有循环等待,也未必发生死锁。但如果系统中每类资源都只有
一个,那循环等待就是死锁的充分必要条件了。

总之,对不可剥夺资源的不合理分配,可能导致死锁

预防死锁

破坏互斥条件

互斥条件:只有对必须互斥使用的资源的争抢才会导致死锁。
如果把只能互斥使用的资源改造为允许共享使用,则系统不会进入死锁状态。比如: SOOLing技术。操作系统可以采用 SPOOLing 技术把独占设备在逻辑上改造成共享设备。比如,用SPOOLing技术将打印机改造为共享设备…

使用了SPooLing技术后,在各进程看来,自己对打印机资源的使用请求立即就被接收处理
了,不需要再阻塞等待

破坏不剥夺条件

不剥夺条件:进程所获得的资源在未使用完之前,不能由其他进程强行夺走,只能主动释放。
破坏不剥夺条件:

  • 方案一:当某个进程请求新的资源得不到满足时,它必须立即释放保持的所有资源,待以后需要时再重新申请。也就是说,即使某些资源尚未使用完,也需要主动释放,从而破坏了不可剥夺条件
  • 方案二:当某个进程需要的资源被其他进程所占有的时候,可以由操作系统协助,将想要的资源强行剥夺。这种方式一般需要考虑各进程的优先级(比如:剥夺调度方式,就是将处理机资源强行剥夺给优先级更高的进程使用)

该策略的缺点:

  1. 实现起来比较复杂。
  2. 释放已获得的资源可能造成前一阶段工作的失效。因此这种方法一般只适用于易保存和恢复状态的资源,如CPU。
  3. 反复地申请和释放资源会增加系统开销,降低系统吞吐量。
  4. 若采用方案一,意味着只要暂时得不到某个资源,之前获得的那些资源就都需要放弃,以后再重新申请。如果一直发生这样的情况,就会导致进程饥饿。
破坏请求和保持条件

请求和保持条件:进程已经保持了至少一个资源,但又提出了新的资源请求,而该资源又被其他进程占有,此时请求进程被阻塞,但又对自已已有的资源保持不放

可以采用静态分配方法,即进程在运行前一次申请完它所需要的全部资源,在它的资源未满足前,不让它投入运行。一旦投入运行后,这些资源就一直归它所有,该进程就不会再请求别的任何资源。

该策略实现起来简单,但也有明显的缺点
有些资源可能只需要用很短的时间,因此如果进程的整个运行期间都一直保持着所有资源,就会造成严重的资源浪费,资源利用率极低。另外,该策略也有可能导致某些进程饥饿。如上图,假设一直有A类和B类进程来申请资源,那么C类进程就无法同时获取到这两个资源进程,就会出现饥饿。

破坏循环等待条件

循环等待条件:存在一种进程资源的循环等待链,链中的每一个进程已获得的资源同时被下一个进程所请求。
可采用顺序资源分配法。首先给系统中的资源编号,规定每个进程必须按编号递增的顺序请求资源,同类资源(即编号相同的资源)一次申请完。

原理分析:一个进程只有已占有小编号的资源时,才有资格申请更大编号的资源。按此规则,已持
有大编号资源的进程不可能逆向地回来申请小编号的资源,从而就不会产生循环等待的现象。
假设系统中共有10个资源,编号为 1,2,…10

在任何一个时刻,总有一个进程拥有的资源编号是最大的,那这个进程申请之后的资源必然畅通无阻因此,不可能出现所有进程都阻塞的死锁现象.

该策略的缺点:

  1. 不方便增加新的设备,因为可能需要重新分配所有的编号;
  2. 进程实际使用资源的顺序可能和编号递增顺序不一致,会导致资源浪费,比如说使用摄像头5号和麦克风7号,进程是先使用麦克风一段时间后再使用一下摄像头,那么就会一直占用摄像头资源,又不怎么使用。
  3. 必须按规定次序申请资源,用户编程麻烦.

避免死锁

什么是安全序列?

所谓安全序列,就是指如果系统按照这种序列分配资源,则每个进程都能顺利完成。只要能找出一个安全序列,系统就是安全状态。当然,安全序列可能有多个

如果分配了资源之后,系统中找不出任何一个安全序列,系统就进入了不安全状态。这就意味着之后可能所有进程都无法顺利的执行下去。当然,如果有进程提前归还了一些资源,那系统也有可能重新回到安全状态,不过我们在分配资源之前总是要考虑到最坏的情况。

举个例子:

你是一位成功的银行家,手里掌握着100个亿的资金…

有三个企业想找你贷款,分别是 企业A、企业B、企业C

  • A 表示:我最多会跟你借70亿…
  • B 表示:我最多会跟你借40亿…
  • C 表示:我最多会跟你借50亿…

然而…江湖中有个不成文的规矩:如果你借给企业的钱总数达不到企业提出的最大要求,那么不管你之前给企业借了多少钱,那些钱都拿不回来了…

刚开始,A 、B、C三个企业分别从你这儿借了 20、10、30 亿…,此时你还剩下40亿

企业最大需求已借走最多还会借走
A702050
B401030
C503020

此时你手上还剩下40亿,如果企业A再找你借个30亿,你能借吗?假设你借了出去,你的手里就只剩下10亿,那么,接下来B、A、T三个企业中任何一个企业的需求你都满足不了了,那你借出去的钱就再也拿不回来了,你就变成了一个失败的银行家。

企业最大需求已借走最多还会借走
A7020+3050-30
B401030
C503020

所以给A企业再借30亿显然是不行的,我们可以给C企业再借20亿,此时C企业已经满足最大需求,就会把前全部还回来,那么此时你手里就还剩下 20 + 50 = 70 20+50=70 20+50=70亿,此时你手里的70亿可以借给A企业50亿,那么A企业就会把借的前全部还回来。最后再借给B企业。

企业最大需求已借走最多还会借走
A702050
B4010+300
C503020

按照C—>B—>A这样的顺序,又或者是C—>A—>B,依次满足企业的顺序,这就是一个安全序列。安全序列是有多个的,如果可以找到安全序列,说明系统处于安全状态,就有可能到置死锁。

如果系统处于安全状态,就一定不会发生死锁。如果系统进入不安全状态,就可能发生死锁(处于不安全状态未必就是发生了死锁,但发生死锁时一定是在不安全状态)

因此可以在资源分配之前预先判断这次分配是否会导致系统进入不安全状态。以此决定是否答应资源分配请求,这也是**“银行家算法”**的核心思想。

银行家算法

银行家算法是荷兰学者 Dikstra 为银行系统设计的,以确保银行在发放现金贷款时,不会发生不能
满足所有客户需要的情况。后来该算法被用在操作系统中,用于避免死锁

核心思想:在进程提出资源申请时,先预判此次分配是否会导致系统进入不安全状态。如果会进入不安全状态,就暂时不答应这次请求,让该进程先阻塞等待

对比上面那个借钱的例子,可以把单维的数字拓展为多维的向量。比如: 系统中有5个进程 P0~P4,3种资源 R0~R2,初始数量为(10,5,7),则某一时刻的情况可表示如下:

进程最大需求已分配还需分配
P0(7,5,3)(0,1,0)(7,4,3)
P1(3,2,2)(2,0,0)(1,2,2)
P2(9,0,2)(3,0,2)(6,0,0)
P3(2,2,2)(2,1,1)(0,1,1)
P4(4,3,3)(0,0,2)(4,3,1)

如上表,总共已经分配了(7,2,5),还剩余(3,3,2)。可把最大需求、已分配的数据看作矩阵
两矩阵相减,就可算出各进程最多还要多少资源了。

经对比发现,(3,3,2)可满足P1、P3,说明无论如何,这两个进程的资源需求一定是可以依次被
满足的,因此P1、P3一定可以顺利的执行完,并归还资源.可把P1、P3先加入安全序列.

死锁的检测和解除

死锁检测

为了能对系统是否已发生了死锁进行检测,必须:

  1. 某种数据结构来保存资源的请求和分配信息;
  2. 提供一种算法,利用上述信息来检测系统是否已进入死锁状态。

如下图使用数据结构图:

有P1和P2两个进程,有R1和R2两种资源,R1这种资源的数量有3个,R2这种资源的数量有2个。

  • 紫色的箭头表示已经分配的资源,P1已经有了2个R1资源,P2有了一个R1资源
  • 绿色的箭头表示进程正在请求资源,此时P1在请求R2资源,P2在请求R1资源

此时等P1申请完R2资源后,P1进程将所有资源申请完毕使用后就会释放资源,接着P2进程就可以申请到对应的R1资源,此时并不会发生死锁。

如果按上述过程分析,最终能消除所有边,就称这个图是可完全简化的。此时一定没有发生死锁 (相当于能找到一个安全序列)
如果最终不能消除所有边,那么此时就是发生了死锁

如下图:

当P3进程使用完R2资源后进行释放,但是此时P1需要2个R2资源,显然不够此时P1进程就会阻塞,那么此时P2进程在等待R1资源也在阻塞,这就发生了死锁

死锁定理:如果某时刻系统的资源分配图是不可完全简化的,那么此时系统死锁

解除死锁

并不是系统中所有的进程都是死锁状态,用死锁检测算法化简资源分配图后,还连着边的
那些进程就是死锁进程

  1. 资源剥夺法。挂起(暂时放到外存上) 某些死锁进程,并抢占它的资源,将这些资源分配给
    其他的死锁进程。但是应防止被挂起的进程长时间得不到资源而饥饿。
  2. 撤销进程法(或称终止进程法)。强制撤销部分、甚至全部死锁进程,并剥夺这些进程的资
    源。这种方式的优点是实现简单,但所付出的代价可能会很大。因为有些进程可能已经运行
    了很长时间,已经接近结束了,一旦被终止可谓功亏一赛,以后还得从头再来。
  3. 进程回退法:让一个或多个死锁进程回退到足以避免死锁的地步。这就要求系统要记录进程
    的历史信息,设置还原点。

如何决定对哪个进程使用解除死锁,可以根据一下几点:

  1. 进程优先级低的
  2. 执行时间更少的进程
  3. 优先马上就能执行完毕的进程,优先获得资源
    个进程的资源需求一定是可以依次被
    满足的,因此P1、P3一定可以顺利的执行完,并归还资源.可把P1、P3先加入安全序列.

显示全文