同步与互斥
背景
到目前为止
- 多道程序设计(multi-programming):现代OS的重要特征
- 并行很有用:(为什么?),提供了多个并行的实体:CPUs,I/O,...,用户
- 进程/线程:OS抽象出来的用于支持多道程序设计
- CPU调度:实现多道程序设计的机制
- 调度算法:不同的策略
接下来:我们将讨论多道程序设计和并发问题
独立进程:
不和其他进程共享资源或状态
确定性:输入状态决定结果
可重现:能够重现起始条件。I/O
调度顺序不重要
并发进程:
在多个进程间有资源共享
不确定性(执行的时间不确定,有可能在某一个阶段被其他进程抢占,访问共享资源,到执行的结果不确定)
不可重现(并且执行的结果不具有可重复性,不能重现)
并发进程的正确性:
执行过程是不确定性和不可重现的
不确定性和不可重现程序错误可能是间歇性发生的
进程并发执行的好处:
进程需要与计算机中的其他进程和设备进行协作
好处1:共享资源
多个用户使用同一台计算机
银行账号存款余额在多台ATM机操作
机器人上的嵌入式系统协调手臂和手的动作
好处2:加速
I/O操作和CPU计算可以重叠(并行)
程序可划分成多个模块放在多个处理器上并行执行
- 好处3:模块化
将大程序分解成小程序:以编译为例,gcc会调用cpp,cc1,cc2,as,ld
使系统易于复用和扩展
并发创建新进程时的标识分配
程序可以调用函数fork()来创建一个新的进程
操作系统需要分配一个新的并且唯一的进程ID
在内核中,这个系统调用会运行:
new_pid= next_pid++ (next_pid当前计算机进程的最高pid)
翻译成机器指令
LOAD next_pid Reg1
STORE Reg1 new_pid
INC Reg1
STORE Reg1 next_pid
两个进程并发执行时的预期结果(假定next_pid=100)
一个进程得到的ID应该是100
另一个进程的ID应该是101
next_pid应该增加到102
为什么会出现这样的错误:因为在上下文切换之后,进程1的寄存器恢复之后依然保存的是100,使next_pid无法更新为102。典型的异常现象。
我们希望无论多个线程的指令序列怎么交替执行,程序都必须正常工作,
- 多线程具有不确定性和不可重现的特点
- 不经过专门设计,调试难度很高
不确定性要求并行程序的正确性
- 先思考清楚问题,把程序的行为设计清楚
- 切忌基于着手编写代码,碰到问题在调试
上面的错误的现象称为:Race condition 竞态条件
导致的系统缺陷就是结果依赖于并发执行或者时间的顺序/时间
- 不确定性
- 不可重复性
怎样避免竞态
- 让指令不被打断
原子操作(Atomic Operation)
原子操作是指一次不存在任何中断或失败的操作
- 该执行成功结束
- 或者根本没有执行
- 并且不应该发现任何部分执行的状态
实际操作往往不是原子的,
- 有些看上去是原子操作,实际上不是
- 连x++这样的简单语句,实际上是由三条指令构成的
- 有时候甚至连单个机器指令都不是原子的:Pipiline,supper-scalar,out-of-oeder,page fault
上述例子中可能A赢,可能B赢,有可能A,B都不会赢,因为有可能出现当A执行i=i+1后,线程切换到B执行i=i-1,A、B一直处于循环当中。需要同步互斥的机制保证或者A赢或者B赢。
临界区 critical section:
进程中一段需要访问共享资源且当另一个进程处于相应代码区域时便不会被执行的代码区域。
互斥 mutual exclusion:
当一个进程处于临界区并访问共享资源时,没有其他进程会处于临界区并且访问任何相同的共享资源。
死锁 Dead lock
两个或者以上进程,在相互等待完成特定任务,而最终没法将自身任务进行下去
饥饿 starvation:
一个可执行的进程,被调度器持续忽略,以至于虽然处于可执行状态却不被执行
例子:现实生活中的同步问题
什么是“面包太多”问题的正确性质
如果需要,有人会去买面包
最多只有一个人去买面包
可能的解决方法
在冰箱上设置一个锁和钥匙(lock&key)
去买面包之前锁住冰箱并且拿走钥匙
修复了太多的问题,要是有人想要果汁怎么办?
可以改变“锁”的含义
“锁”包含“等待”
Lock(锁):在门、抽屉等物理上加上保护性装置,使得外人无法访问物体内的东西。只能等待解锁后才能访问。
Unlock(解锁):打开保护性装置,使得可以访问之前被锁保护的物体类的东西
Deadlock(死锁):A拿到锁1,B拿到锁2,A想继续拿到锁2后在继续执行,B想继续拿到锁1后再继续执行。导致A和B谁也无法继续执行。
可以通过使用便签来避免购买太多面包,购买之前留下便签(一种锁),购买之后移除便签(解锁),通过添加便笺且实现方式一样的情况下,不知道线程切换在合适发生,可能造成同时购买太多的情况
方案一:
方案二:
方案三:
也会造成谁都不去买面包的情况
方案四:
该方案有效,但太复杂,很难验证它的有效性,且该方法让A去买面包的机会较大
A和B的代码不同
每个进程的代码也会略有不同
如果进程更多,怎么办?
当A在等待时,它不能做其他事
忙等待(busy-waiting)
有其他方法吗?
解决方案:为每个线程保护了一段“临界区(critical-section)”代码
if (nobread) {
buy bread;
}
假设我们有一些锁的实现
Lock.Acquire() 在锁被释放前一直等待,然后获得锁
Lock.Release() 解锁并唤醒任何等待中的进程
这些一定是原子操作——如果两个线程都在等待同一个锁,并且同时发现锁被释放了,那么只有一个能够获得锁
这样面包问题得到很好的解决:
怎么样设计进入临界区和离开临界区?
临界区
临界区的特征:
- 互斥:同一时间临界区中最多存在一个线程
- progress:如果一个线程想要进入临界区,那么它最终会成功
- 有限等待:如果一个线程i处于入口区,那么i的请求被接受之前,其他线程进入临界区的时间是有限制的。它不会无限的等待
- 无忙等待(可选):如果一个进程在等待进入临界区,那么在它可以进入之前会被挂起。
方法1:禁用硬件中断
没有中断,没有上下文切换,因此没有并发
硬件将中断处理延迟到中断被启用之后
现代计算机体系结构都提供指令来实现禁用中断
进入临界区:禁止所有中断,并保存标志
离开临界区:使能所有中断,并恢复标志
缺点:禁用中断后,进程无法被停止
整个系统都会为此停下来
可能导致其他进程处于饥饿状态
临界区可能很长:无法确定响应中断所需的时间(可能存在硬件影响,外设产生的事件(网络包、时钟信号,硬盘读写)无法及时响应,影响系统效率)
要小心使用:对于多CPU的情况有所限制,只屏蔽一个CPU,其他CPU不受影响
方法2:基于软件的同步解决方法
线程可通过共享一些共有变量来同步它们的行为
第一次尝试
满足互斥(0和1),但是有时不满足progress,如线程0如果不在进入临界区,则进程1将永不会进入临界区
第二次尝试
不满足互斥,如在开始时都为0,跳过while切换,都变为1.,都进入临界区
第三次尝试
满足互斥,但是存在死锁,两个flag都是1.
Peterson算法
满足线程Ti和Tj之间互斥的经典的基于软件的解决方法(1981年)
能够满足互斥、前进、有限等待
Dekkers算法
N线程的软件方法(Eisenberg和McGuire)
进程i之前的先进入了i(i由进入的愿望)再进入,i之后的等i先进入
Bakery算法
N个进程的临界区
- 进入临界区之前,进程接收一个数字
- 数字最小的进入临界区。
- 如果Pi和Pj 得到相同的数字,比较i,j的大小,小的进入。
- 编号方案总是按照枚举的增加顺序生成数字。
总结:
Dekker算法(1965):第一个针对双线程例子的正确解决方案
Bakery算法(lamport 1979):针对n线程的临界区问题的解决方案
复杂:需要两个进程间的共享数据项
需要忙等待:浪费CPU时间
没有硬件保证的情况下无真正的软件解决方案:Peterson算法需要原子的load和store指令
方法3:更高级的抽象方法
硬件提供了一些同步原语
中断禁用,原子操作指令等
大多数现代体系结构都这样
OS提供更高级的编程抽象来简化并行编程
例如:锁、信号量
用硬件原语来构建
锁(lock)
锁是一个抽象的数据结构
一个二进制变量(锁定/解锁)
Lock::Acquire() 在锁被释放前一直等待,然后获得锁
Lock::Release() 解锁并唤醒任何等待中的进程
使用锁来控制临界区访问
lock_next_pid->Acquire();
new_pid = next_pid++ ;
lock_next_pid->Release();
现代CPU体系结构都提供一些特殊的原子操作指令
- 针对特殊的内存访问电路
- 针对单处理器和多处理器
测试和置位(Test-and-Set)指令
从内存单元中读取值
测试该值是否为1(然后返回真或假)
内存单元值设置为1
boolean TestAndSet (boolean *target)
{
boolean rv = *target;
*target = true;
return rv:
}
交换指令(exchange):交换内存中的两个值
void Exchange (boolean *a, boolean *b)
{
boolean temp = *a;
*a = *b;
*b = temp:
}
使用TS(Test-and-Set)指令实现自旋锁(spinlock)
无忙等待锁
如果临界区执行时间很短,可以选择忙等。如果临界区很长,远远大于执行上下文切换开销,则选择无忙等待。
使用Exchange来实现
原子操作指令锁的特征
优点:
适用于单处理器或者共享主存的多处理器中任意数量的进程同步
简单并且容易证明
支持多临界区
缺点
忙等待消耗处理器时间
可能导致饥饿(进程离开临界区时有多个等待进程的情况,有的进程一直抢不到)
死锁(如果一个低优先级的进程拥有临界区并且一个高优先级的进程页需求,那么高优先级进程会获得处理器并等到临界区,忙等,低优先级无法释放放锁)
同步方法总结
锁是一种高级的同步抽象方法:互斥可以使用锁来实现,需要硬件支持
常用的三种同步实现方法:
禁用中断(仅限于单处理器)
软件方法(复杂)
原子操作指令(单处理器或多处理器均可)
可选的实现内容:
- 有忙等待
- 无忙等待