第六章 同步
竞态,竞争条件,(race condition) 是指多个进程(线程)并发访问和操作同一数据并且执行结果与特定访问顺序有关。
临界区 (critical section) 问题是指设计一个协议使得协作进程不产生竞态。这个问题的解决方案应该有三要素:
- 互斥。如果进程 A 在临界区内执行,其它进程不能在临界区内执行。
- 进步。如果没有进程在临界区内执行,并且有进程需要进入临界区,那么必须有一个进入。
- 有限等待。任何等待进入临界区的进程最终都必须进入,不能饥饿。
死锁:两个或多个进程无限等待一个事件,而该事件只能由这些等待进程之一来产生。
优先级反转: 无需等待锁的低优先级进程,抢占了一个更低优先级的持锁进程,造成更高优先级的等锁进程等待更久。它的必要条件是系统具有两个以上的优先级。解决方案一般是优先级继承,即持锁进程的优先级被临时设置为锁等待队列中优先级最高的那个进程的优先级,从而防止抢占。
管程: 封装了一些底层同步操作的同步工具。
1. 同步的底层实现
解决临界区问题称为同步。可以通过软件同步(Peterson 解决方案)或硬件同步来实现。现代计算机一般通过硬件同步。单处理器环境和多处理器环境的硬件同步难度大大不同,单处理器只需要禁止中断就可以保证当前指令流有序执行,多处理器环境禁止中断会很耗时,一般通过特殊硬件指令来做。如 TAS (test_and_set), CAS (compare_and_swap) 等。
TAS 和 CAS 都可以用来实现互斥,下面分别阐述。
// TAS
bool test_and_set(bool *target) {
bool rv = *target;
*target = true;
return rv;
}
// 锁的初始值置为 false
bool lock = false;
// 应用逻辑
do {
// 尝试获取锁
while (test_and_set(&lock)); // lock 为true 的情况下会自旋等待,false 才跳出
// 获取成功
/* ... 临界区 */
// 释放锁
lock = false;
} while (true);
TAS 会将 target 置为 true, 并返回 target 的原值。一一对应临界区问题的三要素,首先,想要进入临界区,需要 lock 变量为 false。除了初始化后的 lock 为 false,只有从临界区出来的进程能置 lock 为 false。所以,同时进入临界区的最多就一个进程; 第二,如果没有进程在临界区内,且这个时候存在 1 到多个进程走到内部 while 循环处。这个时候的 lock 必定为 false,因为上一个从临界区出来的/没有任何进程进入过临界区,lock 都为 false。因为 TAS 是原子的,第一个执行这条指令的必定会跳出循环。第三,上述实现并不满足有限等待。
// CAS
int compare_and_wap(int* value, int expected, int new_value) {
int tmp = *value;
(if(*value == expected)
*value = new_value;
return tmp;
}
lock = 0; // lock 初始化为 0
// 应用逻辑
do {
while(compare_and_swap(&lock, 0, 1) != 0; // 自旋
/* 临界区域 */
lock = 0;
} while (true);
CAS 与 TAS 类似。但依然不满足有限等待。
而要实现有限等待,需要引入额外的数据结构和新的算法, 以 TAS 为例:
// 初始化为 false
bool waiting[n]
bool lock
// 应用逻辑
do {
waiting[i] = true; // Pi 想获取锁
key = true;
while(waiting[i] && key) // 当前进程在等待且 key 还没拿到
test_and_set(&lock);
waiting[i] = false;
/*进入临界区*/
// 保证有限等待
j = (i + 1) % n;
while((j != i) && !waiting[j]) // 向后找到一个在等待的进程,如果没有其它进程了,就是本身
j = (j + 1) % n;
if(j == i )
lock = false; // 如果是本身,通过控制 lock 来让自己能进入临界区
else
waiting[j] = false; // 如果不是,通过控制 waiting 来让进程 j 进入临界区
}
上述算法的巧妙之处在于,如果存在其它等待进程,是通过 waiting 来控制并发的;如果没了,是通过 lock 来控制的。互斥、进步、有限等待的分析此处略。
上述方案过于麻烦和底层,操作系统一般提供一些封装好的工具,如互斥锁、信号量。信号量底层同样依赖原子指令,以保证 PV 操作的原子性。
2. 一些经典同步问题
2.1 有界缓冲问题
也即生产者-消费者问题。Java 版本的可以直接基于 LinkedBlockingQueue 来做,内部基于 AQS;这里给出信号量的版本:
int n;
semephore mutex = 1;
semaphore empty = n;
semaphore full = 0;
// producer
do {
wait(empty);
wait(mutex);
/* 修改 buffer */
signal(mutex);
signal(full);
} while (true);
// consumer
do {
wait(full);
wait(mutex);
/* 修改 buffer */
signal(mutex);
signal(empty);
} while (true);
2.2 读者-写者问题
也即读写锁。Java 里是 ReentrantReadWriteLock,同样基于 AQS。信号量的思路其实也类似,维持一个 read_count,读写者共享一个 rw_mutex,写者的每次操作都会加锁,第一个读者会负责加锁和释放锁。go 官方库里的 RWMutex 就是基于信号量机制。
2.3 哲学家就餐问题
这个问题的难点并不是互斥,而是避免死锁和饥饿。
第七章 死锁
死锁存在四个必要条件:
- 互斥。资源是互斥使用的,不能共享
- 持有并等待。一个进程至少占有一个资源,并等待另一个资源
- 非抢占。资源不能被抢占。
-
循环等待。进程互相等待其它进程占有的资源。
预防死锁只需要打破上述四个条件任意一个即可。对 1,可以使用可共享资源来避免互斥,但通常不能做到;无锁算法并不是到处都有。对 2,可以让每个进程在执行前申请并获得所有资源。主要问题是效率较低、可能产生饥饿。对 3,也存在局限性,抢占完后需要恢复原状。对4,主要是定义一个获取共享资源的顺序,每个进程只能按顺序申请资源。或者,超时释放,引入随机数
死锁避免算法动态检查资源分配状态,确保循环等待条件不满足,如银行家算法。
另外还有死锁检测和死锁恢复算法。
网友评论