1. 背景
独立进程:不和其他进程共享资源和状态
确定性:输入状态决定结果
可重现:能够重现起始条件
并发进程:在多个进程间有资源共享
不确定性
不可重现
并发进程的正确性
- 执行过程是不确定性和不可重现的
- 程序错误可能是间歇性发生的
进程并发的好处
- 共享资源
- 加速 (I/O操作和CPU计算同时进行)
- 模块化(将大程序分解成小程序,如编译,gcc调用cpp,cc1,cc2,as,ld)
1.1 并发创建新进程时的标识分配
调用函数fork()
来创建一个新的进程(操作系统需要分配一个新的并且唯一的进程ID)
在内核中,这个系统调用会运行:
1 | new_pid = next_pid++ |
两个进程并发执行时的预期结果:
- 一个进程ID为100
- 另一个进程ID为101
- next_pid应该增加到102
next_pid也只+1
1.2原子操作
指一次不存在任何中断或失败的操作
- 要么操作成功完成
- 要么操作没有执行
不存在部分执行的状态
操作系统需要利用同步机制在并发执行的同时,保证一些操作是原子操作
2. 现实生活中的同步问题
操作系统和现实生活的问题类比
2.1 家庭采购问题的分析
- 有人去买面包
- 最多只有一个人买
可能的解决办法:
- 在冰箱上设置一个钥匙
- 去买面包之前锁住冰箱并且拿走钥匙������
加锁导致的问题:别人无法取食物
方案一:
使用便签来避免
1 | if(noBread) { |
分析:
检查面包和便签中间,有其他人检查面包和便签
结果:买多了,买了2份gg
解决方案会有间歇性的失败
方案二:
先留便签,后检查面包和便签
1 | leave Note; |
结果:谁也不会去买面包
方案三:
为便签增加标记,以区别不同人的便签
这么做可以行通吗?
结果:最后还没检查面包就退出了,还是谁都没买
方案四:
结果:A等待B离开后,去买面包啦!~
枚举完所有可能后,可以确认方案四
是有效的
分析:
- 它有效,但太复杂
- A和B代码不一样
- 当A在等待时,它不能做其他事(busy-waiting)
方案五:
利用两个原子操作实现一个锁(lock)
Lock.Acquire():
- 在锁被释放前一直等待,然后获得锁
- 如果两个线程都在等待同一个锁,并且同时发现锁被释放了,那么只有一个能够获得锁
Lock.Release():
- 解锁并唤醒任何等待中的进程
1 | breadlock.Acquire(); // 进入临界区 |
2.2 进程的交互关系
相互感知程度
三种关系:
- 互斥(mutual exclusion)- 一个进程占用了资源,其他进程不能使用
- 死锁(deadlock)- 多个进程占用部分资源,形成循环等待
- 饥饿(starvation)- 其他进程可能轮流占用资源,一个进程一直得不到资源
3. 临界区 Critical Section
进程中访问临界资源的一段需要互斥执行的代码
1 | entry section |
还有 进入区 & 退出区 & 剩余区
访问规则:
- 空闲则入
- 忙则等待
- 有限等待:等待区的进程不能无限期地等待下去
- 让权等待(可选)
实现方法:
- 禁用中断
- 软件方法
- 更高级的抽象方法
4. 禁用硬件中断同步方法
没有中断,没有上下文切换,因此没有并发
- 硬件将中断处理延迟到中断被启用之后
- 现代计算机体系结构都提供指令来实现禁用中断
1 | // 临界区域访问代码 |
进入临界区:禁止所有中断,并保存标志
离开临界区:使能所有中断,并恢复标志
缺点:
- 禁用中断后,进程无法被停止
- 可能导致其他进程处于饥饿
- 临界区可能很长(无法确定响应中断的时间)
5. 基于软件的同步方法
两个线程,T0和T1
线程Ti的代码:
1 | do { |
线程可通过共享一些共有变量来同步他们的行为
第一次尝试:
共享变量:
1 | int turn = 0; |
线程Ti的代码:
1 | do { |
结果:满足“忙则等待”,但是有时不满足“空闲则入”
第二次尝试:
结果:不能满足“忙则等待”
第三次尝试:
结果:满足“忙则等待”,但是有时不满足“空闲则入”
5.1 Peterson算法
算法实现:
线程Ti的代码:
1 | do { |
可以完成两个进程的同步
5.2 Dekkers算法
线程Ti的代码:
1 | flag[0] := false; flag[1] := false; turn := 0; // or1 |
这个算法可以扩展到多个线程,不局限2个线程的同步
N线程的软件方法:
算法分析:
- 复杂
- 忙等待(浪费CPU时间)
6. 高级抽象的同步方法
基于硬件提供的同步原语实现的同步方法(如,中断禁用,原子操作指令)
操作系统提供更高级的编程抽象来简化进程同步
例如:锁,信号量
6.1 锁 lock
锁是一个抽象的数据结构(由1个二进制变量和2个操作原语组成)
- 一个二进制变量(锁定 / 解锁)
- Lock::Acquire()
- Lock::Release()
使用锁来控制临界区访问:
1 | lock_next_pid->Acquire(); |
6.2 原子操作指令
现在CPU体系结构都提供一些特殊的原子指令
- 测试和置位指令 Test-and-Set (也叫
TS指令
)- 从内存单元中读取值
- 测试该值是否为1(然后返回真 or 假)
- 内存单元值设置为1
1 | boolean TestAndSet (boolean *target) |
交换指令
- 交换内存里的2个值
1 | void Exchange(boolean *a, boolean *b) |
使用TS指令来实现自旋锁 Spinlock
1 | class Lock { |
特点:线程在等待状态时是占用CPU的
So?What I can do?优化一下!
无忙等待锁
我们实现了让权等待
原子操作指令锁的特征:
优点
- 适用于单处理机或者共享内存的多处理器中任意数量的进程同步
- 简单并容易证明
- 支持多临界区
缺点
- 忙等待
- 可能导致饥饿(进程离开临界区时有多个等待进程的情况)
- 死锁(低优先级进程占用临界区资源等待CPU,高优先级进程获得处理器等待临界区)
总结:
原子指令的方法是适用性最好的。