视频对应内容:
9.1 背景知识
9.2 一些概念part1
9.3 一些概念part2
9.4 一些概念part3
一、同步互斥的背景
到目前为止学习了:
- 多道程序设计(multi-programming):现代操作系统的重要特性;
- 并行很有用,因为:
-
- 多个并发实体:CPU(s),I/O,…,用户,…
- 进程/线程:操作系统抽象出来用于支付多道程序设计;
- CPU调度:实现多道程序设计的机制;
- 调度算法:不同的策略。
将要学习:
- 协同多道程序设计和并发问题。
进程间不独立存在风险
独立的线程:
- 不和其他线程共享资源或状态;
- 确定性:输入状态决定结果;
- 可重现:能够重视启示条件,I/O;
- 调度顺序不重要。
合并线程:
- 在多个线程中共享状态;
- 不确定性;
- 不可重现。
不确定性和不可重现意味着bug可能是间歇性发生的。
进程间为什么合作?
进程/线程,计算机/设备需要合作。
- 优点1:共享资源
-
- 一台电脑,多个用户;
-
- 一个银行存款余额,多台ATM机;
-
- 嵌入式系统(机器人控制,手臂和手的协调)。
- 优点2:加速
-
- I/O操作和计算可以重叠;
-
- 多处理器:将程序分成多个部分并执行。
- 优点3:模块化
-
- 将大程序分解成小程序:以编译为例,gcc会调用cpp,cc1,cc2,as,Id;
-
- 使系统易于扩展。
例:并发执行产生问题
程序可以调用函数fork()来创建一个新的进程
- 操作系统需要分配一个新的并且唯一的进程ID;
- 因此在内核中,这个系统调用会运行
new_pid = next_pid++;
// next_pid为共享的全局变量,赋id用
- 将上述类c语言翻译成机器指令
LOAD next_pid Reg1 // 将new_next值赋给寄存器1
STORE Reg1 new_pid // 将寄存器1值存到new_pid
INC Reg1 // 寄存器1值加一
STORE Reg1 next_pid // 将寄存器1值存到next_pid
假设两个进程并发执行:
- 如果next_pid 等于100,那么其中一个进程得到的ID应该是100,另一个进程的ID应该是101,next_pid应该增加到102。
但是,并发进程存在如下图的问题。
进程1执行一半,发生了上下文切换。
程序可以在上面4条语句中的任意一句进行调度,这样就会出现不同的结果。(不好)
最终进程1、进程2的PID都是100。可能在任何语句间产生上下文切换,导致结果出现不确定性并不可重复。
因此希望:
- 无论多个线程的指令序列怎样交替执行,程序都必须正常工作(希望和预期的结果一样):
-
- 多线程程序具有不确定性和不可重现的特点;
-
- 不经过专门设计,调试难度很高;
- 不确定性要求并行程序的正确性:
-
- 先思考清楚问题,把程序的行为设计清楚;
-
- 切忌急于着手编写代码,碰到问题再调试。
二、Race Condition(竞态条件)
出现竞态条件,就说明会出现不确定性。
系统缺陷:结果依赖于并发执行或者事件的顺序/时间。
- 不确定性
- 不可重现
怎样避免竞态?
- 让指令不被打断
三、Atomic Operation(原子操作)
原子操作: 不可被打断的操作。
原子操作是指一次不存在任何中断或者失败的执行。
- 该执行成功结束;
- 或者根本没有执行;
- 并且不应该发现任何部分执行的状态。
实际上操作往往不是原子的。
- 有些看上去是原子操作,实际上不是;
- 连x++这样的简单语句,实际上是由3种指令构成的;
- 有时候甚至连单条机器指令都不是原子的
-
- Pipeline, super-scalar, out-of-order, page fault
内存读取是原子的,但未必结果确定
如上图,在c语言层次上,保证原子操作,但是在程序设计上出现了问题。
第一种情况:
可能调度器先选择了线程A,先打印A;
第二种情况:
可能调度器先选择了线程B,先打印B;
第三种情况:
当线程1执行到 i=i+1时 ,调度器切换到 i=i-1,相当于 i 的值没有产生变化,就会出现线程A在while循环里一直循环,线程2一直在它的while循环里一直循环,从而出现谁也不可能赢的情况。
我们需要一种机制,让 或者A赢,或者B赢。
四、由此引出相关基本概念
Critical section(临界区)
临界区 是指进程中一段需要访问共享资源(比如上面例子中的 全局变量 i ),并且当另一个进程处于相应代码区域时,不会被执行的代码区域。
Mutual exclusion(互斥)
当一个进程处于临界区并访问共享资源时,没有其他进程会处于临界区并且访问任何相同的共同资源。
Dead lock(死锁)
两个或以上的进程,在相互等待完成特定任务,而最终没法将自身任务进行下去。
Starvation(饥饿)
一个可执行的进程,被调度器持续忽略,以至于虽然处于可执行状态却不被执行。(无限期等待)
五、操作系统调度的生活类比(购买面包)
如上图,出现了两次“买面包”的操作。(买了2份面包)
什么是“面包太多”问题的正确性质?
- 最多有一个人去买面包;
- 如果需要,才去买面包。
在冰箱上设置一个锁和钥匙(lock&key)
- 去买面包之前锁住冰箱并且拿走钥匙;
- 修复了“太多”的问题:要是有人想要果汁怎么办?
- 可以改变“锁(lock)”的含义;
- “锁(lock)”包含“等待(waiting)”。
Lock(锁)
在门、抽屉等物体上加上保护性装置,使得外人无法访问物体内的东西,只能等待解锁后才能访问。
Unlock(解锁)
打开保护性装置,使得可以访问之前被保护的物体类的东西。
Deadlock(死锁)
A拿到锁1,B拿到锁2,A想继续拿到锁2后再继续执行,B想继续拿到锁1后再继续执行。导致A和B谁也无法继续执行。
解决面包购买办法
如上图,增加一种“标签”,当成一种“锁”,但是依然会产生问题。
执行顺序如上图,简单实用note机制无法解决问题,甚至会使问题更糟。
为便签加标签怎么样?表示一下谁放的标签,如下图。
但是如上图,有可能出现谁都不去买面包的情况。
可以提出如上的机制,保证程序的正常执行。
还有更好的解决方案么
之前方案获得的启示
上述方法太复杂,A、B代码不同,A等待时实际在消耗CPU的时间(叫做“忙等待busy-waiting”)。
上述方案为每个线程保护了一段“临界区(critical-section)”,代码为
if (nobread) {
buy bread;
}
如果有一个进程已经处于临界区,则其他进程不能进入临界区。
互斥就是确保只有一个进程在临界区。
假设我们有一些锁的实现
- Lock.Acquire() 在锁被释放前一直等待,然后获得锁;
- Lock.Release() 解锁并唤醒任何等待中的进程;
- 这些一定是原子操作:如果两个线程都在等待同一个锁,并且同时发现锁被释放了,那么只有一个能够获得锁。
- 由此,面包问题得到解决:
breadlock.Acquire(); // 进入临界区
if (nobread) {
buy bread;
}
breadlock.Release(); // 退出临界区