OS操作系统系列文章目录
【OS操作系统】Operating System 第一章:操作系统的概述
【OS操作系统】Operating System 第二章:启动、中断、异常和系统调用
【OS操作系统】Operating System 第三章:连续内存分配
【OS操作系统】Operating System 第四章:非连续内容分配
【OS操作系统】Operating System 第五章:虚存技术
【OS操作系统】Operating System 第六章:页面置换算法
【OS操作系统】Operating System 第七章:进程与线程
【OS操作系统】Operating System 第八章:处理机调度
【OS操作系统】Operating System 第九章:同步互斥问题
【OS操作系统】Operating System 第十章:信号量与管程
【OS操作系统】Operating System 第十一章:死锁与进程通信
【OS操作系统】Operating System 第十二章:文件系统
第九章:同步互斥问题
背景
- 独立的线程:
- 不和其它线程共享资源或状态;
- 确定性:输入状态决定结果;
- 可重现:能够重现起始条件,IO;
- 调度顺序不重要;
-
合作的线程:
- 在多个线程中共享状态;
- 存在不确定性;
- 无法可重现;
不确定性和不可重现意味着bug可能是间歇性发生的;
- 进程/线程,计算机/设备需要合作:
- 优点1:共享资源;
- 一台电脑,多个用户;
- 一个银行存款余额,多台ATM机;
- 嵌入式系统;
- 优点2:加速;
- IO操作和计算可以重叠;
- 多处理器,将程序分成多个部分并行执行;
- 优点3:模块化;
- 将大程序分为小程序;
以编译为例,gcc会调用cpp,cc1,cc2,as,1d; - 使系统易于扩展
- 将大程序分为小程序;
- 优点1:共享资源;
不确定性的例子
-
程序可以调用函数fork()来创建一个新的进程
-
操作系统需要分配一个新的且唯一的进程ID;
-
因此会在内核中,系统调用会运行:
new_pid = next_pid++; //next_pid是共享的全局变量
-
- 翻译成机器指令:
LOAD next_pid Reg1 STORE Reg1 new_pid INC Reg1 STORE Reg1 next_pid
- 假设两个进程并发执行:
如果next_pid等于100, 那么其中一个进程得到的ID应该是100,另一个进程应该是101,next_pid应该增加到102;
- 实际情况:
中间进行了一次上下文切换,有进程2返回进程1时,将会恢复切换前的状态,将造成寄存器1中没有进行自增操作;
- 无论多个线程的指令序列怎样交替执行,程序都必须正常工作;
- 多线程程序具有不确定性和不可重现的特点;
- 不经过专门设计,调试难度很高;
- 不确定性要求并行程序的正确性;
- 先思考清除问题,把程序的行为设计清除;
- 切忌给予着手编写代码,碰到问题再调试;
基本概念
- 竞态条件(Race condition)
- 系统缺陷:结果依赖于并发执行或者事件的顺序/时间;
- 不确定性;
- 不可重现;
- 只有让指令不被打断,才能避免竞态;
- 系统缺陷:结果依赖于并发执行或者事件的顺序/时间;
- 原子操作(Atomic operation)
- 原子操作是指一次不存任何中断或者失败的执行;
- 该执行成功结束;
- 或者根本没有执行;
- 并且不应该发现任何部分执行的状态;
- 实际上操作往往不是原子的;
- 有些看上去是原子操作,实际不是;
- 连x++这样简单的语句,实际上是由3条指令构成的;
- 有时候甚至连单挑机器指令都不是原子的;
- 原子操作是指一次不存任何中断或者失败的执行;
- 临界区(Critical section)
- 临界区是指进程中的一段需要访问共享资源,且当另一个进程处于相应代码区域时,便不会被执行的代码区域;
-
互斥(Mutual exclusion)
- 当一个进程处于临界区并访问共享资源时,没有其它进程会处于临界区并且访问任何相同的共享资源;
- 当一个进程处于临界区并访问共享资源时,没有其它进程会处于临界区并且访问任何相同的共享资源;
-
死锁(Dead lock)
- 两个或以上的进程,在相互等待完成特定任务,而最终没法将自身任务进行下去;
- 饥饿(Starvation)
- 一个可执行的进程,被调度器持续忽略,以至于虽然处于可执行状态却不被执行;
临界区的属性
- 互斥:
同一时间,临界区中最多存在一个线程; - 前进(Progress):
如果一个线程想要进入临界区,那么它最终会成功; - 有限等待
如果一个线程p处于入口区,那么在p的请求被接受之前,其它线程进入临界区的时间是有限制的; - 无忙等待(可选):
如果一个进程在等待进入临界区,那么在它可以进入之前会被挂起;
- 进出临界区的方法:
- 方法1:禁用硬件中断;
- 方法2:基于软件的解决方案;
- 方法3:更高级的抽象;
方法1:禁用硬件中断
-
没有中断,就没有上下文切换,因此没有并发操作;
- 硬件将中断处理延迟到中断被启用之后;
- 大多是现代计算机体系结构都提供指令来完成;
-
进入临界区
- 禁用中断;
-
离开临界区
- 开启中断;
-
一旦中断被禁用,线程就无法被停止;
- 整个系统都会为你停下来;
- 可能会导致其它线程处于饥饿状态;
-
缺点:
- 无法限制相应中断所需的时间(可能存在硬件影响);
- 临界区的长度无法确定;
- 执行这种屏蔽中断的指令,只是把自身的响应中断的能力屏蔽了,并不意味着也将其它CPU的响应中断能力屏蔽,所以其实其它的CPU还是可以继续产生中断,所以在多CPU的情况下是无法解决互斥问题的;
- 要小心使用,适合较小的操作;
方法2:基于软件的解决方案
方案1
- 定义一个共享变量 trun;进程 i 想进入临界区,则令 turn = i;
//假设现在有两个进程,分别是Ti,Tj
//请注意,两个进程的代码并非是按顺序执行的,可以想象为并发执行;
//进程Ti想访问临界区
turn = i;
do {
while (turn != i); //判断Tj是否也要访问临界区
critical section; //执行Ti的临界区代码
turn = j; //Ti执行完临界区代码后,是共享变量turn = j;
remainder section; //继续执行其它代码;
} while (1);
//进程Tj想访问临界区
turn = j;
do {
while (trun != j);
critical section;
turn = i;
remainder section;
} while (1);
- 满足互斥属性;
- 弊端:
- 无法满足前进属性;
- 两个进程必须都交替执行临界区代码,才能保证两个进程的继续执行;
- 一旦有一个进程不愿意继续执行,则另一个进程将无法前进;
方案2
- 定义一个共享数组 flag;进程 i 想进入临界区,则令flag[i] = true;
//假设现在有两个进程,分别是Ti,Tj
//请注意,两个进程的代码并非是按顺序执行的,可以想象为并发执行;
//进程Ti尝试访问临界区
do {
while (flag[j]); //查看进程Tj是否也要访问临界区
flag[i] = true; //令对应标记位使能,告诉其它进程,Ti要访问临界区;
critical section; //进程Ti执行临界区代码
flag[i] = false; //令对应标记位置零,表示已经结束访问临界区
remainder section; //继续执行其它代码
} while (1);
//进程Tj尝试访问临界区
do {
while (flag[i]);
flag[j] = true;
critical section;
flag[j] = false;
remainder section;
} while (1);
- 满足前进的属性;
- 弊端:
- 无法满足互斥属性;
- 在初始时刻,两个进程都不想进入临界区,故两个flag都是false;
- 则两个进程都会跳出等待循环,然后都进入临界区;
方案3
- 定义一个共享数组 flag;进程 i 想进入临界区,则令flag[i] = true;
//假设现在有两个进程,分别是Ti,Tj
//请注意,两个进程的代码并非是按顺序执行的,可以想象为并发执行;
//进程Ti尝试访问临界区
do {
//与方案2不一样,先进行标记位的使能
flag[i] = true; //令对应标记位使能,告诉其它进程,Ti要访问临界区;
while (flag[j]); //查看进程Tj是否也要访问临界区
critical section; //进程Ti执行临界区代码
flag[i] = false; //令对应标记位置零,表示已经结束访问临界区
remainder section; //继续执行其它代码
} while (1);
//进程Tj尝试访问临界区
do {
flag[j] = true;
while (flag[i]);
critical section;
flag[j] = false;
remainder section;
} while (1);
- 满足互斥属性;
- 弊端:
- 若出现上下文切换,使得两个线程的标记位都使能了,则两个进程在进入等待循环后,将无法跳出循环,出现死锁情况;
方案4(Peterson算法)
- 定义一个共享变量turn和一个共享数组 flag;turn指示该谁进入临界区,flag指示是否准备好进入临界区;
//假设现在有两个进程,分别是Ti,Tj
//请注意,两个进程的代码并非是按顺序执行的,可以想象为并发执行;
//进程Ti尝试访问临界区
do {
flag[i] = true; //进程Ti的flag使能,表示准备好进入临界区
turn = j; //令变量赋值为其它进程
while (flag[j] && turn == j); //进入等待循环,判断其它进程是否准备好且已进入临界区
critical section; //执行临界区代码
flag[i] = false; //进程Ti的flag置零,表示退出临界区
remainder section; //执行剩余代码
} while (1);
//进程Tj尝试访问临界区
do {
flag[j] = true;
turn = i;
while (flag[i] && turn == i);
critical section;
flag[j] = false;
remainder section;
} while (1);
- 该算法满足互斥、前进和有限等待三个属性;
- 反证法:
假定,现在两个进程都进入临界区,都在执行临界区代码,但是turn是一个共享变量,所以总会有一个线程跳出循环;
方案5(Diskker算法)
- 所需的变量空间相同,但是更加复杂
//假设现在有两个进程,分别是Ti,Tj
//请注意,两个进程的代码并非是按顺序执行的,可以想象为并发执行;
//进程Ti尝试访问临界区
do {
flag[i] = true;
while (flag[j]) {
if (turn != i) {
flag[i] = false;
while (turn != i) {
flag[i] = true;
}
}
}
critical section;
turn = j;
flag[i] = false;
remainder section;
} while (1);
//进程Tj尝试访问临界区
//代码同理
- n进程解决方案1(E&M算法)
针对n个进程保持互斥属性;
大致的思路:
对于进程i而言,对于其前面的进程而言,如果有进程想进入临界区,或者已经进入了临界区,那么i进程就必须等待。而对于i进程后面的进程,必须要等待i进程执行之后在进入临界区。这样就可以通过一个循环的方式完成n个进程有序的进入临界区。
- n进程解决方案2(Bakery算法)
大致思路:
- 总结:
- 即使是针对两个进程的解决竞态的实现还是比较复杂的;
- 需要忙等待,浪费CPU时间;
- 没有硬件包装的情况下,没有真正的软件解决方案;对硬件的需求较低(只需要load操作和store是原子操作即可);
方法3:更高级的抽象——基于原子操作
基本概念
- 硬件提供原语
- 像中断禁用,原子操作指令等;
- 大多数现代体系结构都这样;
- 操作系统提供更高级的编程抽象来简化并行编程
- 例如:锁、信号量;
- 从硬件原语中构建;
- 锁是一个抽象的数据结构
- 一个二进制状态(锁定/解锁),两种方法;
- Lock::Acquire(),锁被释放前一直等待,然后得到锁;
- Lock::Release(),释放锁,唤醒任何等待的进程;
-
使用锁来编写临界区
-
前面的例子将会变得简单:
lock_next_pid->Acquire(); new_pid = nex_pid++; lock_next_pid->Release();
-
-
大多数现代体系结构都提供特殊的原子操作指令
- 通过特殊的内存访问电路;
- 针对单处理器和多处理器;
-
一些特殊的操作:
- Test-and-Set测试和置位(一条机器指令完成了读写操作两条指令的功能)
- 从内存中读取值;
- 测试该值是否为1(然后返回真或假);
- 内存值设为1;
- 交换(交换内存中的两个值)
- Test-and-Set测试和置位(一条机器指令完成了读写操作两条指令的功能)
-
只要计算机系统提供了这两条的其中一条指令就可以很容易的完成互斥问题;
Test-and-Set方法
- 忙等情况
//定义共享变量,锁值value
class Lock {
int value = 0;
}
//某条进程想进入临界区,请求上锁
//如果锁被释放,那么test-and-set读取0,并将值设置为1;锁被设置为忙且需要等待完成;
//如果锁处于忙状态,那么test-and-set读取1,并将值设为1;不改变锁的状态且需要循环(自旋spin);
Lock::Acquire() {
while (test-and-set(value)); //spin
}
//退出临界区,进行解锁
Lock::Release() {
value = 0;
}
- 不忙等情况
解决忙等的情况:先让其睡眠,在最后执行唤醒操作;
//定义共享变量,锁值value,等待队列q;
class Lock {
int value = 0;
WaitQueue q;
}
//某条进程想进入临界区,请求上锁;
Lock::Acquire() {
while (test-and-set(value)) {
add this TCB to wait queue q;
schedule();
}
}
//退出临界区,进行解锁,且唤醒等待线程;
Lock::Release() {
value = 0;
remove one thread from q;
wakeup(t);
}
- 两者的区别:
- 忙等:
不需要上下文切换,但是利用率低,适用于临界区执行时间短的情况; - 不忙等:
需要上下文切换,上下文切换开销较大,适用于临界区很长,远远大于上下文切换所需开销的情况;
- 忙等:
交换的方法
- 当一个进程想要进入临界区时,key = 1, 且lock的初始值为0,所以当执行到while循环的时候,由于执行了交换,交换执行的过程不会被打断进行上下文切换的操作,而后lock的值为1,而key的值为0;所以退出循环,执行临界区代码;
- 需要注意的是,当进入临界区的时候,lock已为1,当其它进程进入临界区执行的时候,lock是1,而key也是1,交换之后都还是1,一直会循环的等待,进入不了临界区;只有当进入临界区的进程,退出临界区后,将lock变为0时,其它进程才会继续执行exchange;
- 这类原子操作的特点:
- 优点:
- 简单且容易证明;
- 适用于单处理器或共享主存的多处理器中任意数量的进程;
- 可以很容易拓展n个进程,可以用于支持多临界区;
- 开销较小;
- 缺点:
- 忙等待消耗处理器时间;
- 等进程离开临界区并且多个进程在等待的时候,可能会导致饥饿现象;
- 出现死锁的情况(例子:如果一个低优先级的进程拥有临界区并且一个高优先级进程也需求,那么高优先级进程会获得处理器并且等待临界区,而低优先级进程无法获得处理器而退出临界区——需要使用优先级反转的方式进行处理);
- 优点:
总结
- 锁是更高级的编程抽象
- 互斥可以使用锁来实现;
- 通常需要一定等级的硬件支持
- 常用的三种实现方式
- 禁用中断(仅限于单处理器);
- 软件方法(复杂);
- 原子操作指令(单处理器或多处理器均可)——更常用;
- 可选的实现内容
- 有忙等待;
- 无忙等待;