北邮 操作系统(五)

第五章 进程同步

多个进程在并发执行的过程中并不一定完全独立,会相互依赖,本章就是解决在操作系统中如何实现这些依赖关系(比如多项式1+2*3,必须保证乘法先于加法执行才能得到正确结果);

当进程之间存在依赖关系的时候,进程不能一直执行自己的工作,需要在适当时间查看其它进程的工作情况,根据查看情况再决定自己是否需要继续工作;

进程同步的基本结构:

  • 一个进程在需要同步的地方停下来等待依赖进程,当发现依赖进程完成了和同步对应的工作以后,这个进程再继续向前执行;

上述流程我们称为睡眠和唤醒,据此可以给出进程同步的描述性定义:进程同步就是通过对进程睡眠和唤醒的控制使得多个进程步调一致,合理有序地向前推进;

1.进程互斥

进程互斥和进程同步不是类似的概念,进程互斥是指当一个进程访问某临界资源时另一个想要访问该临界资源的进程必须等待直到访问临界资源的进程释放;

系统中的资源主要有两种共享方式:

  • 互斥共享方式:系统中的某些资源可以提供给多个进程使用,但一个时间段内只允许一个进程访问该资源 ———— 把这种资源称为临界资源;
  • 同时共享方式:系统中的某些资源允许一个时间段内有多个进程“同时”对其进行访问(实际每个时间片只会有一个进程访问该资源);

对临界资源的访问必须互斥地进行,可以在逻辑上分为如下四部分

do{
    entry section;		//进入区:负责检查是否可以进入临界区,如果可以进入则应上锁,以阻止其他进程同时进入临界区
    critical section;	//临界区:访问临界资源的那段代码
    exit section;	    //退出区:负责解除正在访问临界资源的标志“解锁”
    remainder section;	//剩余区:做其他处理
}while(true)
//临界区是进程中访问临界资源的代码段;
//进入区和退出区是负责实现互斥的代码段;

进程互斥需要遵循以下原则:

1.空闲让进。临界区空闲时,可以允许一个请求进入临界区的进程立即进入临界区;
2.忙则等待。当已有进程进入临界区时,其他试图进入临界区的进程必须等待;
3.有限等待。对请求访问的进程,应保证能在有限时间内进入临界区(保证不会饥饿);
4.让权等待。当进程不能进入临界区时,应立即释放处理机,防止进程忙等待;

为什么要介绍进程互斥?因为进程互斥是实现进程同步的一种方式,进程同步是一种处理异步性的方式,基于互斥实现对资源的有序访问;

2.信号&信号量

我们已经建立了基于睡眠和唤醒的进程同步轮廓,接下来就是实现这个轮廓,实现的核心在于进程间如何实现睡眠和唤醒(有些地方也称为等待和通知)——进程间通过信号实现等待和通知;

我们以前面提到过的“生产者-消费者”模型分析如何利用信号解决进程同步问题:


Q:信号量和条件变量(Condition Variable)的关系是什么?

A:参考回答信号量和条件变量的关系是什么? - 知乎 (zhihu.com)

其实我们完全没必要将信号量和条件变量的区别分的很清,因为在某个角度来说互斥变量和条件变量属于信号量的特殊情况,即条件变量+互斥变量=信号量

再换一个通俗易懂的说法,条件变量就是我们这里介绍的信号,因为语义信息较少所以不适用于多变的调度情况;


生产者-消费者模型是多个进程共享一个缓存区产生的相互依赖的问题,生产者进程往共享缓存区写入内存,消费者进程从共享缓存区取出内容:

  • 缓存区满的时候生产者进程需要等待消费者进程取走内容;
  • 缓存区空的时候消费者进程需要等待生产者进程写入内容;

要解决生产者-消费者问题,首先需要找到两个进程之间需要同步的地方:对于生产者进程,需要停下来等待的地方是在缓存区满的时候,在此处插入等待信号的动作;

接下来要找到发送信号的地方(让生产者进程继续前进的地方):对于生产者进程,消费者进程制造出一个空闲缓存区时需要唤醒生产者进程,在此处发出生产者进程等待的信号;

消费者进程的睡眠和唤醒与之类似,此处不再赘述;

上述解决方案中,定义了两个信号:

  • empty表示“空闲单元信号”,消费者进程用掉一个内容单元则产生一个空闲缓存区,则向生产者进程发送一个empty信号;
  • full表示“内容单元信号”,生产者进程产生一个内容单元则向消费者发出一个full信号;

通过if(counter==BUFFER_SIZE)来判断counter(当前缓存区中内容单元的个数)是否等于BUFFER_SIZE,如果相等说明缓存区已满生产者进程会令自己进入阻塞态并等待信号empty;

当消费者进程消耗掉一个内容单元后消费者发现counter变成BUFFER_SIZE-1,那么就会给生产者发送empty信号并唤醒生产者wake_up(empty);(消费者进程与之类似不再赘述)


上述依赖变量counter进行判断是否需要同步存在一个问题,因为counter表达出来的语义不够因而无法应对多变的调度情况;

当缓存区满的时候,此时进来两个生产者进程P1和P2以及一个消费者进程C;
此时的counter==BUFFER_SIZE,当C消耗掉一个内容单元会给P1发送empty唤醒P1,此时counter==BUFFER_SIZE-1;
C再次消耗掉一个内容单元后此时counter==BUFFER_SIZE-2,现在P2就不能被唤醒了;

引入信号量的概念:信号量就是在信号上关联一个表达“量”的整数(这个量可以自定义,如阻塞在empty上的进程个数):

(1)信号量就是一个整型变量,用来记录和进程同步有关的重要信息;

(2)能让进程阻塞睡眠在这个信号量上;

(3)需要同步的进程通过操作(加1和减1)信号量实现进程的阻塞和唤醒,即进程间的同步;

因此,信号量就是一个数据对象以及操作这个数据对象的两个操作:

  • 其中数据对象是信号量数值以及相应的阻塞进程队列;
  • 而在这个数据对象上的两个操作就是对信号量数值的加1和减1,并根据加减后的信号量数值决定的睡眠和唤醒;

我们还是用前面的例子来讲解

如果P1阻塞,则需要等待的empty=1,
如果P2阻塞,则需要等待的empty=1+1=2,
C执行一次发现empty==1这意味着还有一个进程在阻塞,C再次执行一次发现empty==0;

综上,对于empty这个信号来说,当它为正数或者0的时候,表示现在和该信号量对应的资源还有empty个;当它为负数的时候表示亏欠|empty|个资源(这个地方不要纠结,本质上就是简单的多余资源和少资源;
总而言之信号量的定义是人为的,只要给出一个合理的定义就可以,不要太纠结书上的语法;

综上,我们得出信号量的含义:

  • 信号量是一个需要被多个进程共享的整数;
  • 通过信号量的值判断进程是否需要睡眠/唤醒,睡眠/唤醒的操作对象是进程的PCB;
  • 修改信号量的值的时候需要进行临界区保护,具体方法参考下面将会介绍的临界区的实现;

3.临界区

信号量的数值非常重要,只有信号量的数值与信号量对应的语义信息保持一致才能正确地使用信号量来决定进程的同步;

在多个进程“共同”修改信号量时需要对信号量进行保护,不能随意修改:每个进程对信号量的修改要么一点也不做,要么全部做完,中途不能被打断(每个进程对信号量的修改必须是一个原子操作);

下图中P1和P2一次最多只能有一个进程进入执行某一段代码,这段代码就是进程的临界区

临界区并非是信号量的专有概念,我们将一次仅允许一个进程使用的资源称为临界资源,对临界资源的访问必须互斥的进行,将临界资源的访问过程分为下面4部分:

  • 进入区。为了进入临界区使用临界资源,在进入区要检查可否进入临界区,若能进入临界区,则应设置正在访问临界区的标志,以阻止其他进程同时进入临界区。
  • 临界区。进程中访问临界资源的那段代码,又称临界段。
  • 退出区。将正在访问临界区的标志清除。
  • 剩余区。代码中的其余部分。
do{
    entry section;		//进入区
    critical section;	//临界区
    exit section;		//退出区
    remainder section;	//剩余区
}while(true)

有了临界区的概念以后,信号量的保护机制就是让进程中修改信号量的代码成为临界区代码(“进入区”->“临界区”修改信号量->“退出区”);

3.1 临界区的软件实现

(在王道课程中这里也被称为进程互斥的软件/硬件实现)

3.1.1 轮换法

轮换法也称为单标志法,指的是两个进程在访问完临界区之后会把使用临界区的权限转交给另一个进程 —— 即每个进程进入临界区的权限只能被另一个进程赋予;

任何时候只有一个进程有权利进入临界区,只有这个进程退出临界区后才能轮换到其他进程,保证了临界区的互斥性;

turn变量在任意时刻只能取值为0或1,对P0而言turn=0,对P1而言turn=1时表示允许进入临界区;

缺点:假如P1一直不访问临界区,则turn值永远都是1不变,抑或是P1进程直接退出,这些都会导致P0永远无法进入临界区执行;

实现临界区需要考虑如下(其实就是前面已经介绍过的进程互斥需要遵循的原则):

第一,互斥进入。如果有多个进程要求进入空闲的临界区,一次仅允许一个进程进入;在任何时候,一旦已经有进程进入其自身的临界区,则其他所有试图进入相应临界区的进程都必须等待。
第二,有空让进。如果没有进程处于临界区内且有进程请求进入临界区,则应该能让某个请求进程进入临界区执行,即不发生资源的死锁情况。(轮换法并没有实现有空让进)
第三,有限等待。有限等待意味着一个进程在提出进入临界区请求后,最多需要等待临界区被使用有限次以后,该进程就可以进入临界区。这样,任何一个进程对临界区的等待时间都是有限的,即不出现因等待临界区而造成的饿死情况。

3.1.2 标记法

标记法也被称为双标志检查法,算法思想是设置一个布尔型数组flag[],数组中的各个元素用于标记各个进程想要进入临界区的意愿;每个进程在进入临界区之前先检查当前有没有别的进程想要进入临界区,如果没有则将自身对应的标志flag[i]修改为true之后访问临界区;

标记法在这里被分为双标志后检查法(哈工大教程介绍)和双标志前检查法(王道教程介绍);

首先将整个flag[]数组初始化为全0,若进程P0想进入临界区就做一个标记flag[0]=ture,那么当进程P0在访问临界区的时候P1会一直在while循环等待,直到P0进程明确表示自己不再需要使用临界区;

我们分析标记法是否可以满足上述临界区的实现要求:

  • 互斥进入:若两个进程都在临界区则flag[0]=flag[1]=ture,但是对于P0来说flag[1]=ture会自旋等待,对P1来说flag[0]=ture会自旋等待,这与假设矛盾,所以可以保证互斥进入;
  • 有空让进:这是标记法不能解决的,假设进程P0设置了flag[0]=ture之后并没有进入临界区,而是切换到进程P1,进程P1设置flag[1]=true后发现满足while(flag[0] == ture)则自旋等待(实际上P0并没有在临界区中),而P0也因为满足while(flag[1] == ture)所以自旋等待

我们简单说一下双标志检查法的缺陷,假如我们将进入区的两行代码顺序交换,然后进行分析;

初始时flag[1]为0所以进程P0可以顺利跳出while循环,此时发生进程切换,进程P1此时因为flag[0]也是0所以也成功跳出while循环,接着这两个进程就修改标志并进入临界区…这就导致P0和P1同时访问临界区,不符合互斥进入的准则!

原因之一是因为P0进程和P1进程是可以并发执行的,进程并发执行很可能导致异步性;原因之二是因为在进入区的“检查”和“上锁”两个步骤并不是一气呵成的,即很可能在“检查”之后发生进程切换然后再“上锁”

3.1.3 Peterson算法

标记法存在有空不能进的情况,是因为两个进程都要进入临界区而相互锁住;Peterson算法简单来说就是将两个相互竞争的进程化解为,主动让对方先使用临界区,类似“孔融让梨”

Peterson算法是一种综合性的算法:

  1. 用标记法判断进程是否请求进入临界区,即flag[]数组表示进程想要进入临界区的意愿;
  2. 如果进程请求进入临界区则使用轮换法给进程进行一个明确的优先排序,即turn表示优先让哪个进程进入临界区;

  • 互斥进入:若P0和P1都在临界区中则flag[0]=flag[1]=ture,且因为经过了进入区所以turn=0且turn=1,这明显是不可能的;
  • 有空让进:如果P0和P1都请求进入临界区,turn会使得轮到的进程一定能够进入临界区;如果某个进程不愿意进入临界区则flag[]=flase,该进程根本不会自旋等待;
  • 有限等待:如果P0请求但不能进入临界区,只可能是flag[1]=ture且turn=1,这意味着P1在临界区中,P1使用一次临界区后会将turn设置为0,此时P0就可以进入临界区了;

Peterson算法满足上述三个原则,但是仍未遵循让权等待(即使进程P0不能进入临界区,但是会一直占用CPU使其一直执行while循环,这就是所谓的“忙等待”)

3.1.4 Lamport面包店算法

Peterson算法只能处理两个进程的临界区,涉及多进程时就需要Lamport面包店算法;

面包店(临界区)一次只能接待一个客户(进程),按照次序给每个客户分发一个号码,顾客按照其号码由小到大的次序进行购买,完成购买的顾客号码置零(这意味着如果该顾客需要再次购买需要重新取号排队);

3.2 临界区的硬件实现

当软件实现变得复杂以后,可以使用硬件来简化操作、提高效率;

3.2.1 中断屏蔽法

禁止中断是一种实现临界区保护的方法(禁止中断意味着禁止进入内核,那么就无法调用schedule函数进而无法实现进程切换,则CPU只会执行一个进程的临界区代码,那么就不可能发生两个进程同时访问临界区的情况)

However,这种关中断操作对于多CPU计算机的其他CPU没有任何影响(即中断屏蔽法不适用于多处理机),同时该方法只适用于操作系统内核进程而不适用于用户进程(开/关中断指令只能运行在内核态,用户随意使用会非常危险);

3.2.2 TestAndSet指令

TSL指令是用硬件实现的,执行过程中不允许被中断,只能一气呵成;

我们联想之前的消费者-生产者模型 —— 这就引出了保护临界区的互斥信号量,该信号量只会取0、1两个值,通常被命名为lock:

  • lock当前值为0则说明没有上锁,可以执行进入临界区并将lock修改为1(上锁);
  • 若lock当前值为1则进程自旋等待;

我们可以看到,该信号量的P、V操作非常简单所以可以使用硬件实现,而实际上在计算机中的确存在这样一条指令,被称为TSL,这条指令有一个操作数lock,是存放一个布尔变量的内存地址,如果该内存中的变量为false,该指令会返回false,并且将内存中的变量置为true;如果在内存中的变量为true,则返回true;

假如我们使用代码模拟该硬件原子指令的实现,可以表示如下(时刻记住这只是一个模拟逻辑,实际上这一系列的语义都是由硬件来完成的且不可以被中断)

bool TestAndSet(bool *lock){ //lock共享变量表示当前临界区是否被加锁,ture表示已加锁,flase表示未加锁
    bool old;
    old = *lock;  //old用来存放lock原来的值
    *lock = true; //将lock设置为ture
    return old;   //返回lock原来的值
}

//进入区代码段
while(TestAndSet(&lock));  //上锁并检查
//临界区代码段
...
lock = false;              //解锁
//剩余区代码段
...

TSL的优点是将“上锁”和“检查”两个操作用硬件的方式变成了一气呵成的原子操作;缺点是不满足“让权等待”的原则,即暂时无法进入临界区的进程会占用CPU并循环执行TSL指令导致“忙等”;

3.2.3 swap指令

也称为Exchange指令或XCHG指令,swap指令是用硬件实现的,执行过程中不允许被中断;

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

因为这两个指令的逻辑类似,所以优缺点几乎也是一样的,这里就不再赘述;

4.信号量机制

前面我们讨论了临界区的四种软件实现方法以及三种硬件实现方法,但是它们或多都有一些缺陷(比如所有的方案都不能实现“让权等待”),一位科学家提出了一种卓有成效的实现进程互斥、同步的方法,这就是我们要介绍的信号量机制;

(前面小结中的信号量只是介绍了信号量最基本的使用,可以说就只是引出了信号量这个概念而已,本节我们将深入研究怎么实现信号量以及如何真正的使用信号量)

信号量机制:临界区保护了信号量P、V操作的原子性进而保证信号量数值的语义正确性(根据信号量数值表达的语义可以正确地控制进程的阻塞和唤醒,也就是实现进程同步),因此只要操作系统给上层用户提供了信号量定义接口以及P、V原语操作后,用户就可以直接调用这些接口方便地完成进程同步以及进程互斥;

  • 信号量:被实现为操作系统内核中的一个数据对象(简单来说,信号量就是一个变量,可以使用信号量来表示系统中某种资源的数量,比如说系统中只有一台打印机,则可以设置一个初值为1的信号量),而P、V操作被实现为操作系统提供的系统调用;

  • 原语:原语是一种特殊的程序段,其执行只能一气呵成,不可被中断。原语是由关中断/开中断指令实现的。软件解决临界区的方案的主要问题是由“进入区的各种操作无法一气呵成”,因此如果能把进入区、退出区的操作都用“原语”实现,使这些操作能“一气呵成”就能避免问题。

举例来说,POSIX标准针对信号量定义了如下四个基本系统调用:

  • sem_t *sem_open(const char *name,int oflag,mode_t mode,unsigned int value):打开或创建一个信号量变量;
  • int sem_unlink(count char*name):根据名字从操作系统中删除信号量;
  • int sem_wait(sem_t *sem):信号量的P操作;
  • int sem_post(sem_t *sem):信号量的V操作;

(简单理解信号量机制就是将前面的临界区和信号量概念整合在一起,共同实现进程同步和进程互斥)


关于P、V操作究竟是什么,怎么一来就抛出这样的概念不加解释?

信号量的P操作实质就是wait原语操作,而之前我们写的一系列wait函数实际上都是在模拟wait原语的动作(signal类似),所以其实我们是应当早就熟悉P、V操作;

wait用法:

wait(num),num是目标参数,wait的作用是使其(信息量)减一。
如果信息量>=0,则该进程继续执行;否则该进程置为等待状态,排入等待队列;

signal用法:

signal(num),num是目标参数,signal的作用是使其(信息量)加一。如果信息量>0,则该进程继续执行;否则释放队列中第一个等待信号量的进程;

P操作

加锁对应的是P将信号量减1,并阻塞其他线程;(注意注意注意,P操作就是wait原语,它们两个的描述虽然不太一样但是并不矛盾!!!它俩就是同一个概念!!!)

V操作

解锁对应的是V将信号量加1,并唤醒某一个线程;


4.1 信号量的分类

关于信号量机制的详细讲解参考:2.3_4_信号量机制_哔哩哔哩_bilibili(强推!!!)

4.1.1 整型信号量

定义:用一个整数型的变量作为信号量,用来表示系统中某种资源的数量;

信号量与普通整数变量的区别在于对信号量的操作只有初始化、P操作以及V操作,我们这里用计算机系统中存在一台打印机举例说明整型信号量的简单使用;

int S= 1//初始化整型信号量s,表示当前系统中可用的打印机资源数
void wait(int S){//wait 原语,相当于"进入区"
	while(S <= 0);  //如果资源数不够,就一直循环等待
	S=S-1//如果资源数够,则占用一个资源
}
void signal(int S){//signal 原语,相当于“退出区"
S	=S+1//使用完资源后,在退出区释放资源
}
//进程P0:
...
wait(S);          //进入区,申请资源
//使用打印机资源...   //临界区,访问资源
signal(S);        //退出区,释放资源
...

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

4.1.2 记录型信号量

定义:使用记录型数据结构表示的信号量

/*记录型信号量的定义*/
typedef struct {
	int value;       //剩余资源数
	struct process*L;//等待队列
}semaphore;
/*某进程需要使用资源时,通过 wait 原语申请*/
void wait(semaphore S){
	S.value--if(S.value <0{
        block(S.L);//如果剩余资源数不够,则使用block原语,block原语的作用是使得进程从运行态进入阻塞态,并将其挂在到信号量S的阻塞队列中
    }
}
/*进程使用完资源后,通过 signal 原语释放*/
void signal(semaphore S){
	s.value++if(S.value <= 0{
		wakeup(S.L);//释放资源后,若还有别的进程在等待这种资源,则使用wakeup原语唤醒等待队列中的一个进程,该进程从阻塞态变为就绪态
    }
}

关于记录型信号量的使用参考2.3_5_用信号量实现进程互斥、同步、前驱关系_哔哩哔哩_bilibili,这里不再赘述;

4.2 信号量机制的运用

本节将介绍如何利用信号量机制实现进程互斥、同步以及进程的前驱关系;

4.2.1 信号量机制实现进程互斥

1.分析并发进程的关键活动,划定临界区(如:对临界资源打印机的访问就应放在临界区);

2.设置互斥信号量mutex,初值为1;

3.在临界区之前执行P(mutex);

4.在临界区之后执行V(mutex);

/*信号量机制实现互斥*/
semaphore mutex=1//初始化信号量
P1(){
    ...
	P(mutex);   //使用临界资源前需要加锁
	临界区代码段...
	V(mutex);   //使用临界资源后需要解锁
	...
}
P2(){
...
	P(mutex);   //使用临界资源前需要加锁
	临界区代码段...
	V(mutex);   //使用临界资源后需要解锁
	...
}

注意:对不同的临界资源需要设置不同的互斥信号量;

4.2.2 信号量机制实现进程同步

讲解视频参考:2.3_5_用信号量实现进程互斥、同步、前驱关系_哔哩哔哩_bilibili

4.2.3 信号量机制实现进程的前驱关系

视频参考:2.3_5_用信号量实现进程互斥、同步、前驱关系_哔哩哔哩_bilibili

5.锁&条件变量&信号量

相信很多人或多或少在学习操作系统的时候听说过类似锁、条件变量等名词,但国内大部分教科书好像都对这些概念视而不见或仅仅只是浅谈几句(甚至有的教材默认我们已经了解这些概念),让人很是疑惑,所以这里我们将锁、条件变量以及信号量三者之间究竟有什么关系以及三者的详细概念做一个整理;

5.1 锁

并发编程的最基本的问题:我们希望原子式执行一系列指令,但由于单处理器上的中断导致其难以实现;锁就是专门用于解决这一问题的,在源代码中加锁,放在临界区的周围以保证临界区代码能够像单条原子指令一样执行;

简单来说,锁就是一个变量,这个锁变量保存了锁在某一时刻的状态;它要么是可用的(available,或 unlocked,或free),表示没有线程持有锁,要么是被占用的(acquired,或locked,或held),表示有一个线程持有锁,正处于临界区。我们也可以保存其他的信息,比如持有锁的线程,或请求获取锁的线程队列,但这些信息会隐藏起来,锁的使用者不会发现。

锁的操作主要有lock()和unlock():

  • 调用lock()尝试获取锁,如果没有其他线程持有锁(即它是可用的),该线程会获得锁,进入临界区。这个线程有时被称为锁的持有者(owner)。如果另外一个线程对相同的锁变量调用lock(),因为锁被另一线程持有,该调用不会返回。这样,当持有锁的线程在临界区时,其他线程就无法进入临界区。
  • 锁的持有者一旦调用unlock(),锁就变成可用了。如果没有其他等待线程(即没有其他线程调用过lock()并卡在那里),锁的状态就变成可用了,如果有等待线程(卡在lock()里),其中一个会注意到(或收到通知)锁状态的变化,获取该锁,进入临界区。
5.1.1 Pthread锁

POSIX库将锁称为互斥量(mutex),因为它被用来提供线程之间的互斥。即当一个线程在临界区,它能够阻止其他线程进入直到本线程离开临界区。

当然我们可以选择使用不同的锁来保护不同的变量或数据结构,这样可以增加并发;

5.1.2 锁的实现

首先我们需要明确,如何评价一种锁的实现效果,我们设立了如下标准:

  1. 有效性:锁是否能完成它的基本任务,即提供互斥,阻止多个线程进入临界区;
  2. 公平性:当锁可用时是否每一个竞争线程都有公平的机会抢到锁?即是否有线程会被starve;
  3. 性能:具体来说就是使用锁之后增加的时间开销

如何实现锁?实际上我们已经在临界区的软件实现临界区的硬件实现介绍过了,这里就不再赘述,我们来介绍一些术语:

自旋锁

这是一种最简单的锁,一直自旋,利用CPU等待直到锁可用;

自旋锁的性能问题主要是线程在等待已经被持有的锁时,采用了自旋等待(spin-waiting)的技术,就是不停地检查标志的值。自旋等待在等待其他线程释放锁的时候会浪费时间。尤其是在单处理器上,一个等待线程等待的目标线程甚至无法运行(至少在上下文切换之前)!(因此在单处理器上,需要抢占式的调度器(preemptivescheduler,即不断通过时钟中断一个线程,运行其他线程)。否则,自旋锁在单CPU上无法使用,因为一个自旋的线程永远不会放弃CPU)

两阶段锁

Linux采用的是一种古老的锁方案,多年来不断被采用,可以追溯到20世纪60年代早期的Dahm锁[M82],现在也称为两阶段锁(two-phase lock)。两阶段锁意识到自旋可能很有用,尤其是在很快就要释放锁的场景。因此,两阶段锁的第一阶段会先自旋段时间,希望它可以获取锁。

但是,如果第一个自旋阶段没有获得锁,第二阶段调用者会睡眠,直到锁可用。Linux锁就是这种锁,不过只自旋一次;更常见的方式是在循环中自旋固定的次数,然后使用futex睡眠。

两阶段锁是一个杂合(hybrid)方案的例子,即结合两种好想法得到更好的想法。当然,硬件环境、线程数、其他负载等这些因素,都会影响锁的效果。

5.2 条件变量

上文介绍的锁并不是并发程序设计所需要的唯一原语;在很多情况下线程需要检查某一条件满足之后才会继续运行,因此引出了条件变量的概念;

条件变量(condition variable)是利用线程间共享的全局变量进行同步的一种机制,主要包括两个动作:一个线程等待某个条件为真,而将自己挂起;另一个线程使的条件成立,并通知等待的线程继续。为了防止竞争,条件变量的使用总是和一个互斥锁结合在一起。

条件变量是一个显式队列,当某些执行条件不满足时,线程可以将自己加入队列等待该条件;而另外某些线程改变了执行条件之后就可以唤醒一个或多个等待线程;(实际上信号量机制中有非常类似条件变量的概念,这也体现出三者之间的关系)

最早条件变量被称为“私有信号量”,有两种相关操作:wait()和signal()

  • 线程要睡眠的时候,调用wait(),wait()调用有一个参数就是互斥量/锁,wait()的作用是释放锁并让线程休眠(线程既然都休眠了必然需要释放锁以释放资源给其他线程使用);
  • 当线程想唤醒等待在某个条件变量上的睡眠线程时,调用signal();

提示:对条件变量使用while而不是if(原因在《操作系统导论》P260)

5.3 信号量


Q:为什么有了互斥锁和条件变量还需要提供信号量?

A:在Posix.1基本原理一文声称,有了互斥锁和条件变量还提供信号量的原因是:“本标准提供信号量的而主要目的是提供一种进程间同步的方式;这些进程可能共享也可能不共享内存区。互斥锁和条件变量是作为线程间的同步机制说明的;这些线程总是共享(某个)内存区。这两者都是已广泛使用了多年的同步方式。每组原语都特别适合于特定的问题”。尽管信号量的意图在于进程间同步,互斥锁和条件变量的意图在于线程间同步,但是信号量也可用于线程间,互斥锁和条件变量也可用于进程间。应当根据实际的情况进行决定。信号量最有用的场景是用以指明可用资源的数量。


信号量作为与同步有关的所有工作的唯一原语,可以使用信号量代替锁和条件变量;

使用信号量实现锁非常简单,因为锁只有两个状态(持有和未持有),所以信号量的这种用法也被称为二值信号量;

当然我们完全可以自己实现一个信号量,这只需要一把锁、一个条件变量和一个状态变量来记录信号的值;

而使用信号量实现条件变量可能不是一件容易的事,这里我们不再赘述;

结论:信号量是编写并发程序的强大而灵活的原语,因为其简单实用,所以很多时候可以只用信号量而不需要使用锁和条件变量;

6.经典同步问题

前面我们介绍线程同步的时候简单介绍过一些同步问题,这里我们对经典的同步问题及其规范的解答方法做一个总结和整理;

6.1 生产者-消费者问题

问题描述:

问题分析:

1.关系分析

互斥关系:

  • 缓冲区是临界资源,各进程必须互斥地访问;

同步关系:

  • 只有缓冲区没满时,生产者才能把产品放入缓冲区,否则必须等待 —— 缓冲区满时,生产者要等待消费者取走产品;
  • 只有缓冲区不空时,消费者才能从中取出产品,否则必须等待 —— 缓冲区空时(即没有产品时),消费者要等待生产者放入产品;

2.整理思路

一共需要三对P、V操作以对应三个不同的信号量:

  • 生产者每次要消耗(P)一个空闲缓冲区,并生产(V)一个产品;

  • 消费者每次要消耗(P)一个产品,并释放一个空闲缓冲区(V);

  • 往缓冲区放入/取走产品需要互斥;

3.设置信号量

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

具体代码实现:(while表示生产者进程不断的生产产品,消费者不断的消费产品)

注意:实现互斥的P操作一定要在实现同步的P操作之后,但是因为V操作不会导致进程阻塞,所以V操作的顺序可以交换

6.2 多生产者-多消费者问题

问题描述:

注意这里的“多”并不是指多个生产者、消费者,而是指生产者生产的产品的类别不同;

问题分析:

1.关系分析

2.整理思路

3.设置信号量

具体代码实现:

6.3 吸烟者问题

问题描述:

本质上这题也属于“生产者-消费者”问题,更详细的说应该是“可生产多种产品的单生产者-多消费者”;

问题分析:

1.关系分析

2.整理思路

3.设置信号量

代码实现:

注意本题不需要专门给桌子设置一个专门的互斥信号量(这是缓冲区大小为1的时候的一种特殊情况,可以参考2.3_7_多生产者-多消费者问题_哔哩哔哩_bilibili了解原理)

6.4 读者-写者问题

问题描述:

问题分析:

代码实现:

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

解决方法很简单,只需要额外加一个互斥信号量w即可

6.5 哲学家进餐问题

问题描述:

问题分析:

我们很容易想到使用如下的代码解决方案,但这将导致死锁的发生

如何防止死锁的发生呢?主要有如下解决方案:

  1. 可以对哲学家进程施加一些限制条件,比如最多允许四个哲学家同时进餐。这样可以保证至少有一个哲学家是可以拿到左右两只筷子的;
  2. 要求奇数号哲学家先拿左边的筷子,然后再拿右边的筷子,而偶数号哲学家刚好相反。用这种方法可以保证如果相邻的两个奇偶号哲学家都想吃饭,那么只会有其中一个可以拿起第一只筷子,另一个会直接阻塞。这就避免了占有一支后再等待另一只的情况;
  3. 仅当一个哲学家左右两支筷子都可用时才允许他抓起筷子;更准确的说法应该是:各哲学家拿筷子这件事必须互斥的执行。这就保证了即使一个哲学家在拿筷子拿到一半时被阻塞,也不会有别的哲学家会继续尝试拿筷子。这样的话,当前正在吃饭的哲学家放下筷子后,被阻塞的哲学家就可以获得等待的筷子了;

7.管程

在管程引入之前,实现同步机制和互斥机制一般都是使用信号量机制,但是信号量机制存在的问题就是编写程序困难、易出错;

管程是这样一种机制,让程序员在写程序的时候不需要关注复杂的PV操作;

管程是一种特殊的软件模块,由这些部分组成:
1.局部于管程的共享数据结构说明;
2.对该数据结构进行操作的一组过程(函数);
3.对局部于管程的共享数据设置初始值的语句;
4.管程有一个名字(其实这里可以看出管程类似于面向对象语言中的类);

管程的基本特征:
1.局部于管程的数据只能被局部于管程的过程所访问;
2.一个进程只有通过调用管程内的过程才能进入管程访问共享数据;
3.每次仅充许一个进程在管程内执行某个内部过程;

8.死锁

8.1 死锁的概念

死锁现象简单来说就是进程P0在等待P1的时候,P1也在等待P0;

死锁现象在多进程并发和同步的计算机系统中是必然会产生且不可预见的,故必须依靠操作系统提供的处理机制保证进程的有序推进;

死锁的定义:在并发环境下,各进程因竞争资源而造成的一种互相等待对方手里的资源,导致各进程都阻塞,都无法向前推进的现象,就是“死锁”。发生死锁后若无外力干涉,这些进程都将无法向前推进;

接下来我们辨析三个比较容易让人混淆的三个概念:

  • 死锁:各进程互相等待对方手里的资源,导致各进程都阻塞,无法向前推进的现象;
  • 饥饿:由于长期得不到想要的资源,某进程无法向前推进的现象。比如:在短进程优先(SPF)算法中,若有源源不断的短进程到来,则长进程将一直得不到处理机,从而发生长进程“饥饿”;
  • 死循环:某进程执行过程中一直跳不出某个循环的现象。有时是因为程序逻辑bug导致的,有时是程序员故意设计的;

主要总结了如下三种情况会导致发生死锁:

1.对系统资源的竞争。各进程对不可剥夺的资源(如打印机)的竞争可能引起死锁,对可剥夺的资源(CPU)的竞争是不会引起死锁的;
2.进程推进顺序非法。请求和释放资源的顺序不当,也同样会导致死锁。例如,并发执行的进程P1、P2分别申请并占有了资源R1、R2,之后进程P1又紧接着申请资源R2,而进程P2又申请资源R1,两者会因为申请的资源被对方占有而阻塞,从而发生死锁;

3.信号量的使用不当也会造成死锁。如生产者-消费者问题中,如果实现互斥的P操作在实现同步的P操作之前,就有可能导致死锁。(可以把互斥信号量、同步信号量也看做是一种抽象的系统资源)

结论:总而言之,对不可剥夺资源的不合理分配,可能导致死锁;

死锁的处理策略主要有如下三种:

1.预防死锁。破坏死锁产生的四个必要条件中的一个或几个;
2.避免死锁。用某种方法防止系统进入不安全状态,从而避免死锁(银行家算法);
3.死锁的检测和解除。允许死锁的发生,不过操作系统会负责检测出死锁的发生,然后采取某种措施解除死锁;

这也是我们之后会详细介绍的内容;

8.2 死锁的处理

8.2.1 死锁预防

死锁发生的四个基本条件(必要条件)如下,只要其中任意一个条件不成立,死锁就不会发生:

  • 互斥:资源不能被共享,一个资源每次只能被一个进程使用。
  • 不可剥夺:进程已获得的资源,在未使用完之前,不能强行剥夺。
  • 请求与保持:一个进程因请求资源而阻塞时,对已获得的资源保持不放。
  • 循环等待:若干进程之间形成一种头尾相接的循环性资源等待关系。

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

只需要破坏这四个必要条件中的某个条件,就不会形成死锁,这就是死锁预防的基本思想;

但是“互斥”和“不可剥夺”这两个条件在通常条件下是资源自身特性或程序本身决定,无法直接破坏,所以死锁预防主要是破坏“请求与保持”和“循环等待”两个条件:

  • 请求与保持:
    • 将申请资源的方式修改为一次性申请进程所需的所有资源(当进程在请求资源阻塞时,它不会占有任何资源),这种方式被称为静态分配方法
    • 该策略实现起来简单,但也有明显的缺点:有些资源可能只需要用很短的时间,因此如果进程的整个运行期间都一直保持着所有资源,就会造成严重的资源浪费,资源利用率极低。另外,该策略也有可能导致某些进程饥饿;

  • 循环等待:
    • 避免资源等待形成环路(因为环路等待是死锁的必要条件),只要资源按序申请就一定不会造成死锁,这种方式被称为顺序资源分配法;首先给系统中的资源编号,规定每个进程必须按编号递增的顺序请求资源,同类资源(即编号相同的资源)一次申请完;
    • 原理分析:一个进程只有已占有小编号的资源时,才有资格申请更大编号的资源。按此规则,已持有大编号资源的进程不可能逆向地回来申请小编号的资源,从而就不会产生循环等待的现象;
    • 该策略的缺点:
      • 不方便增加新的设备,因为可能需要重新分配所有的编号;
      • 进程实际使用资源的顺序可能和编号递增顺序不一致,会导致资源浪费;
      • 必须按规定次序申请资源,用户编程麻烦;

这里简单补充一下为什么在实际应用中我们不会选择破坏互斥条件和不可剥夺这两个条件实现死锁预防;

关于破坏互斥条件

基本思想:如果把只能互斥使用的资源改造为允许共享使用,则系统不会进入死锁状态;

实际上是可以有这样的技术的,比如:SPOOLing技术。操作系统可以采用SPOOLing技术把独占设备在逻辑上改造成共享设备。比如,用SPOOLing技术将打印机改造为共享设备;

该策略的缺点:并不是所有的资源都可以改造成可共享使用的资源。并且为了系统安全,很多地方还必须保护这种互斥性。因此,很多时候都无法破坏互斥条件。

关于破坏不可剥夺条件

方案一:当某个进程请求新的资源得不到满足时,它必须立即释放保持的所有资源,待以后需要时再重新申请。也就是说,即使某些资源尚未使用完,也需要主动释放,从而破坏了不可剥夺条件。

方案二:当某个进程需要的资源被其他进程所占有的时候,可以由操作系统协助,将想要的资源强行剥夺。这种方式一般需要考虑各进程的优先级(比如:剥夺调度方式,就是将处理机资源强行剥夺给优先级更高的进程使用)

该策略的缺点:
1.实现起来比较复杂。
2.释放已获得的资源可能造成前一阶段工作的失效。因此这种方法一般只适用于易保存和恢复状态的资源,如CPU。

​ 3.反复地申请和释放资源会增加系统开销,降低系统吞吐量。

​ 4.若采用方案一,意味着只要暂时得不到某个资源,之前获得的那些资源就都需要放弃,以后再重新申请。如果一直发生这样的情况,就会导致进程饥饿。


死锁预防固然是一种解决死锁的方式,但是它需要预先计算资源且预留资源,这样的设计是及其不合理的,所以我们需要想出新的解决办法,这就是我们接下来会介绍的死锁避免;

8.2.2 死锁避免&银行家算法

(银行家算法是必考算法!!!如果觉得看文字抽象就直接看视频讲解2.4_3_死锁的处理策略—避免死锁_哔哩哔哩_bilibili)

死锁避免的基本思想:每次资源申请都要判断是否有出现死锁的危险,如果有危险就拒绝此次申请;

银行家算法是一种优秀的避免死锁的算法:在银行中,客户要向银行申请贷款,每个客户在第一次申请贷款时会声明完成项目所需的最大资金量,客户会分期贷款,且贷款的总数不超过其声明的最大需求量。只有客户贷到了所需的全部资金才能完成项目,也才能向银行归还其全部贷款。银行要考虑的关键问题是如何处理这些贷款请求,既保证银行有钱给客户放款,同时又保证所有客户的总贷款要求得到满足,最终能偿还其全部贷款,这样银行才不会有损失;与银行贷款类比:

(1)银行是操作系统,资金就是资源,客户相当于要申请资源的进程;

(2)客户能最终偿还贷款需要银行满足客户的全部贷款请求,相当于操作系统满足进程的所有资源请求,即让进程执行完成,不造成死锁;

(3)银行判断贷款请求是否应该被批准相当于操作系统判断进程资源请求是否可以被允许,银行没有损失相当于操作系统没有死锁;

(4)操作系统判断这个资源请求是否安全的算法就是银行判断此次贷款是否安全的算法,因此被称为银行家算法

银行家算法的核心在于确保“系统安全”,也就是找到一个安全序列,使得对所有进程的资源请求都存在一种调度方案令其满足,从而能顺利执行完成;

  • 安全序列:所谓安全序列,就是指如果系统按照这种序列分配资源,则每个进程都能顺利完成。只要能找出一个安全序列,系统就是安全状态。当然,安全序列可能有多个。
    • 如果分配了资源之后,系统中找不出任何一个安全序列,系统就进入了不安全状态。这就意味着之后可能所有进程都无法顺利的执行下去。当然,如果有进程提前归还了一些资源,那系统也有可能重新回到安全状态,不过我们在分配资源之前总是要考虑到最坏的情况。
    • 如果系统处于安全状态,就一定不会发生死锁。如果系统进入不安全状态,就可能发生死锁(处于不安全状态未必就是发生了死锁,但发生死锁时一定是在不安全状态)
    • 因此可以在资源分配之前预先判断这次分配是否会导致系统进入不安全状态,以此决定是否答应资源分配请求。这也是“银行家算法”的核心思想。

当然银行家算法一开始只是为了解决银行系统的放贷问题,之后才被用于操作系统避免死锁,而银行系统只有一种类型的资源–money,但是在计算机系统中会存在多种多样的资源,所以思考如何将算法拓展为多种资源的情况;

最简单思想就是将单维的数字拓展为多维的向量,比如:系统中有5个进程P0-P4,3种资源R0-R2,初始数量为(10,5,7),则某一时刻的情况可表示如下:

我们下面直接给出一个例子帮助理解(考题就是这种形式)

当然上述都只是理论上的分析,我们下面简单介绍一下如何编程实现银行家算法

本节最核心的思想:系统处于不安全状态未必死锁,但死锁时一定处于不安全状态,系统处于安全状态一定不会死锁(这个点一定理解,不要觉得说不按照路线来就会导致死锁什么的,只要是安全状态不管怎么作都不可能死锁!!!);

8.2.3 死锁检测/恢复

前面介绍的两种方式都是通过资源使用的限制保证不出现死锁,这样的限制会造成资源使用效率的降低;那么我们就直接放开资源的使用,这意味着一定会造成死锁(因为相当于预防和避免直接跳过),此时我们的死锁检测/恢复就派上用场了;

  1. 死锁检测算法:用于检测系统状态,以确定系统中是否发生了死锁;
    • 经过改造的银行家算法可以进行死锁检测,用于判断进程当前提出的请求是否会导致死锁,也就是通过算法检测哪些进程死锁了;
  2. 死锁解除算法:当认定系统中已经发生了死锁,利用该算法可将系统从死锁状态中解脱出来;
    • 死锁恢复(解除)是指当检测到一个集合中的进程处于死锁状态时,如果使用进程回退法就需要选择一些进程进行回滚,这里就引出更多的问题:

      • 如何进行回滚;
      • 回滚到哪里比较合适;
      • 选择哪些进程回滚合适;

死锁的检测

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

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

针对第一点,我们使用一种被称为资源分配图的数据结构

针对第二点,我们考虑如何基于上述数据结构来分析系统是否处于死锁状态:

  • 如果系统中剩余的可用资源数足够满足进程的需求,那么这个进程暂时是不会阻塞的,可以顺利地执行下去;
  • 如果这个进程执行结束了把资源归还系统,就可能使某些正在等待资源的进程被激活,并顺利地执行下去;
  • 相应的,这些被激活的进程执行完了之后又会归还一些资源,这样可能又会激活另外一些阻塞的进程;

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

如果最终不能消除所有边,那么此时就是发生了死锁;最终还连着边的那些进程就是处于死锁状态的进程;

总结死锁检测算法:依次消除与不阻塞进程相连的边,直到无边可消(注:所谓不阻塞进程是指其申请的资源数还足够的进程)

死锁的解除

注意这里的死锁并不是指的系统中所有的进程都是死锁状态才进行处理,而是指使用死锁检测算法化简资源分配图之后仍然还连接有边的进程就是死锁进程,需要对这些进程进行处理;

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

接下来就需要考虑对哪个死锁进程“动手”使其做出一定牺牲,可以从如下角度考虑

  • 进程优先级

  • 已执行多长时间

  • 还要多久能完成

  • 进程已经使用了多少资源

  • 进程是交互式的还是批处理式的

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

坂.y

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值