1 进程的并发执行
1.1 问题的提出
并发是所有问题产生的原因, 也是操作系统设计的基础。
1.2 进程的特征
表1-1 进程的特征
进程的特征 | 说明 |
---|---|
并发 | 进程的执行是间断性的,相对执行速度不可预测 |
共享 | 进程/线程之间的制约性,并发环境下多个进程/线程会共享资源 |
不确定性 | 进程的执行结果与其执行的相对速度有关,都是不确定的 |
1.3 并发执行过程分析
本节用一个小例子演示并发执行过程以及可能出现的错误。
场景:假设有四个消息队列:f,s,t,g。定义三个进程:get, 从f获取一个元素到s;copy, 从s复制元素到t;put, 从t获取元素推到g。不同的执行过程将会产生不同的结果,假设g,c,p是get,copy,put的一次循环。那么执行结果如下表所示:
表1-2 进程执行过程分析
状态 | f队列 | s队列 | t队列 | g队列 | 结果说明 |
---|---|---|---|---|---|
当前状态 | (3,4,5…,m) | 2 | 2 | (1,2) | 初始值 |
g,c,p | (4,5,…,m) | 3 | 3 | (1,2,3) | 正确数据 |
g,p,c | (4,5,…,m) | 3 | 3 | (1,2,2) | 异常数据 |
c,g,p | (4,5,…,m) | 3 | 2 | (1,2,2) | 异常数据 |
c,p,g | (4,5,…,m) | 3 | 2 | (1,2,2) | 异常数据 |
由上表可发现,在没有限制条件时候,进程并发执行很可能出错,导致脏数据的产生,那么应该怎么解决呢?这就需要进程前趋图——即并发环境下进程间的制约关系。
2 进程互斥
2.1 竞态条件(Race Condition)
两个或多个进程读写某些共享数据,而最后的结果取决于进程运行的精确时序,由于不恰当的执行顺序而出现不正确的结果,这种情况叫做竞态条件。
2.2 进程互斥(Mutual Exclusive)
由于各进程要求使用共享资源(变量、文件等),而这些资源需要排他使用,各进程之间竞争使用这些资源,这种关系成为进程互斥。
2.2.1 临界资源:critical resource
共享资源又称为临界资源、互斥资源,是指系统中一次只允许一个进程使用的资源。
2.2.2 临界区(互斥区):critical section
每个进程中对某个临界资源进行操作的程序片段,这些程序片段互为临界区。如下图所示:
2.2.3 临界区的使用原则
- 没有进程在临界区时,想进入临界区的进程可进入
- 不允许两个进程同时处于其临界区中
- 临界区外运行的进程不得阻塞其他进程进入临界区
- 不得使进程无限期等待进入临界区
3 进程互斥的解决方案
3.1 进程互斥的软件解决方案
3.1.1 After you问题
假设有两个进程P和Q,两个进程是否想进入临界区的标识分别为pFlag和qFlag(true表示想进入),初始值都为false,P进程进入临界区的条件是pFlag=true且qFlag=false,当Q进程获取到CPU时间片,开始执行,想进入临界区,则把qFlag修改成true,但此时时间片到了,Q进程下了CPU,接着P进程获取到CPU,P进程也想进入临界区,于是修改pFlag=true,但此时qFlag也等于true,导致P进程也进入不了临界区,造成了虽然临界区空闲但是两个进程都无法进入临界区。这就是After you问题。
3.1.2 Dekker算法
是第一个解决了进程互斥问题的算法。核心思想在于维护了一个turn变量,当两个进程P、Q都想进入临界区时,根据turn变量的值决定由谁进入临界区,并且未进入临界区的进程不断循环turn变量的值以检测是否可以进入临界区。这个算法可能会引发忙等待(busy waiting)问题。
3.1.3 Peterson算法
相较Dekker算法而言是一种更良好的算法。核心思想在于每个进程i在想进入临界区之前都会调用enter_region(i),离开临界区都会调用leave_region(i)方法。相关方法的逻辑见如下代码。
程序清单3-1 Peterson算法核心逻辑
#define FALSE 0
#define TRUE 1
// 进程的个数
#define N 2
// 轮到谁
int turn;
//感兴趣数组,初始值都为FALSE,为TRUE表示对应的进程想进入临界区
int interested[N];
// process=0或1
void enter_region(int process){
int other;
// 另外一个进程的进程号
other=1-process;
// 表明本进程感兴趣
interested[process]=TRUE;
// 设置标志位
turn=process;
// 如果标识变量turn和本进程相等且另外一个进程也想进入临界区,则进入while循环,当另外一个进程不想进入临界区时,则本进程进入临界区
while(turn==process && interested[other]==TRUE);
}
void leave_region(int process){
// 本进程已经离开临界区
interested[process]=FALSE;
}
3.2 进程互斥的硬件解决方案
3.2.1 中断屏蔽的方法
简单来说就是通过开关中断的指令实现。执行“关闭中断”指令—>进程进入临界区操作—>执行“开启中断”的指令。事实上**原语(原子操作)**就是基于这个思想实现的。
该方法的优点在于简单、高效,但是缺点也很明显,代价高,限制了CPU的并发能力,不适用于多处理器。
3.2.2 测试并加锁(TSL)指令
Test and Set Lock, 是指进程每次要进入临界区之前,都要调用enter_region方法,方法的主要实现是:复制锁到寄存器并将锁置1,判断寄存器是否为0,如果不是,重新进入enter_region;如果是0,则调用者进程进入临界区(该方法也是一个忙等待的过程)。当进程离开临界区时,将锁置0。
3.2.3 交换指令(Exchange)
进程每次要进入临界区之前调用enter_region方法时,给寄存器中置1,交换寄存器与锁变量的内容,判断寄存器内容是否为0,若不是0则跳转到enter_region重新执行,若是0则返回调用者进程,且进程进入临界区。
3.3 小结
通过软件的解决方案对编程技巧要求较高。在用硬件的解决方案时,注入TSL指令和Exchange指令会造成忙等待问题。
Busy waiting:进程在得到临界区访问权之前,占用着CPU持续测试而不做其他事情。
在单处理器的系统下,如果唯一的CPU被占用着进行忙等待的操作就对整个系统的运行都不利,但是在多处理器的情况下让一个进程利用自旋锁(Spin lock)占用其中一个处理器进行不断的自旋请求以获取临界区的操作则是比较明智的选择(因为进程切下CPU引起的上下文开销一般要高于自旋锁占用CPU的消耗)。
4 进程同步(synchronization)
进程同步是指多个进程中发生的事件存在某种时序关系,需要相互合作共同完成一项任务。
具体地说,一个进程运行到某一点时,要求另一个伙伴进程为它提供消息,在未获得消息之前,该进程进入阻塞态,获得消息之后被唤醒进入就绪态。
4.1 信号量及P、V操作
4.1.1 概念
信号量是一个特殊的变量,用户进程间传递信息的一个整数值,定义如下:
程序清单4-1 信号量的数据结构
struc semaphore{
int count;
queueType queue;
}
对信号量可进行的操作:初始化(非负数)、P和V(P和V分别是荷兰语的test和increment的首字母)。
4.1.2 P、V操作的定义
程序清单4-2 P/V操作的底层实现原理
// s是一个信号量
P(s){
s.count--;
if(s.count<0){
//该进程状态设置为阻塞状态;
//同时将该进程插入相应的等待队列s.queue末尾;
//最后重新调度
}
}
V(s){
s.count++;
if(s.count<=0){
//唤醒相应等待队列s.queue中等待的一个进程;
//改变其状态为就绪态,并将其插入到就绪队列;
}
}
P、V操作均为原语操作(atomic action),最初提出的二元信号量(只有值0和1)为了解决互斥的问题,后来推广到计数信号量可以用来解决同步问题。
4.1.3 用P、V操作解决进程间的互斥问题
- 设置信号量mutex初值为1
- 在进入临界区前实施P(mutex)
- 在退出临界区后实施V(mutex)
假设有P1、P2、P3这三个进程,用P、V操作解决互斥问题的逻辑示意图如下。当P1进程想进入临界区时,先进行P操作,此时mutex.count=0,此时P1进程进入临界区,然后下了CPU,P2进程此时获得CPU的执行权,想进入临界区之前同样调用P操作,首先是count自减少,此时由于mutex.count=-1<0,P2进程进入阻塞态,同时将进程加入到mutex.queue的队尾;如果此时P2的时间片也到了,下了CPU后由进程P3紧接着获得CPU的执行权,依然是P操作,此时mutex.count=-2,依然进入不了临界区,P3进程也被加入mutex.queue的队尾。此时P3释放了CPU,接着由P1获取CPU的执行权,注意,此时P1还是在临界区的,P1执行完相关操作退出临界区,此时调用V操作,count自增,结果是mutex.count=-1<0,所以操作系统会唤醒等待队列mutex.queue中的一个进程(此时P2、P3进程都在队列中)并将其插入到就绪队列等待CPU调度。当P2接着获取到CPU执行权时候,此时由于P2进程已经执行过P操作,所以进入临界区。
4.1.4 用P、V操作解决生产者消费者问题
程序清单4-3 用P、V操作解决生产者消费者问题
/* 缓冲区默认个数 */
#define N 100
/* 信号量是一种特殊的整型数据 */
typedef int semaphore;
/* 互斥信号量:控制对临界区的访问 */
semaphore mutex=1;
/* 空缓冲区的个数 */
semaphore empty=N;
/* 满缓冲区的个数 */
semaphore full=0;
void producer(void){
int item;
while(TRUE){
item=produce_item();
P(&empty);
P(&mutex);
insert_item(item);
V(&mutex);
V(&full);
}
}
void consumer(void){
int item;
while(TRUE){
P(&full);
P(&mutex);
insert_item(item);
V(&mutex);
V(&empty);
consume_item(item);
}
}
用信号量解决的过程如下图:
思考:如果把代码里的 P(&empty);P&(mutex);顺序换成P&(mutex); P(&empty);可以吗?
答:不可以,会发生死锁问题,至于原因读者可以结合前面说明的P、V操作的特点思考一下。
4.1.5 用P、V操作解决读者写者问题
1 问题描述:多个进程共享一个数据区,这些进程分为两组:
读者进程:只读数据区中的数据
写者进程:只往数据区写数据
需要满足条件:
- 只允许多个读者同时进行读操作
- 不允许多个写者同时操作
- 不允许读者、写者同时操作
本节主要针对读者优先的问题,什么是读者优先呢:
如果读者执行:
- 无其他读者、写者,则该读者可以读
- 若已有写者等,但有其他读者正在读,则该读者也可以读
- 若有写者正在写,该读者必须等
如果写者执行: - 无其他读者、写者,该写者可以写
- 若有读者正在读,该写者等待
- 若有其他写者正在写,该写者等待
2 读者优先问题的解决方案
读者优先问题的关键在于只对第一个读者进程执行P操作,使其能够进入临界区,并且第二个、第三个……进程不需要再进行P操作就可以进入临界区,当最后一个读者进程离开临界区的时候执行V操作。在临界区有读者进程时,由于临界资源被占用,所以任何的写者进程都无法进来。
需要判断某个进程是否是第一个进程或者最后一个进程,需要注意的是readCount++
不是原子操作,并且if(readCount==1)
是先检查再执行操作,可能也有线程安全性问题。所以还需要对这两个操作加入P、V操作。
程序清单4-4 用P、V操作解决读者写者问题
void reader(void){
while(TRUE){
P(mutex);
/* 读者进程数 */
readCount++;
if(readCount==1){
/* 第一个读者 */
P(w);
}
V(mutex);
读操作
P(mutex);
readCount--;
if(readCount==0){
/* 最后一个读者 */
V(w);
}
V(mutex);
// 其他操作
}
}
void writer(void){
while(TRUE){
……
P(w);
写操作
V(w);
}
}
3 实例:Linux中的读-写锁
Linux的IPX路由代码中就使用了读-写锁,保护了路由表的并发访问。
要通过查找路由表实现包转发的程序需要请求读锁;需要添加和删除路由表中入口的程序必须获取写锁(由于通过读路由表的情况比更新路由表的情况多得多,使用读-写锁提高了性能)
5 小结
本篇主要对进程同步互斥概念进行了讲解,介绍了竞态条件、临界区、自旋锁等重要概念。重点分析了信号量和PV操作的原理以及在经典问题模型中的应用。