一、并发
1、相关术语
- 原子操作(atomic operation)——不可分割的操作;
- 临界区(critical section)——不允许多个进程同时进入的一段访问共享资源的代码;
- 死锁(deadlock)——两个及以上进程因每个进程都在等待其他进程做完某事(如释放资源),而不能继续执行;
- 活锁(livelock)——两个及以上进程,为响应其他进程中的变化而不断改变自己的状态,但是没有做任何有用的工作;
- 互斥(mutual exclusion)——当一个进程在临界区访问共享资源时,不允许其他进程进入访问;
- 竞争状态(race state)——多个进程/线程读写共享数据,其结果依赖于它们执行的相对速度;
- 饥饿(starvation)——可运行的进程长期未被调度执行;
2、并发的基本特征
并发:在同一时间间隔内发生的进程或线程交替地共享相同的资源。
异步性:相对执行速度不可预测是多道程序系统的基本特征。
3、竞争
竞争出现的条件:
- 在并发环境中发生;
- 多个进程共享数据;
- 多个进程读取且至少一个进程写入;
- 共享数据的最终结果取决于进程执行的相对速度;
竞争引发的控制问题:互斥、死锁、饥饿。
4、操作系统需要考虑的问题
- 并发环境中跟踪每个进程,知道它们的状态;
- 为每个进程分配和回收各种资源:处理器、存储器、文件、I/O设备;
- 保护进程拥有的数据和物理资源;
- 保证进程的结果与相对执行速度无关;
5、进程间的互相作用
间接作用:因为共享而产生竞争,通过共享实现合作;
直接作用:通过通信的合作(进程知道其他进程的存在);
二、互斥
1、互斥的要求
- 在具有相同资源或共享对象的临界区的所有进程中,一次只允许一个进程进入临界区——强制排它;
- 一个在非临界区停止的进程必须不干涉其他进程——充分并发;
- 没有进程在临界区中时,任何需要访问临界区的进程必须能够立即进入——空闲让进;
- 决不允许出现一个需要访问临界区的进程被无限延迟——有限等待;
- 相关进程的执行速度和处理器数目没有任何要求或限制(满足异步条件)——满足异步;
- 若进程无法进入临界区则应该立即释放处理器以防止进程忙等待——让权等待;
2、实现互斥的尝试方案
1、第一种方案
该方案硬性规定了进程进入临界区的顺序——轮流进入,可以保证互斥,但难以支持并发处理。
存在的问题:
- 忙等待(busy waiting):为了等待一事件的发生,重复执行一段循环代码——浪费CPU时间;
- 必须轮流进入临界区的设计不合理,限制了进程的推进速度;
- 如果一个进程失败那么另一个进程将被永久阻塞;
2、第二种方案
该方案中每个进程有自己进入临界区的钥匙,每个进程控制一个标志位表示自己进入和离开临界区,进程先检查对方的标志再设置自己进入临界区的标志。
存在的问题:
- 一个进程在临界区内失败,则另一个进程将被永远阻塞
- 不能保证互斥——两个进程可能同时进入临界区
这是一个错误的方案,不能保证进程的运行结果与执行速度无关。
3、第3种方案
该方案的每个进程先表示自己准备进入临界区,再检查其他进程已经进入临界区,然后再决定自己是否要进入临界区。
这种方案可以保证互斥,但如果flag[0]=true执行完后切换执行flag[1]=true,则会导致死锁!
4、第四种方案
该方案的思想是在循环等待中使用延时给其他进程进入临界区的机会。可以保证互斥,但会导致活锁(当两个进程的执行顺序完全相同的时候)。
这里针对性的描述一下死锁和活锁的区别:
- 死锁:都想进入临界区但都无法进入;
- 活锁:本来可以进入临界区但结果都无法进入;
3、实现互斥的硬件方法
1、关中断
原理:对于单CPU体系结构,如果进程访问临界资源时不被中断(通过中断进行进程的调度),就能保证互斥地访问。
途径:使用关中断/开中断的指令——CLI/STI
缺点:限制了处理器交替执行各进程的能力,且不能用于多处理器结构。
2、专用指令
适用指令:单处理器或共享主存多处理器结构,对同一存储单元的访问是互斥的。
比较和交换指令:(486以上的X86/X64CPU的对应指令为CMPXCHG,可用于操作系统支持并发。)
- 原子指令:在一个指令周期内完成,不会被中断;
- 由两部分构成:比较内存单元与测试值;
- 检查内存单元的值是否与测试值相等,若相等则用一个新值取代内存单元的值(详见下面代码),否则保持不变;
- 总是返回旧内存单元的值,若返回值=测试值则表示该内存单元已经被更新;(结合上一点)
上面函数的返回值如果和测试值相同就说明内存单元已经被更新,产生了交换。整个比较和交换功能按原子操作执行,不接受中断。
实例应用:
进程在得到临界区访问权之前只能继续执行测试变量的指令来得到访问权(忙等待),当一个进程离开临界区,即没有进程在临界区内时bolt被重置为0,此时只有一个等待进程被允许进入临界区,进程的选择取决于哪个进程正好执行紧接着的比较和交换指令。
exchange指令:(X86CPU对应的指令为XCHG)
- 交换一个寄存器和内存单元的内容;
- 原子操作
实例应用:
算法的本质是:bolt+∑keyi = n,如果bolt=0则没有进程在临界区内;如果bolt=1则只有一个进程在临界区中,即keyi=0的那个进程。
锁机制:一个锁变量加两个基础操作Lock和Unlock,用于锁定和解锁临界区。
机器指令的优点:
- 适用于在单处理器或共享内存的多处理器上的任何数目的进程;
- 简单且易于证明;
- 可以支持多个临界区,每个临界区都可以用它自己的变量定义;
缺点:
- 忙等待;
- 可能会引发饥饿:当一个进程离开一个临界区并且有多个进程正在等待时,选择哪一个进程是任意的,因此可能会有进程被无限地拒绝进入;
- 可能会导致死锁:如果进程P1进入临界区后被中断并把处理器让给具有更高优先权的P2,如果P2试图使用同一资源,由于互斥机制它将被拒绝访问。因此P2进入忙等待,而P1由于优先级比P2低所以永远不会被调度执行。
三、信号量
信号量(semaphore):用于进程间传递信号的一个整数值(常用来表示可用资源数目),只有初始化、增、减三种原子操作,可以阻塞/解除阻塞进程。
基本原理:两个或多个进程可以通过简单的信号进行合作,一个进程可以被迫在某一位置停止直到它接收到一个特定的信号。任何复杂的合作需求都可以通过适当的信号结构得到满足。
1、对信号量的操作(仅有三个操作)
基本要求:保证P操作、V操作的原子性。
1、初始化
通常将信号量的值初始化为非负整数(表示可用的资源数)。
2、P操作
- 信号量的值减1——申请一个单位的资源;
- 若信号量的值变成负数,则执行P操作的进程被阻塞——资源已经分配完了,进程只能等待;
3、V操作
- 信号量的值加1——释放一个单位的资源;
- 如果信号量的值不是正数,则其绝对值表示目前被阻塞的进程数,那就让一个因为执行P操作而被阻塞的进程解除阻塞——唤醒一个在等待的进程。
2、二元信号量
- 二元信号量的取值只能为0或1;
- 与其他一般信号量具有相同的表达能力;
- 因为count不能<0,所以要修改判断方式来更新信号量;
- 节省空间;
原语描述:
3、信号量的应用
1、实现互斥
- N个进程访问同一个共享资源(临界资源)
- 将信号量S初始化为1;(只需要申请一个信号量)
- 每个进程进入临界区之前执行P操作:
- S=S-1;
- 若S>=0,则进程进入临界区;
- 若S<0,则进程被阻塞不能进入临界区,加入等待队列;
- 每个进程离开临界区后执行V操作:
- S=S+1;
- 若S<=0,则唤醒一个被阻塞的进程,将其移除等待队列,置为就绪状态,使其在下次操作系统调度的时候可以进入临界区;
2、实现同步
- 理清有多少个同步关系,每个同步关系的前驱动作和后继动作;
- 每个同步关系都需要一个信号量;
- 前驱后做V操作,解除阻塞(如果存在阻塞);
- 后继前做P操作,保证同步;
举例:
上述问题有四个同步关系:<PI(i),PC(i)>,<PC(i),PP(i)>,<PC(i),PI(i+1)>,<PP(i),PC(i+1)>
故设置四个信号量并初始化:empty1=1(B1为空),full1=0,empty2=1(B2为空),full2=0;
三个进程的描述:
P(empty1):检查B1是否为空,是的话输入数据到B1;
V(full):指出B1已经满了;
(PI是<PI(i),PC(i)>的前驱,是<PI(i),PC(i)>的后继,满足上述要求:前驱后做V操作,后继前做P操作,下面的进程也是如此)
4、生产者/消费者问题(并发处理的常见问题类型)
1、对于无限缓冲区的情形:
基于二元信号量的解决方案:
int n; /*缓冲区中产品数*/
Binary_semaphore s=1; /*互斥*/
Binary_semaphore delay=0; /*等待
2、有限循环缓冲区的情形
基于计数信号量的解决方案:
const int sizebuffer=N; /*缓冲区数*/
semaphore n=0; /*缓冲区中产品数*/
semaphore s=1; /*互斥*/
semaphore e=N; /*缓冲区空闲数量*/
上面消费者的p(n)和p(s)两条语句不能颠倒,否则会产生死锁——当一开始消费者先执行p(s),s=0,再执行p(n),n=-1,消费者被阻塞,然后切换到生产者,生产者执行到p(s)时s=-1,被阻塞,无法继续将产品放入缓冲区,这将导致死锁。
四、管程
管程(monitor)是一种封装同步机制与同步策略的程序设计语言结构。
1、软件模块的组成:
- 若干过程;
- 一个初始化序列;
- 局部数据;
- 条件变量;
2、特点:
- 本地变量只能由管程过程访问;(封装)
- 进程通过调用管程过程进入管程;(调用)
- 每次只能有一个进程在执行相关管程的过程;(互斥)
3、缺陷:
- 可能增加了两次多余的进程切换;
- 对进程调度有特殊的要求;(不允许插队)
4、管程提供的互斥机制
- 管程中的数据每次只能被一个进程访问;
- 可将共享数据结构放入管程以得到保护;
5、管程对同步的支持
- 通过csignal(c),cwait(c)操作管程中的条件变量以实现同步(类似信号量的P,V操作,但操作的是条件变量,指明该条件是否满足)
6、存在的问题
- 要求条件队列中至少有一个进程;
- 当一个进程为该条件产生csignal信号时则产生此信号的进程必须立即退出管程或阻塞在管程上以便让队列中被唤醒的进程能够立即进入管程运行;
- 如果产生csignal信号的进程在管程内的运行还未结束,则需要两次额外的进程切换:阻塞已被唤醒的进程运行,等产生信号的进程结束后再恢复被阻塞的进程;
- 进程调度程序必须保证在激活被唤醒的进程前没有其他进程进入管程,否则可能造成将被唤醒的进程永久阻塞;
7、使用通知和广播的管程
使用通知和广播的管程解决上述存在的问题。
特点:
- 用cnotify原语代替原来的csignal操作:发通知的进程不需立即退出管程,接到通知的进程也不立即被唤醒,只是转为就绪,等待何时的时候再进入管程运行。
- 用while循环代替if判断条件是否成立(有额外的条件变量检查但可避免额外的两次进程切换)
cnotify原语:通知特定等待条件队列中的第一个等待进程,但当前执行cnotify原语的进程继续执行;被通知的等待进程转为就绪但必须重新检查条件。
五、消息传递
消息传递是进程通信的一种常用方法。
两个进程通过访问同一个共享内存来进行通讯则称为直接通讯,跟操作系统无关,所以不属于消息传递机制。
1、进程交互的两个基本要求:
- 同步:互斥进程间需要同步,同步指的是对进程执行时序的约束;
- 通信:合作进程间交换信息;
2、消息传递实现互斥
- 使用无阻塞send和阻塞receive的组合;
- 一组进程共享一个信箱box,信箱被初始化为一条无内容的空消息(代表进入临界区的钥匙);
- 每个进程在进入临界区前首先尝试接收消息,离开临界区时将接收到的消息放回信箱。
- 每次只有接收到消息的那个进程才可以进入临界区(实现互斥)
3、消息传递解决生产者-消费者问题
生产者信箱:mayproduce;消费者信箱:mayconsume
六、读者-写者问题(同步与并发设计的著名问题)
1、读者优先方案:
由于读者优先,所以可能导致饥饿——写者一直没办法写。
写者可以写数据,则说明此时已经没有读者,若在写的期间有读者进入,则读者会阻塞在P(wsem)上,等到写者写完才解除该阻塞。确保了写者写期间没有读者可以读。
2、写者优先方案 :
- 为保证写进程优先进入,使用信号量rsem以禁止读进程在写进程正在写数据区的时候进入数据区;
- 为了不让读者插队,需要使得如果写者必须等待读者读完,那写者最多需要等待一个读者就好,其他读者即使先于写者请求访问,但还是排在写者后面,因此引入了一个信号量z,将其他读者阻塞在z的队列里;
- 信号量x用于保证读者数量的正确更新,信号量y用于保证写者数量的正确更新;
- 信号量wsem用于表明写者是否可以写——即是否有读者正在缓冲区,以及用于保证一次只有一个写者在数据区;
信号量初始化:semaphore x=1, y=1, z=1, rsem=1, wsem=1;