一、背景
协作进程能与系统内的其他执行进程相互影响。协作进程或能直接共享逻辑地址空间(即代码和数据),或能通过文件或消息来共享数据,然而共享数据的并发访问可能导致数据的不一致
1.数据不一致性
- 多个进程并发或并行执行
- 每个进程可在任何时候被中断
- 仅仅进程的部分代码片段可连续执行
- 共享数据并发/并行访问:
- 数据不一致性:又称不可再现性,同一进程在同一批数据上多次运行的结果不一样
- 解决方法-同步(互斥)机制
- 例:有界缓冲
- n个缓冲区的有界缓冲问题
- 增加变量counter,初始化为0
- 向缓冲区增加一项时,counter加1
- 从缓冲区移去一项时,counter减1
- 对于数据不一致的解决方案:关于的counter的加减操作必须被原子性地执行
2.竞争条件
- 竞争条件:多个进程并发执行访问同一共享数据并且执行结果与特定访问顺序有关的情况
- 共享数据的最终结果取决于:最后操作的进程
- 防止竞争条件方法:并发进程同步或互斥
3.同步和互斥
- 同步
- 协调进程的执行次序,使并发进程间能有效地共享资源和相互合作,保证数据一致性
- 协调执行次序
- 互斥
- 进程排他性地运行某段代码,任何时候只有一个进程能够运行
- 互斥访问独占资源
二、临界区问题
1.临界资源(Critical resource)
- 一次只允许一个进程使用的资源
- 又称互斥资源、独占资源或共享变量
- 共享资源:一次允许多个进程使用的资源
- 例:
- 许多物理设备都属于临界资源,如输入机、打印机、磁带机等
- 许多物理设备都属于临界资源,如输入机、打印机、磁带机等
2.临界区
- 涉及临界资源的代码段
- 注意点:
- 进程在执行该区时可能修改公共变量、更新一个表、写一个文件等
- 当一个进程在临界区内执行时,其他进程不允许在它们的临界区内执行
- 临界区是代码片段
- 临界区是进程内的代码
- 每个进程有一个或多个临界区
- 临界区的设置方法由程序员确定
- 若能保证诸进程互斥进入关联的临界区,可实现对临界资源的互斥访问
- 临界区问题:设计一个协议以便协作进程
- 进入区:在进入临界区前,每个进程应请求许可,实现这一请求代码区段为进入区
- 退出区:临界区之后可以有退出区
- 剩余区:其他代码为剩余区
- 处理操作系统临界区问题的两种常用方法:
- 抢占式内核:允许处于内核模式的进程被抢占;需认真设计,以便确保内核数据结构不会导致竞争条件;响应更快,进程在释放CPU之前不会运行任意长的时间;更适合于实时编程
- 非抢占式内核:不允许处于内核模式的进程被抢占;数据结构基本不会导致竞争条件
临界区使用准则
- 互斥(Mutual Exclusion)
- 假定进程Pi在某个临界区执行,其他进程将被排斥在该临界区外
- 有相同临界资源的临界区都需互斥
- 无相同临界资源的临界区不需互斥
- 有空让进(Progress)
- 临界区内无进程执行,不能无限期地延长下一个要进临界区进程的等地时间
- 有限等待(Bounded Waiting)
- 每个进程进入临界区前的等待时间必须有限
- 不能无限等待
访问临界区
- 访问临界区过程
- 在进入区实现互斥准则
- 在退出区实现有空让进准则
- 每个临界区不能过大,从而实现有限等待准则
四、硬件同步
- 基于软件的解决方案并不保证在现代计算机体系结构上正确工作
1.设计步骤
- 使用简单的硬件指令,并有效使用它们来解决临界区问题
- 对于单处理器:临界区问题可以简单的加以解决;在修改共享变量时只要禁止中断出现,就可以保证保证当前指令流有序进行,且不会被抢占;多用于非抢占式内核
- 对于多处理器:多处理器的中断禁止很耗时,中断禁止不适合多处理器
- 许多现代操作系统提供特殊硬件指令,用于检测和修改字的内容,或者用于原子地交换两个字;可以采用这些特殊指令,相对简单地解决临界区问题
五、互斥锁
- 临界区问题的基于硬件地解决方案不但复杂,而且不能为程序员直接使用
1.设计步骤
- 采用互斥锁保护临界区,从而防止竞争条件
- 一个进程在进入临界区时得到锁,它在退出临界区时释放锁
- 每个互斥锁有一个布尔变量available,它的值表示锁是否可用
- 如果锁是可用的,那么调用acquire()会成功,并且锁不再可用
- 当一个进程试图获取不可用的锁时,它会阻塞,直到锁被释放
- 自旋锁:忙等待,当有一个进程在临界区中,任何其他进程在进入临界区时必须连续循环地调用acquire(),因为进程不停地旋转,以等待锁变得可用
- 缺点:即当多个进程共享同一CPU时,这种连续循环显然是个问题;忙等待浪费CPU周期
- 优点:当进程在等待锁时,没有上下文切换(上下文切换可能需要相当长的时间);当使用锁的时间较短时,自旋锁换还是有用的
- 自旋锁常用于多处理器系统,一个线程可以在一个处理器上‘旋转’,而其他线程在其他处理器上执行临界区
六、信号量
1.信号量概念
- 早期
- 硬件解决方法
- 程序设计人员:太复杂
- 信号量-软件解决方案
- 保证两个或多个代码段不被并发调用
- 在进入关键代码段前,进程必须获取一个信号量,否则不能运行
- 执行完该关键代码段,必须释放信号量
- 信号量有值,为正说明它空闲,为负说明其忙碌
2.信号量类型
- 计数信号量:
- 变化范围:没有限制的整型值
- 计数信号量=同步信号量
- 二值信号量:
- 变化范围仅限于0和1的信号量(整型信号量)
- 二值信号量=互斥信号量
3.信号量S的使用
- S必须设置一次且只能一次初值
- S初值不能为负数
- 除了初始化,只能通过执行P、V操作来访问S
- 两个操作都是由操作系统作为基本系统调用来提供的
- 信号量操作应为原子执行
- 如果信号量为负,其绝对值就是等待它的进程数
- 信号量的初值为可用资源数量
- 互斥信号量使用(同一进程中)
- 同步信号量使用(不通过进程中)
- 实现各种同步问题
- 当进程使用资源时,要对信号量执行wait操作;当进程释放资源时,要对信号量执行signal操作;当信号量为0时,所有资源都在使用中,需要使用资源的进程将会阻塞,直到计数器大于0
- 整型信号量:
- 信号量S-整型变量
- 提供两个不可分割的[原子操作]访问信号量
- wait(S)(P(S)、减少信号量的计数,判断条件(S<=0),判断后再进行减操作,存在忙等)、signal(S)(V(S)、增加信号量的计数
- 整型信号量的问题:忙等(解决方案,修改信号量操作(记录型信号量)):
- wait()操作判断信号量是否为正(判断条件(S<0),判断前进行减操作),将要等待的进程添加到与信号量相关的等待队列中,进程切换为等待状态
- signal()操作判断信号量(判断条件(S<=0),判断前进行加操作),将等待队列中的进程唤醒,进入就绪状态进入运行,重新启动阻塞进程P的执行
- 提供两个不可分割的[原子操作]访问信号量
- 信号量S-整型变量
4.信号量S和PV操作的讨论
- 理解信号量的物理含义
- S>0,表示有S个资源可用
- S=0,表示无资源可用
- S<0,则|S|表示S等待队列中的进程数
- 理解wait()(P操作)和signal()(V操作)这两个原子操作
- wait(S),表示申请一个资源
- signal(S),表示释放一个资源
- 注意信号量初值
- 互斥信号量初值一般为1
- 同步信号量初值一般为0-N的整数
- 信号量使用中注意的问题
- P.V操作必须成对出现,有一个P操作就一定有一个V操作
- 当为互斥操作时,P、V操作同处于同一进程
- 当为同步操作时,则P、V操作不在同一进程中出现
- 如果P(S1)和P(S2)两个操作在一起,那么P操作的顺序至关重要,如果使用不当会造成死锁
- 同步P和互斥P操作在一起时,同步P操作在互斥P操作前,而两个V操作无关紧要
- 例:
- 死锁:是指两个或两个以上的进程在执行过程中,因争夺资源而造成的一种互相等待的现象,若无外力作用,它们都将无法推进下去
七、经典同步问题
1.有界缓冲区问题(生产者-消费者问题)
概念
- 生产者(M 个):生产产品,并放入缓冲区
- 消费者(N 个):从缓冲区取产品消费
- 问题:如何实现生产者和消费者之间的同步和互斥
互斥分析
- 临界资源
- 生产者(多个):
- 把产品放入指定缓冲区
- in:所有的生产者对in指针需要互斥
- counter:所有生产者消费者进程对counter互斥
- 消费者(多个):
- 从指定缓冲区取出产品
- out:所有消费者对out指针需要互斥
- counter:所有生产者消费者进程对counter互斥
- 生产者(多个):
- 划分临界区
- 增加互斥机制
同步分析
- 两者需要协同的部分
- 生产者:把产品放入指定缓冲区
- 消费者:从满缓冲区取出一个产品
- 三种运行次序(不同条件下不同运行次序)
- 所有缓冲区空时:
- 所有缓冲区满时:
- 缓冲区有空也有满时:
生产者与消费者
同步信号量定义
2.数据读写操作(读者-写者问题)
概念
- 假设一个数据库为多个并发进程所共享。有的进程可能只需要读数据库,而其他进程可能需要更新(即读和写)数据库
- 称前者为:读者
- 称后者为:写者
- 两组并发进程:读者和写者
- 共享一组数据区进行读写
- 允许多个读者同时读
- 不允许读者、写者同时读写
- 不允许多个写者同时写
- 两个读者同时访问共享数据,并不会产生不利结果
- 若个一个作者和其他线程(或读者或写者)同时访问数据库,那么将会产生混乱
问题详述
- 读者-写者问题:确保在作者写入数据库时具有共享数据库独占的访问权,不会产生混乱
- 第一读者-作者问题(读者优先):要求读者不应保持等待,除非作者已获得权限使用共享对象
- 读者:
- 无读者、写者,新读者可读
- 有写者等,但其它读者在读,则新读者也可读
- 有写者写,新读者等
- 写者:
- 无读者和写者,新写者可写
- 有读者,新写者等待
- 有其他写者,新写者等待
- 方案一:
- 存在问题:读者不能同时读;有写者等,但其它读者在读,则新读者也可读
- 方案二:
- 增加一个读者计数器rc,设置初始值为0;再增加一个互斥信号量M,设置初始值为1
- 信号量M为确保更新rc时的互斥,初始值为1
- 信号量W为读者和写者进程所共用,供作者作为互斥信号量,也作为第一个进入临界区和最后一个离开临界区的读者所使用,初始值为1
- 变量rc用于跟踪多少进程正在读对象,初始值为0
- 读者:
- 第二读者-作者问题(写者优先):一旦作者就绪,那么作者会尽快可能地执行;如果有一个作者等待访问对象,那么不会有新的读者可以开始读
- 有些系统将读者-写者问题及其解答进行了抽象,从而提供了读写锁
- 在获取读写锁时,需要指定锁的模式:读访问或写访问
- 当一个进程只希望读共享数据时,可申请读模式的读写锁
- 当一个进程只希望修改共享数据时,应申请写模式的读写锁
- 多个进程可允许并发获取读模式的读写锁,但是只有一个进程可获取写模式的读写锁,作者进程需要互斥的访问
- 第一读者-作者问题(读者优先):要求读者不应保持等待,除非作者已获得权限使用共享对象
3.资源竞争(哲学家就餐问题)
概念
- 哲学家就餐问题:经典的同步问题,大量并发控制问题的一个例子。这个例子满足:在多个进程之间分配多个资源,而且不会出现死锁和饥饿
- 问题描述:
- 5个哲学家
- 5根筷子
- 哲学家左右各有一根筷子
- 哲学家只有拿起左右两个筷子才能吃饭
- 多个进程共享资源竞争问题
- 把5根筷子看作5个互斥信号量,任意一个哲学家只有拿起左右两根筷子,也就是获得左右两个信号量才能吃饭
- 吃完饭,这些就应该放下左右两根筷子,也就是释放左右两个信号量
解决方法
- 方案一:
- 简单的解决方法是每只筷子都用一个信号量来表示。一个哲学家通过执行操作wait()试图获取相应的筷子,他会通过执行操作signal()以释放相应的筷子
- 存在问题:每个哲学家同时执行wait(chopStick[i]),拿起左边筷子,所有筷子的信号量现在均为0,当每个哲学家试图拿起右边的筷子时,他将会被永远推迟,导致死锁
- 死锁:是指两个或两个以上的进程在执行过程中,因争夺资源而造成的一种互相等待的现象,若无外力作用,它们都将无法推进下去
- 死锁导致进程无法推进、资源无法使用,是必须解决的
- 死锁解决措施
- 最多允许4个哲学家同时坐在桌子周围(必然有一个人可以拿起左右两根筷子)(降低了并发度,系统不知道那个被禁止)
- 仅当一个哲学家左右两边的筷子都可用时,才允许他拿起筷子(筷子要一起拿)
- 两根筷子都空闲,则该哲学家可以拿起两根筷子吃饭
- 否则,只要有一根筷子在被其他哲学家使用,那么两根筷子都无法拿到
- 为了避免死锁,所以把哲学家分为三种状态:思考、饥饿、进食,并且一次拿到两只筷子,否则不拿
- 设置5个信号量,对于5个哲学家
- 给所有哲学家编号,奇数号的哲学家必须首先拿左边的筷子,偶数号的哲学家则反之(效率低,并不知道进程会申请什么资源)
- 最多允许4个哲学家同时坐在桌子周围(必然有一个人可以拿起左右两根筷子)(降低了并发度,系统不知道那个被禁止)
4.信号量同步的缺点
- 同步操作分散:信号量机制中,同步操作分散在各个进程中,使用不当就可能导致各进程死锁(如P、V操作的次序错误、重复或遗漏)
- 易读性差:要了解对于一组共享变量及信号量的操作是否,必须通过读整个系统或者并发程序
- 不利于修改和维护:各模块的独立性差,任一组变量或一段代码的修改都可能影响全局
- 正确性难以保证:操作系统或并发程序通常很大,很难保证这样一个复杂的系统没有逻辑错误
八、管程
1.信号量机制的问题
- 优点:
- 程序效率高、编程灵活
- 问题:
- 需要程序员实现,编程困难
- 维护困难、容易出错
- wait/signal位置错
- wait/signal不配对
- 解决方法:
- 管程(1970s,Hoare和Hansen)
- 由编程语言解决同步互斥问题,而不是程序员
- 信号量是分散式;管程是集中式
2.管程的概念
- 抽象数据类型(ADT):封装了数据及对其操作的一组函数,这一类型独立于任何特定的ADT实现
- 管程类型属于ADT类型,提供一组由程序员定义的、在管程内互斥的操作
- 管程类型也包括一组变量,用于定义这一类型的实例状态,也包括操作这些变量函数的实现
- 管程类型的表示不能直接由各种进程所使用
- 只有管程内的定义的函数才能访问管程内的局部声明的变量和形式参数
- 管程的局部变量只能为局部函数所访问
- Hansen的管程定义:一个管程定义了一个数据结构和能为并发进程所执行(在该数据结构上)的一组操作,这组操作能同步进程和改变管程中的数据
- 高级同步构建类型
- 管程是对提供线程安全机制的高度抽线
- 任一时刻在管程中只有一个线程能运行
- 结构:
3.管程功能
- 互斥:
- 管程中的变量只能被管程中的操作访问
- 任何时候只有一个进程在管程中操作
- 类似临界区
- 由编译器完成
- 同步:
- 管程在处理某些同步问题时,还不够强大,需要自行附加的同步机制,可由条件(condition)结构来提供
- 当程序员需要编写定制的同步方案时,他可以定义一个或多个类型为condition的变量
- 条件变量
- 唤醒和阻塞操作
4.条件变量
- condition x,y
- 条件变量的操作:
- 阻塞操作x.wait():进程阻塞直到另外一个进程调用x.signal()
- 唤醒操作x.signal():唤醒另外一个进程
- 条件变量问题:
- 管程内可能存在不止1个进程
- 如:进程P调用signal操作唤醒进程Q后,此时管程内有P和Q两个进程
- 存在可能:
- P等待直到Q离开管程(Hoare)
- Q等待直到P离开管程(Lampson & Redll,MESA语言)
- P的signal操作是P在管程内的最后一个语句,然后Q开始运行(Hansen,并行Pascal)
- 管程内可能存在不止1个进程
5.Hoare管程
- 进程互斥进入管程
- 如果有进程在管程内运行,管程外的进程等待
- 入口队列:等待进入管程的进程队列
- 管程内进程P唤醒Q后
- P等待,Q运行
- P加入紧急队列
- 紧急队列的优先级高于入口队列
- condition x
- x.wait()
- 紧急队列非空:唤醒第一个等待进程
- 紧急队列空:释放管程控制权,允许入口队列进程进入管程
- 执行该操作进程进程进入x的条件队列
- x.signal()
- x的条件队列空:空操作,执行该操作进程继续运行
- x的条件队列非空:唤醒该条件队列的第一个等待进程,执行唤醒后的进程,而该操作进程进入紧急队列