主要内容
- 背景
- —些概念
- 临界区 (Critical section)
- 方法1:禁用硬件中断
- 方法2:基于软件的解决方法
- 方法3:更高级的抽象
背景
多道程序设计(multi-programming):现代操作系统的重要特性
并行很有用(为什么? )
- 提示:多个并发实体:CPU(s),I/O, …,用户,…
进程/线程:操作系统抽象出来用于支持多道程序设计
CPU调度:实现多道程序设计的机制
调度算法-不同的策略
独立的线程:
- 不和其他线程共享资源或状态
- 确定性=输入状态决定结果
- 可重现→能够重现起始条件,I/O
- I/O调度顺序不重要
合作线程:
- 在多个线程中共享状态
- 不确定性
- 不可重现
不确定性和不可重现意味着bug可能是间歇性发生的
进程/线程,计算机/设备需要合作
优点1:共享资源
- —台电脑,多个用户
- 一个银行存款余额,多台ATM机
- 嵌入式系统〔机器人控制:手臂和手的协调)
优点2:加速
- I/O操作和计算可以重叠
- 多处理器–将程序分成多个部分并行执行
优点3:模块化
- 将大程序分解成小程序使系统易于扩展
- 以编译为例,gcc会调用cpp,cc1,cc2,as,ld
程序可以调用函数fork()来创建一个新的进程
- 操作系统需要分配一个新的并且唯一的进程ID
- 因此在内核中,这个系统调用会运行共享的全局变量翻译成机器指令
- new_pid = next_pidit ++(不是原子操作);
1) LOAD next_pid Regl
2)STORE Regl new_pid
3)INC Regl
4STORE Regl next_pid
假设两个进程并发执行
- 如果next_pid等于100,那么其中一个进程得到的ID应该是100,另一个进程的ID应该是101,next_pid应该增加到102
无论多个线程的指令序列怎样交替执行,程序都必须正常工作
- 多线程程序具有不确定性和不可重现的特点
- 不经过专门设计,调试难度很高
不确定性要求并行程序的正确性
- 先思考清楚问题,把程序的行为设计清楚
- 切忌急于着手编写代码,碰到问题再调试
Race Condition(竞态条件)
系统缺陷:结果依赖于并发执行或者事件的顺序/时间
- 不确定性
- 不可重现
怎样避免竞态?
- 让程序不会被打断
Atomic Operation(原子操作)
原子操作是指一次不存在任何中断或者失败的执行
- 该执行成功结束
- 或者根本没有执行
- 并且不应该发现任何部分执行的状态
实际上操作往往不是原子的
- 有些看上去是原子操作,实际上不是
- 连x++这样的简单语句,实际上是由3条指令构成的
- 有时候甚至连单条机器指令都不是原子的
- Pipeline,super-scalar,out-of-order,page fault
问题
Critical section(临界区)
- 临界区是指进程中的一段需要访问共享资源并且当另一个进程处于相应代码区域时便不会被执行的代码区域
Mutual exclusion (互斥)
- 当一个进程处于临界区并访问共享资源时,没有其他进程会处于临界区并且访问任何相同的共享资源
Dead lock (死锁)
- 两个或以上的进程,在相互等待完成特定任务,而最终没法将自身任务进行下去
Starvation(饥饿)
- 一个可执行的进程,被调度器持续忽略,以至于虽然处于可执行状态却不被执行
临界区
临界区属性
- 互斥:同一时间临界区中最多存在一个线程
- Progress:如果一个线程想要进入临界区,那么它最终会成功
- 有限等待:如果一个线程i处于入口区,那么在i的请求被接受之前,其他线程进入临界区的时间是有限制的
- 无忙等待(可选)∶如果一个进程在等待进入临界区,那么在它可以进入之前会被挂起
临界区代码保护方法
方法1:禁用硬件中断
没有中断,没有上下文切换,因此没有并发
- 硬件将中断处理延迟到中断被启用之后
- 大多数现代计算机体系结构都提供指令来完成
进入临界区
- 禁用中断
离开临界区
- 开启中断
缺点
一旦中断被禁用,线程就无法被停止
- 整个系统都会为你停下来
- 可能导致其他线程处于饥饿状态
要是临界区可以任意长怎么办
- 无法限制响应中断所需的时间(可能存在硬件影响)
所以要小心使用
另外,禁用软件中断无法在多CPU情况下无法解决互斥问题
方法2:基于软件的解决方法
经典逻辑
线程可以共享一些共有的变量来同步他们的行为。
Peterson算法
Dekker算法:第一个针对双线程例子的正确解决方法
Bakery算法——N个进程的临界区
- 进入临界区之前,进程接收一个数字
- 得到的数字最小的进入临界区
- 如果进程Pi和Pj收到相同的数字,那么如果i<j,Pi先进入临界区,否则Pj先进入临界区
- 编号方案总是按照枚举的增加顺序生成数字
基于软件的解决方法总结
复杂
- 需要两个进程间的共享数据项
需要忙等待
- 浪费CPU时间
没有硬件保证的情况下无真正的软件解决方案
- Peterson算法需要原子的LOAD和STORE指令
方法3:更高级的抽象
硬件提供了一些原语
- 像中断禁用,原子操作指令等
- 大多数现代体系结构都这样
操作系统提供更高级的编程抽象来简化并行编程
- 例如:锁,信号量
- 从硬件原语中构建
锁是一个抽象的数据结构
- 一个二进制状态(锁定/解锁),两种方法
- Lock::Acquire()–锁被释放前一直等待,然后得到锁
- Lock::Release()–释放锁,唤醒任何等待的进程
使用锁来编写临界区,前面的例子变得简单起来:
lock_next_pid->Acquire() ; new_pid = next_pid++ ; lock_next_pid->Release() ;
大多数现代体系结构都提供特殊的原子操作指令
- 通过特殊的内存访问电路
- 针对单处理器和多处理器
Test-and-Set测试和置位
- 从内存中读取值
- 测试该值是否为1(然后返回真或假)
- 内存值设置为1
交换
- 交换内存中的两个值
优点
- 适用于单处理器或者共享主存的多处理器中任意数量的进程
- 简单并且容易证明
- 可以用于支持多临界区
缺点
- 忙等待消耗处理器时间
- 当进程离开临界区并且多个进程在等待的时候可能导致饥饿
- 死锁
- 如果一个低优先级的进程拥有临界区并且一个高优先级进程也需求,那么高
- 优先级进程会获得处理器并等待临界区
总结
锁是更高等级的编程抽象
- 互斥可以使用锁来实现
- 通常需要一定等级的硬件支持
常用的三种实现方法
- 禁用中断(仅限于单处理器)
- 软件方法((复杂)
- 原子操作指令(单处理器或多处理器均可)
可选的实现内容:
- 有忙等待
- 无忙等待