第六章 同步
协作进程需考虑数据同步问题,本章讨论多种机制,确保共享同一逻辑地址空间的协作进程有序执行,维护数据的一致性。
6.1 背景
以之前消费者-生产者的例子来说明。使用一个整形变量counter来表示缓冲区里项的数量,生产者每生产一项和消费者每消费一项分别对应着:
counter++;//生产者生产时增加
counter--;//消费者消费时减少
当生产者进程和消费者进程并发执行时语句“counter++”和“counter–”并发执行。通过这两条语句的执行变量counter的值可能是4、5或6!不过唯一正确结果是5,如果生产者和消费者分开执行,则可正确生成。可以这样解释counter值有可能不正确。
语句“counter++”可按如下方式通过机器语言(在一个典型机器上)来实现:
register1 = counter
register1 = register1 + 1
counter = register1
其中register1为CPU本地寄存器,同理,“counter–”可按如下方式实现:
register2 = counter
register2 = register2 - 1
counter = register2
并发执行“counter++”和“counter–”相当于按任意顺序交替执行上述低级语句(每条高级语句内的顺序是不变的)。一种交错如下:
register1 = counter //假设初始时 counter = 5
register1 = register1 + 1 //register1 = 6
register2 = counter //register2 = 5
register2 = register2 - 1 //register2 = 4
counter = register1 //counter = 6
counter = register2 //counter = 4
按照上述顺序执行就得到了counter = 4,最后两行低级命令交换顺序就可以得到不正确的状态counter = 6。因为允许两个进程并发操作变量counter,所以得到不正确的状态。像这种情况,即多个进程并发访问和操作统一数据并且执行结果与特定的访问顺序有关,称为竞争条件(race condition)。为了防止竞争条件,需确保一次只有一个进程可以操作变量counter,未达到这种目的,要求这些进程按一定的方式同步。接下来大部分讨论的都是协作进程如何进行进程同步(process synchronization)和进程协调(process coordination)。
6.2 临界区问题
每个进程有一段代码称为临界区(critical section),进程在执行该区时可能修改公共变量、更新一个表、写一个文件等。当一个进程在临界区内执行时,其他进程不允许在它们的临界区内执行。临界区问题(critical-section problem)是设计一个协议以便协作进程。在进入临界区前,每个进程应请求许可,实现这一请求的代码区段称为进入区(entry section)。临界区之后可以有退出区(exit section),其他代码称为剩余区(remainder section)。
临界区问题的解决方案应满足如下三条要求:
- 互斥(mutual exclusion):一个时间只能有一个进程在其临界区内执行。
- 进步(progress):如果没有进程在临界区内执行,并且有进程需要进入,那么只有不在剩余区内执行的进程可以参加选择,这种选择不能无限推迟
- 有限等待(bounded waiting):从一个进程请求进入临界区到这个请求允许为止,其他进程允许进入其临界区的次数有上限。
处理操作系统临界区问题的两种常用方法:抢占式内核(preemptive kernel)与非抢占式内核(nonpreemptive kernel)。前者允许处于内核模式的进程被抢占,后者不允许,在后者的情况下处于内核模式的进程会一直允许,直到退出内核模式、阻塞或自愿放弃CPU控制。
非抢占式内核的数据结构基本不会导致竞争条件。但是抢占式内核响应更快,抢占式内核更适用于实时编程。
6.3 Peterson 解决方案
Peterson解决方案适用于两个进程交错执行临界区与剩余区。两个进程P0和P1。Peterson解答要求两个进程共享两个数据项:
当使用Pi时,用Pj表示另一个进程,即 j == 1 - i;
int turn; //turn == i 那么进程Pi允许在临界区内执行
bool flag[2]; //flag[i] == true 那么进程Pi准备进入临界区
Pi要进入临界区,首先设置flag[i]的值为true;并且设置turn的值为j,表示如果另一个进程Pj希望进入临界区,那么它可以进入,如果两个进程同时试图进入,那么turn几乎同时设置成i和j,但是先写的值会被覆盖,后写的值会被保留,虽然写的时候是写的对方进程,但实际上,谁先写了turn,谁就能先进入临界区。代码如下:
do{
//进入区
flag[i] = true;
turn = j;
while(flag[j] && turn == j);
//临界区
//退出区
flag[i] = false;
//剩余区
}while(true);
证明解答正确,需要证明:
1.互斥成立。
2.进步要求满足。
3.有限等待要求满足。
当Pi在临界区执行时 turn = i;当Pj在临界区执行时 turn = j;显然不可能同时满足这两个条件,因此互斥成立。
为了证明后面两点应注意到,只有 flag[j] == true && trun == j 成立进程才会陷入while语句,被阻止进入临界区,如果Pj不准备进入临界区,flag[j] = false,Pi就能进入临界区,若Pj准备进入,则看turn的值。若 turn == j,当Pj执行了临界区代码后,执行 flag[j] = false 后,Pi也能进入临界区。如果Pj重新设置flag[j] == true,它也会设置 turn == i,允许Pi进入临界区(进步)。并且Pi 在 Pj进入临界区后最多一次就能进入(有限等待)。
6.4 硬件同步
对单处理器环境,在修改共享变量时只要禁止中断出现就可以解决临界区问题。在多处理器环境下,中断禁止会很耗时,所以这种方案是不可行的。
关于原子指令test_and_set()和compare_and_swap()的介绍请自行查阅。
6.5 互斥锁
基于硬件的解决方案不但复杂,而且不能为程序员直接使用。最简单的软件工具就是互斥锁(mutex lock)。用互斥锁保护临界区,防止竞争条件。一个进程在进入临界区时应该得到锁;退出临界区时应释放锁。函数 acquire() 获取锁,函数 release()释放锁。每个互斥锁有一个布尔变量available,它表示锁是否可用,可用则调用 acquire() 会成功,并且锁不再可用。当一个进程试图获取不可用的锁,它会阻塞,直到锁被释放。
按如下定义 acquire() 和 release():
acquire(){
while(!available);
available = false;
}
release(){
available = true;
}
对 acquire() 或 release() 的调用必须原子地执行。这里所给实现的主要缺点是,它需要忙等待(busy waiting)。
6.6 信号量
一个信号量(semaphore)S是个整型变量,它除了初始化外只能通过两个标准原子操作:wait(),signal()来访问。可按如下定义wait(),signal():
wait(S){
while(S <= 0 )
;//busy wait
S--;
}
signal(S){
S++;
}
在wait(),signal()操作中,当一个进程修改信号量值时,没有其他进程能够同时修改同一个信号量的值。另外,对于 wait(S),S整数值的测试(S <= 0)和修改(S–),也不能被中断。
6.6.1 信号量的使用
操作系统通常区分计数信号量与二进制信号量。计数信号量(counting semaphore)的值不受限制,二进制信号量(binary semaphore)的值只能为0或1。二进制信号量类似互斥锁,可用于提供互斥(没有提供互斥锁的系统上)。
计数信号量可用于控制访问具有多个实例的某种资源。信号量的初值为可用资源数量。当进程需要使用资源时,对该信号量执行 wait() 操作(减少信号量计数);当进程释放资源时,对该信号量执行 signal() 操作(增加信号量的计数)。信号量也可以用来解决各种同步问题。例如,现有两个进程并发运行:P1有语句S1,P2有语句S2,。假设要求只有在S1执行之后才能执行S2。让P1和P2共享同一信号量synch,并初始化为0。在P1中插入语句:
S1;
signal(synch); //这样在这条语句执行之前 synch 值为0,P2始终位于等待状态
在P2中插入语句;
wait(synch); //当S1执行之后,执行了signal(synch)才能跳出等待。
S2;
6.6.2 信号量的实现
信号量操作 wait() 和 signal() 也有忙等待。为了克服这一情况,可以这样修改定义:当一个进程执行操作 wait() 并且发现信号量的值不为正,它必须等待。但是该进程不是忙等待而是阻塞自己。阻塞操作将一个进程放到与信号量相关的等待队列中,并且将该进程状态切换为等待状态。然后控制转到CPU调度程序,以便选择执行另一个进程。等待信号量S而阻塞的进程,在其他进程执行操作 signal() 后,应被重新执行。进程的重新执行是通过操作 wakeup() 来进行的,它将进程从等待状态改为就绪状态,进程被添加到就绪队列。
我们这样定义信号量:
typedef struct{
int value;
struct process *list;
}semaphore;
每个信号量都有一个整数value和一个进程链表list。当一个进程必须等待信号量时,就被添加到进程链表。signal() 从等待进程链表上取走一个进程,并加以唤醒。现在 wait() , signal() 定义如下:
//wait
wait(semaphore *S){
S->value--;
if(S->value < 0){
add this process to S->list;
block();
}
}
//signal
signal(semaphore *S){
S->value++;
if(S->value <= 0){
remove a process P from S->list;
wakeup(P);
}
}
注意,这样实现的信号量的值可以是复数,而在具有忙等待的信号量经典定义下,信号量不能为负。如果信号量的值为负,它的绝对值就是等待它的进程数。
一个关键问题是,信号量操作应原子执行,对同一信号量没有两个进程可以同时执行操作 wait() 和 signal()。对于多处理器环境,SMP系统提供其他枷锁技术,如 compare_and_swap() 或自旋锁,保证 wait() 和 signal() 原子执行。
6.6.3 死锁与饥饿
两个或多个进程无限等待一个事件,而该事件只能由这些等待进程之一来产生。这里的事件是执行操作 signal() 。当出现这样的状态时候,这些进程就为死锁(deadlocked)。这里主要关心的是资源的获取和释放,产生死锁多出现在,各进程获取到一些资源,然后等待另一些资源,而彼此等待的资源被对方获取,然后所有进程就一直处于等待状态。与死锁相关的另一个问题是无限阻塞(indefinite blocking)或饥饿(starvation),即进程无限等待信号量。这里只是简要说明概念,在下一章将会讨论多种处理死锁问题的机制。
6.6.4 优先级的反转
这里简要介绍一下优先级继承协议(priority-inheritance protocol)。根据这个协议,所有正在访问资源的进程获得需要访问它的更高优先级进程的优先级,直到它们用完了有关资源为止,这里这样做的目的是为了防止,在访问资源时被其他进程抢占而引起的错误,,当它们用完时,优先级恢复到原始值。
6.7 经典同步问题
6.7.1 有界缓冲问题
这里给出该解决方案的一种通用结构。生产者和消费者进程共享以下数据结构:
int n; //有n个缓冲区
semaphore mutex = 1; //mutex提供缓冲池访问的互斥要求,初始化为1
semaphore empty = n; //空的缓冲区数量
semaphore full = 0; //满的缓冲区数量
生产者进程如下:
do{
//do something
//produce an item in next_produced
wait(empty); //等待有空的缓冲区
wait(mutex); //等待缓冲池访问权限
//do something
//add next_produced to the buffer
signal(mutex); //释放缓冲池访问权限
signal(full); //满的缓冲区数量增加
}while(true);
消费者进程如下:
do{
wait(full);
wait(mutex);
//do something
//remove an item from buffer to next_consumed
signal(mutex);
signal(empty);
//do something
//consume the item in next_consumed
}while(true);
在这里的结构中,由于 mutex 的控制,消费者和生产者不能同时访问缓冲池。
6.7.2 读者-作者问题
一个数据库为多个并发进程所共享时就会出现读者-作者问题。称只读数据库的进程为读者(reader),称需要更新(即读和写)数据库的进程为作者(writer)。如果一个读者和其他线程同时访问数据库,那么就可能出现错误。为了避免这些问题,我们要求作者在写入数据库时具有共享数据库独占的访问权。这一同步问题称为读者-作者问题(reader-writer problem)。
最简单的问题,通常称为第一读者-作者问题:要求读者不应等待,除非作者已获得权限使用共享对象。第二读者-作者问题:如果有一个作者等待访问对象,那么不会有新的读者可以开始读。前者作者可能饥饿;后者读者可能饥饿。
这里介绍第一读者-作者问题的一个解答,读者进程共享以下数据结构:
semaphore rw_mutex = 1; //读者和作者共用,初始化为1
semaphore mutex = 1; //初始化为1,用于确保更新变量 read_count 时的互斥。
int read_count = 0; //跟踪多少进程正在读对象
作者进程、读者进程代码分别如下:
//writer
do{
wait(rw_mutex);
//writing
signal(rw_mutex);
}while(true);
//reader
do{
wait(mutex);
read_count++;
if(read_count == 1){
wait(rw_mutex);
}
signal(mutex);
//reading
wait(mutex);
read_count--;
if(read_count == 0){
signal(rw_mutex);
}
signal(mutex);
}while(true);
如果有一个作者进程在临界区内,且n个读者处于等待,那么一个读者在rw_mutex上等待,n-1个在mutex上等待。当一个作者执行 signal(rw_mutex) 时,可以重新启动等待的读者或作者的执行,这一选择有调度进程来进行。
有些系统将读者-作者问题及其解答进行了抽象,从而提供读写锁(read-writer lock)。读写锁在容易识别只读和只写共享数据的进程、读者进程数比作者进程数多的两种情况下中更为有用。
6.7.3 哲学家就餐问题
假设有五个哲学家共用一个圆桌,他们只会思考和吃饭。桌子中间有一碗米饭,每两位哲学家之间有一支筷子。当他饥饿时,他会试图拿起与他相近的两根筷子,一次只能拿起一只。同时拥有两根筷子时,他就能吃,吃完后会放下两根筷子,并开始思考。哲学家就餐问题(dining-philosophers problem)是一个经典的同步问题。一种简单的解决方法是每只筷子用一个信号量表示,执行 wait() 操作获取相应筷子,执行 signal() 操作释放相应筷子。
semaphore chopstick[5]; //共享数据
......
do{
wait(chopstick[i]);
wait(chopstick[i+1]);
//eating
signal(chopstick[i]);
signal(chopstick[i+1]);
//thinking
}while(true);
这种解决方案可能导致死锁,如5个哲学家同时拿起左边的筷子。死锁问题有多种可能的补救措施:
- 允许最多四个哲学家同时坐在桌子上。
- 只有两根筷子都可用时,他才能拿起它们(必须在临界区内拿)。
- 使用非对称解决方案,单号哲学家先拿左边的,再拿右边的,双号哲学家先拿右边的,再拿左边的。
应确保没有一个哲学家可能会饿死,没有死锁的解决方案不一定能消除饥饿的可能性。
6.8 管程
6.8.1 使用方法
**抽象数据类型(Abstract Data Type,ADT)封装了数据即对其操作的一组函数。管程类型(monitor type)属于ADT类型,提供一组由程序员定义的、在管程内互斥的操作。管程类型也包括一组变量,用于定义这一类型的实力状态,也包括操作这些变量的函数实现。管程类型的表示不能直接由各种进程所使用。因此,只有管程定义的函数才能访问管程内的局部声明的变量和形式参数。管程类型语法如下:
mmonitor monitor_name{
//shared variable declarations
function P1(...){
...
}
...
functoin Pn(...){
...
}
initialization_code(...){
...
}
}
管程结构确保每次只有一个进程管程内处于活动状态,因此不需要明确编写同步约束。在功能不足以处理某些同步问题时,可定义附加的同步机制(由条件(condition)结构来提供)。在编写定制同步方案时,可以定义一个或多个类型为 condition 的变量:
condition x, y;
对于条件变量,只有操作 wait(), signal() 可以调用:
x.wait(); //意味着调用这一操作的进程会被挂起,直到另一进程调用 x.signal();
操作 x.signal() 重新恢复正好一个挂起进程,如果没有挂起进程则没有作用。
6.8.2 哲学家就餐问题的管程解决方案
下面通过哲学家就餐问题的一个无死锁解答说明管程概念,这个解答加强以下限制:只有当一个哲学家的两根筷子都可用时他才能拿起筷子。需要区分哲学家所处的三个状态,为此引入如下数据结构:
enum{THINKING, HUNGRY, EATING} state[5];
哲学家 i 只有在他的两个邻居不在就餐时,才能设置变量 state[i] = EATING 。
还需声明:
condition self[5];
这让哲学家 i 在饥饿又不能拿到筷子时,可以延迟自己。现在哲学家就餐问题解答描述如下,筷子分布是由管程 DiningPhilosophers 来控制的,它的定义如下:
monitor DiningPhilosophers{
enum {THINKING, HUNGRY, EATING} state[5];
condition self[5];
void pickup(int i){
state[i] = HUNGRY;
test(i);
if(state[i] != EATING)
self[i].wait();
}
void putdown(int i){
state[i] = THINKING;
test((i+4)%5);
test((i+1)%5);
}
void test(int i){
if((state[(i + 4) % 5] != EATING) &&
state[i] == HUNGRY &&
state[(i + 1) % 5] != EATING){
state[i] = EATING;
self[i].signal();
}
}
initialization_code(){
for(int i = 0; i < 5; i++){
state[i] = THINKING;
}
}
}
每个哲学家用餐前调用 pickup(),这可能挂起该哲学家进程,在操作 pickup() 成功后就可以进餐,然后调用 putdown()。
DiningPhilosophers.pickup(i);
...
eat
...
DiningPhilosophers.putdown(i);
观察上述代码,当哲学家 i 不能拿起筷子时,调用 self[i].wait() ,这种情况下说明他邻近的哲学家在进餐,他邻近的哲学家进餐结束后会在 putdown() 调用 test() 以唤醒自身邻近的两位哲学家,这样哲学家 i 会被唤醒。这一解答确保了相邻的两个哲学家不会同时用餐,不会出现死锁,但可能会有哲学家饿死。
6.8 3 采用信号量的管程实现
考虑采用信号量的管程的可能实现。对于每一个管程,都有一个信号量 mutex (初始化为1)。进程在进入管程之前应执行 wait(mutex) ,离开管程之后应执行 signal(mutex) 。由于唤醒进程必须等待,直到重新启动的进程离开或者等待,所以引入一个信号量 next (初始化为0)。唤醒进程可使用 next 挂起自己。还有一个整型变量 next_count 对挂起的进程进行计数。因此每个外部函数 F 会被替换成:
wait(mutex);
...
body of F
...
if(next_count > 0) //如果有挂起的唤醒进程,则唤醒挂起的进程
signal(next);
else
signal(mutex);
这确保了管程内的互斥。
对于实现条件变量,对于每个条件变量 x ,都有一个信号量 x_sem 和一个整型变量 x_count ,两者均初始化为0,x.wait(), x_signal() 可按如下实现:
//x.wait()
x_count++;
if(next_count > 0)
signal(next);
else
signal(mutex);
wait(x_sem);
x_count--;
//x.signal()
if(x_count > 0){
next_count++;
signal(x_sem);
wait(next);
next_count--;
}
这里有点疑问,什么是外部函数???以及 x.wait() ,x.signal() 的实现。
6.8.4 管程内的进程重启
如果多个进程已经挂在条件 x 上,并且有个进程执行了操作 x.signal() ,那么应该选择哪个挂起进程重新运行?一种方法是使用先来先服务顺序。在不满足要求是也可以使用条件等待(conditional-wait)结构。它具有如下形式:
x.wait(c);
c 是整形表达式,在执行 wait() 前计算,称为优先值(priority number),与挂起进程的名称一起存储,当执行 x.signal() 时具有最小优先值的进程会被重新启动。
6.9 同步例子
略
##6.10 替代方法
6.10.1 事务内存
事务内存(transactional memory)的概念原子数据库理论,它提供了一种进程同步的策略。内存事务(memory transaction)为一个内存读写操作的序列,它是原子的。如果事务中的所有操作都被完成,内存事务就被提交,否则应终止操作并回滚。
作为传统加锁方法的替代,可利用事务性内存的优点,为编程语言添加新的特性。假设添加构造 atomic{S} ,它确保 S 中的操作作为事务执行。假设一个函数 update() 用于修改共享数据,采用互斥锁(或信号量)来编写,与按新特性重写的对比如下:
//传统方式
void update(){
acquire();
//modify shared data
release();
}
//重写后
void update(){
atomic{
//modify shared data
}
}
事务内存可以通过软件或硬件来实现:软件事务内存(Software Transactional Memory,STM)完全通过软件实现;硬件事务内存(HardWare Transactional Memory,HTM)。
6.10.2 OpenMP
除了编译指令 #pragma omp parallel外,OpenMP 还提供了编译指令 #pragma omp critical,以指定它后面的代码为临界区,即一次只有一个线程可以在该区内执行。
void update(int value){
counter += value;
}
//使用临界区编译器指令弥补竞争条件
void update(int value){
#pragma omp critical{
counter += value;
}
}
与标准互斥锁相比,它通常被认为共容易,但缺点是,应用程序开发人员仍然必须识别可能的竞争条件,并使用编译器指令充分保护共享数据。
6.10.3 函数式编程语言
只是简单介绍,略。