操作系统—同步 & 互斥
背景
-
独立的线程
➢ 不和其他线程共享资源或状态
➢ 确定性 => 输入状态决定结果
➢ 可重现 => 能够重现起始条件,I/O
➢ 调度顺序不重要
-
合作线程
➢ 在多个线程中共享状态
➢ 不确定性
➢ 不可重现
-
不确定性和不可重现意味着bug可能是间歇性发生的
进程 / 线程,计算机 / 设备需要合作。
合作优点
-
共享资源
➢ 一台电脑,多个用户
➢ 一个银行存款余额,多台ATM机
➢ 嵌入式系统(机器人控制:手臂和手的协调)
-
加速
➢ I/O操作和计算可以重叠
➢ 多处理器:将程序分成多个部分并行执行
-
模块化
➢ 将大程序分解成小程序
√ 以编译为例,gcc会调用cpp,cc1,cc2,as,ld
➢ 使系统易于扩展
新进程分配标识中的可能的异常现象
最主要的问题在于:整个操作的四条机器指令执行过程中,产生了一次上下文切换,使得整个的结果和我们预期不一致了。
调度的时机点可以在四条语句的任何一个位置产生切换,会得到很多不一样的结果,这种交叉切换性会有很多种情况,也就意味着最终的结果具有不确定性 和 不可重复性。
-
无论多个线程的指令序列怎样交替执行,程序都必须正常工作
➢ 多线程程序具有不确定性和不可重现的特点
➢ 不经过专门设计,调试难度很高
-
不确定性要求并行程序的正确性
➢ 先思考清楚问题,把程序的行为设计清楚
➢ 切忌给予着手编写代码,碰到问题再调试
我们必须要有一些新的机制来保证能够达到最终确定的结果,后面会引入同步互斥机制 解决这种不确定性的问题。
一些概念
Race Condition(竞态条件)
-
系统缺陷:结果依赖于并发执行或者时间的顺序 / 时间
➢ 不确定性
➢ 不可重现
-
怎么样避免竞态?
Atomic Operator(原子操作)
-
原子操作是指一次不存在任何终端或者失败的执行
➢ 该执行成功结束
➢ 或者根本没有执行
➢ 并且不应发生任何部分执行的状态
-
实际上操作往往不是原子的
➢ 有些看上去是原子操作,实际上不是
➢ 连
x++
这样的简单语句,实际上是由三条指令构成的➢ 有时候甚至连单条假期指令都不是原子的
√ Pipeline,super-scalar,out-of-order,pape fault
有可能 A A A 赢,有可能 B B B 赢,有可能两个人都无法赢,无法保证。
临界区(Critical section)
- 是指进程中的一段需要访问共享资源并且当另一个进程处于相应代码区域时便不会被执行的代码区域
互斥(Mutual exclusion)
- 是指当一个进程处于临界区并访问共享资源时,没有其他进程会处于临界区并且访问任何相同的共享资源
死锁(Dead lock)
- 是指两个或以上进程,在相互等待完成特定任务,而最终没法将自身任务进行下去,形成循环等待
饥饿(Starvation)
- 是指一个可执行的进程,被调度器持续忽略,以至于虽然处于可执行状态却不被执行
临界区(Critical section)
临界区的属性
-
互斥:同一时间临界区中最多存在一个线程
-
Progress:如果一个线程想要进入临界区,那么它最终会成功
-
有限等待:如果一个线程i处于入口区,那么在i的请求被接受之前,其他线程进入临界区的时间是有限制的
-
无忙等待(可选):如果一个进程在等待进入临界区,那么在它可以进入之前会被挂起
临界区的实现方法
-
禁用中断
-
软件方法
-
更高级的抽象方法
-
不同的临界区实现机制的比较
➢ 性能:并发级别
补充:
-
临界区(critical section)
➢ 进程中访问临界资源的一段需要互斥执行的代码
-
进入区(entry setcion)
➢ 检查是否进入临界区的一段代码
➢ 如可进入,设置相应 “正在访问临界区” 标志
-
退出区(exit section)
➢ 清除 “正在访问临界区” 标志
-
剩余区(remainder section)
➢ 代码中的其余部分
临界区的访问规则
-
空闲则入(Progess)
➢ 没有进程在临界区时,任何进程可以进入
-
忙则等待(互斥)
➢ 有进程在临界区时,其他进程均不能进入临界区
-
有限等待
➢ 等待进入临界区的进程不能无限期的等待
-
让权等待(可选)
➢ 不能进入临界区的进程,应释放CPU(如转换到阻塞状态)
方法1:禁用硬件中断
-
没有中断,没有上下文切换,因此没有并发
➢ 硬件将中断处理延迟到中断被启用之后
➢ 大多数现代计算机体系结构都提供指令来完成
-
进入临界区
➢ 禁用中断
-
离开临界区
➢ 开启中断
用这种方法确实可以解决这个问题,但它还有一些缺点:
-
一旦中断被禁用,线程就无法被停止
➢ 整个系统都会为你停下来
➢ 可能导致其他线程处于饥饿状态
-
要是临界区可以任意长怎么办?
➢ 无法限制响应中断所需的时间(可能存在硬件影响)
-
要小心使用
➢ 适用于临界区很小的情况
在多CPU的情况下存在一定局限性,无法解决互斥问题。
方法2:基于软件的解决方案
第一次尝试
- 满足 “忙则等待”,但有时不满足 “空闲则入”
- T i T_i Ti 不在临界区, T j T_j Tj 想要继续运行,但是必须等待 T i T_i Ti 进入过临界区后
第二次尝试
- 不满足 “忙则等待”
第三次尝试
- 满足 “忙则等待”,但是不满足 “空闲则入”。
Peterson算法
-
满足进程 T i T_i Ti 和 T j T_j Tj 之间互斥的经典的基于软件的解决方法(1981年)
-
Use two shared data items(使用两个共享数据项)
➢
int turn;
// 指示该谁进入临界区➢
boolean flag[];
// 指示进程是否准备好进入临界区 -
Code for ENTER_CRITICAL_SECTION(进入临界区)
flag[i] = TRUE; turn = j; while (flag[j] && turn == j);
-
Code for EXIT_CRITICAL_SECTION(退出临界区)
flag[i] = FALSE;
-
进程 P i P_i Pi 的算法
do { flag[i] = TRUE; turn = j; while (flag[j] && turn == j); CRITICAL SECTION flag[i] = FALSE; REMAINDER SECTION } while (TRUE);
上述算法能够满足互斥、前进、有限等待三种特性;可用反证法来证明。
Dekker算法
进程 P i P_i Pi 的算法
flag[0] := false flag[1] := false := 0 // or 1
do {
flag[i] = TRUE;
while flag[j] == true {
if turn != i {
flag[i] := false
while turn != i {
}
flag[i] := TRUE
}
}
CRITICAL SECTION
turn := j
flag[i] = FALSE;
REMAINDER SECTION
} while (TRUE);
Eisenberg and McGuire算法
N个线程的软件方法
Bakery算法
N个进程的临界区
- 进入临界区之前,进程接收一个数字
- 得到的数字最小的进入临界区
- 如果进程 P i P_i Pi 和 P j P_j Pj 收到相同的数字,那么如果 i < j i<j i<j, P i P_i Pi 先进入临界区,否则 P j P_j Pj 先进入临界区
- 编号方案总是按照枚举的增加顺序生成数字
总结
-
Dekker算法(1965):第一个针对双线程例子的正确解决方案
-
Bakery算法(Lamport 1979):针对n线程的临界区问题解决方案
-
复杂
➢ 需要两个进程的共享数据项
-
需要忙等待
➢ 浪费CPU时间
-
没有硬件保证的情况下无真正的软件解决方案
➢ Peterson算法需要原子的LOAD和STORE指令
方法3:更高级的抽象
-
硬件提供了一些原语
➢ 像中断禁用,原子操作指令等
➢ 大多数现代体系结构都这样
-
操作系统提供更高级的编程抽象来简化并行编程
➢ 例如:锁,信号量
➢ 从硬件原语中构建
-
锁(lock) 是一个抽象的数据结构
➢ 一个二进制状态(锁定,解锁),两种方法
➢
Lock::Acquire()
:锁被释放前一直等待,然后得到锁➢
Lock::Release()
:锁释放,唤醒任何等待的进程 -
使用锁来编写临界区
➢ 前面的例子变得简单起来:
lock_next_pid->Acquire(); new_pid = next_pid++; lock_next_pid->Release();
-
大多数现代体系结构都提供特殊的原子操作指令
➢ 通过特殊的内存访问电路
➢ 针对单处理器和多处理器
-
Test-and-Set测试和置位指令
➢ 从内存中读取值
➢ 测试该值是否为1(然后返回真或假)
➢ 内存值设置为1
boolean TestandSet(boolean *target) { boolean rv = *target; *target = true; return rv; }
-
交换指令(exchange)
➢ 交换内存中的两个值
void Exchange(boolean *a,boolean *b) { boolean tmp = *a; *a = *b; *b = tmp; }
基于TS指令实现自旋锁(spinlock)
该方法存在进程的忙等情况,该如何改进呢?
无忙等待锁
使处于忙等的进程睡眠,在临界区执行完的进程将睡眠的进行唤醒。
如果临界区执行时间短,选择忙等方式;如果临界区执行时间长,选择无忙等待方式。
基于Exchange指令实现
-
共享数据(初始化为0)
➢
int lock = 0;
-
线程 T i T_i Ti
int key; do { key = 1; while (key == 1) exchange(lock, key); critical section lock = 0; remainder section } while (1);
原子操作指令锁的特征
-
优点
➢适用于单处理器或者共享主存的多处理器中任意数量的进程同步
➢简单并且容易证明
➢支持多临界区
-
缺点
➢忙等待消耗处理器时间
➢可能导致饥饿
√ 进程离开临界区时有多个等待进程的情况
➢ 死锁
√ 拥有临界区的低优先级进程
√ 请求访问临界区的高优先级进程获得处理器并等待临界区
总结
-
锁是更高等级的编程抽象
➢ 互斥可以使用锁来实现
➢ 通常需要一定等级的硬件支持(比如 原子操作的指令)
-
常用的三种实现方法
➢ 禁用中断(仅限于单处理器)
➢ 软件方法(复杂)
➢ 原子操作指令(单处理器或多处理器均可)
-
可选的实现内容
➢ 有忙等待
➢ 无忙等待
整理自 【清华大学】 操作系统