在单处理器多道程序设计系统中,进程会被交替地执行,因而在宏观上表现出一种并发执行的外部特征。
而多处理器系统中,不仅可以从单个核心的角度看是交替执行进程,还可以在多个核心的角度上看多个进程是重叠执行的。
单处理器条件下,中断可能会在进程中的任何地方停止指令的执行,对于一个共享内存,多个进程间紧密的交互访问,会导致读写混乱。
在多处理器条件下,还有可能多个进程同时试图访问同一个全局变量,也会引发问题。
竞争条件是并发的产生的问题
竞争条件发生在多个进程读写数据时,最终结果取决于多个进程的执行执行的顺序。
竞争进程面临互斥的问题
多个进程需要访问一个不可共享的资源,称之为临界资源(Critical Resource),
使用临界资源的那部分程序称为程序的临界区(Critical Section)。一次只允许有一个程序在临界区中。
互斥会导致死锁
两个进程都在等待对方进程已经获得的临界资源,在获得其他资源前,谁都不会释放自己已拥有的资源,此时这两个进程就会发生死锁。
互斥会导致饥饿
多个进程轮流访问互斥资源,导致其他进程无限被拒绝资源访问。
支持互斥必须临界区只有一个进程在执行
要提供对互斥的支持,必须满足以下要求:
-
必须强制实施互斥。
在与相同资源或共享对象的临界区有关的所有进程中,一次只允许一个进程进入临界区。
-
一个在非临界区停止的进程不能干涉其他进程。
-
不允许出现需要访问临界区的进程被无限延迟,即不会出现死锁或饥饿。
-
没有进程在临界区中,任何需要进入的进程必须能立即进入。
-
对相关进程的执行速度和处理器没有任何要求和限制。
-
一个进程驻留在临界区中的事件必须是有限的。
满足这些互斥条件的方法有多种:
通过软件方法支持互斥
通过机器指令支持互斥
单处理器用中断禁用的方式保证互斥
单处理器机器中,并发只能交替。一个进程在调用一个系统服务或被中断前,将一直运行。因此,为保证互斥,只需保证一个进程不被中断即可。
while (true) {
// 禁用中断
/*
* 临界区
*/
// 启用中断
}
这种方式代价高,且不适用于多处理器体系结构中。
专用机器指令
通过操作系统或程序语言支持互斥
信号量
多个进程通过简单的信号进行合作,强迫进程在某个位置停止,知道接收到一个特性的信号。进程通过原语 semSignal(s)
发生信号,
通过原语 semWait(s)
接收信号。
计数信号量
struct semaphore {
int count;
queueType queue;
}
void semWait(semaphore s) {
s.count --;
if (s.count < 0) {
// 把当前进程插入队列
// 阻塞当前进程
}
}
void segSinal(semaphore s) {
s.count++;
if (s.count <= 0) {
// 把进程P从队列中移除
// 把进程P插入就绪队列
}
}
其中,
semWait
和semSinal
原语被假设是原子操作。
-
信号量可以被初始化为非负数。
若为正数,则它等于发出
semWait
操作后可立即继续执行的进程的数量。
若为 ,则发出semWait
操作的下一个进程会被阻塞。 -
semWait
操作使信号量减 。若值变成负数,则阻塞执行
semWait
的进程,负值等于正在等待解除阻塞的进程的数量。
否则进程继续执行。 -
semSignal
操作使信号量加 。若信号量值小于等于 ,每个
semSignal
操作都会将被semWait
操作阻塞的一个进程解除阻塞。
二元信号量
二元信号量的值,只能是 和 。
struct binary_semaphore {
enum(zero, one} value;
queueType queue;
}
void semWaitB(binary_semaphore s) {
if (s.value == one) {
s.value = zero;
} else {
// 把当前进程插入队列
// 阻塞当前进程
}
}
void segSinalB(binary_semaphore s) {
if (s.queue is empty()) {
s.value = one;
} else {
// 把进程P从等待队列中移除
// 把进程P插入就绪队列
}
}
其中,
semWaitB
和semSinalB
原语被假设是原子操作。
- 二元信号量的初始值为 或 。
semWaitB
操作检查信号的值。若为 ,则进程执行semWaitB
就会受阻。否则,将值改为 ,并继续执行该进程。semSignalB
操作检查是否有任何进程在该信号上阻塞。如果有,则将受阻的进程唤醒。否则,置值为
例:
进程A、B、C、依赖进程D的结果。
- 初始时刻(1),A正在运行,B、C、D就绪,信号量为 。A执行一条
semWait
指令后,信号量减为 ,A能继续执行,随着时间片超时,加入就绪队列。 - 时刻(2),B执行一条
semWait
后,进入阻塞。 - 在时刻(3),D被允许执行。当D在时刻(4)发出一个
semSignal
指令后,允许B移到就绪队列。 - 在时刻(5),D时间片超时,移到就绪队列,C开始运行。当执行
semWait
指令后被阻塞。 - 类似的,在时刻(6),A和B运行,且被阻塞在这个信号量上。调度D执行,当D有一个结果后,执行
semSignal
指令,把C移到就绪队列中。随后,D循环将解除A和B的阻塞状态。
通过信号量解决互斥问题
- 信号量初始化为 ,这样第一个执行
semWait(s)
的进程可以直接进入临界区,并把s
的值置为 。 - 任何试图进入临界区的进程,尝试进入失败后都会使
s
的值减 。 - 当最初进入临界区的进程离开时,
s
曾 ,一个被阻塞的进程(如果有的话)被移除等待队列,置于就绪态。当操作系统调度时,便可进入临界区。
管程
使用信号量设计一个正确的程序是很困难的。在于 semWait
和 semSignal
操作可能分布在整个程序中,很难看出信号量上的这些操作所产生的整体效果。
管程是一种程序设计语言结构,提供的功能与信号量相同,更易于控制。
用管程可以锁定任何对象,对于类似链表之类的对象,可以用一个锁锁住整个链表,也可以每个表用一个锁,还可以为表中的每个元素用一个锁。
一个管程定义了一个数据结构和能为并发进程所执行(在该数据结构上)的一组操作,这组操作能同步进程和改变管程中的数据。
原理:
管程提供一种互斥机制:
- 管程中的数据一次只能被一个进程访问。
- 封装于管程内部的数据仅能被封装于管程内部的过程访问。
- 封装于管程内部的过程仅能访问分装于管程内部的数据。
因此,可以把一个临界资源放在管程中,进程通过管程间接访问该资源,从而实现了进程互斥。
Hoare模型:
进程在管程中被阻塞后必须马上释放管程,否则其他进程无法进入。因此引入条件变量,每个条件变量保存了一个链表用于记录因该条件变量被阻塞的进程。
并提供两个操作:
cwait(c)
:正在调用管程的进程因c
条件被阻塞,该进程被插入c
条件的等待队列,并释放管程。csignal(c)
:正在调用管程的进程发现c
条件发生了变化,重新启动因c
条件被阻塞的其中一个进程。
Hoare模型要求条件队列至少有一个进程每当另一个进程为该条件产生 csinal
时,立即运行队列中的一个进程。因此,产生 csignal
的进程必须立即退出管程或阻塞在管程上。
缺点:
- 若产生
csignal
的进程在管程内还未结束,则需要两次额外的进程切换。 - 调度程序必须确保在激活相应条件队列中的进程前,没有其他进程进入管程,否则进程被激活的条件可能会改变。
MESA模型:
改进Hoare模型中的 csignal
方法为 cnotify
:
-
通知条件队列,适时出队执行:
当一个正在管程中的进程执行
cnotify(x)
时,会使x条件队列得到通知,发信号的进程继续执行。等待处理器调度条件队列头的进程进入执行。等待的进程被恢复时,会再次检查条件。 -
给每个条件关联一个监视计时器:
无论是否收到通知,等待时间超时的一个进程将被置为就绪,进程被恢复执行时会先进性条件检查,如果满足条件则可继续执行。
该条改进可防止进程执行时,在发出cnotify(x)
信号前失败,导致等待该条件的进程被无限制推迟而处于饥饿状态。