程序员在源代码中加锁,放在临界区周围,保证临界区能够像单条原子指令一样执行。
28.1锁的基本思想
lock_t mutex; // some globally-allocated lock 'mutex'
lock(&mutex);
balance = balance + 1;
unlock(&mutex);
锁就是一个变量,因此我们需要声明一个某种类型的锁变量(lock variable,如上面的mutex),才能使用。这个锁变量(简称锁)保存了锁在某一时刻的状态。它要么是可用的(available,或 unlocked,或 free),表示没有线程持有锁,要么是被占用的(acquired,或 locked,或 held),表示有一个线程持有锁,正处于临界区。
28.2 Pthread 锁
POSIX 库将锁称为互斥量(mutex),因为它被用来提供线程之间的互斥。即当一个线程在临界区,它能够阻止其他线程进入直到本线程离开临界区
28.4 评价锁
- 第一是锁是否能完成它的基本任务,即提供互斥(mutual exclusion)。最基本的,锁是否有效,能够阻止多个线程进入临界区?
- 第二是公平性(fairness)。当锁可用时,是否每一个竞争线程有公平的机会抢到锁?用另一个方式来看这个问题是检查更极端的情况:是否有竞争锁的线程会饿死(starve),一直无法获得锁?
- 最后是性能(performance),具体来说,是使用锁之后增加的时间开销。
28.5 控制中断
最早提供的互斥解决方案之一,就是在临界区关闭中断。这个解决方案是为单处理器系统开发的。
void lock() {
DisableInterrupts();
}
void unlock() {
EnableInterrupts();
}
假设我们运行在这样一个单处理器系统上。通过在进入临界区之前关闭中断(使用特殊的硬件指令),可以保证临界区的代码不会被中断,从而原子地执行。结束之后,我们重新打开中断(同样通过硬件指令),程序正常运行。
遗憾的是,缺点很多。
- L、首先,这种方法要求我们允许所有调用线程执行特权操作(打开关闭中断),即信任这种机制不会被滥用。
- 第二,这种方案不支持多处理器。
- 第三,关闭中断导致中断丢失,可能会导致严重的系统问题
- 最后一个不太重要的原因就是效率低。与正常指令执行相比,现代 CPU 对于关闭和打开中断的代码执行得较慢。
28.6 测试并设置指令(原子交换)
因为关闭中断的方法无法工作在多处理器上,所以系统设计者开始让硬件支持锁。最简单的硬件支持是测试并设置指令(test-and-set instruction),也叫作原子交换(atomicexchange)。
28.7 实现可用的自旋锁
通常称为测试并设置指令(test-and-set) 。我们用如下的 C 代码片段来定义测试并设置指令做了什么:
int TestAndSet(int *old_ptr, int new) {
int old = *old_ptr; // fetch old value at old_ptr
*old_ptr = new;
// store 'new' into old_ptr
return old;
// return the old value
}
测试并设置指令做了下述事情。它返回 old_ptr 指向的旧值,同时更新为 new 的新值。当然,关键是这些代码是原子地(atomically)执行。因为既可以测试旧值,又可以设置新值,所以我们把这条指令叫作“测试并设置”。
我们来确保理解为什么这个锁能工作。首先假设一个线程在运行,调用 lock(),没有其他线程持有锁,所以 flag 是 0。当调用 TestAndSet(flag, 1)方法,返回 0,线程会跳出 while循环,获取锁。同时也会原子的设置 flag 为 1,标志锁已经被持有。当线程离开临界区,调用 unlock()将 flag 清理为 0。
typedef struct
int flag;
} lock_t;
lock_t {
void init(lock_t *lock) {
// 0 indicates that lock is available, 1 that it is held
lock->flag = 0;
}
void lock(lock_t *lock) {
while (TestAndSet(&lock->flag, 1) == 1)
; // spin-wait (do nothing)
}
void unlock(lock_t *lock) {
lock->flag = 0;
}
第二种场景是,当某一个线程已经持有锁(即 flag 为 1)。本线程调用 lock(),然后调用TestAndSet(flag, 1),这一次返回 1。只要另一个线程一直持有锁, TestAndSet()会重复返回 1,本线程会一直自旋。当 flag 终于被改为 0,本线程会调用 TestAndSet(),返回 0 并且原子地设置为 1,从而获得锁,进入临界区。
28.9 比较并交换
int CompareAndSwap(int *ptr, int expected, int new) {
int actual = *ptr;
if (actual == expected)
*ptr = new;
return actual;
}
比较并交换的基本思路是检测 ptr 指向的值是否和 expected 相等;如果是,更新 ptr 所指的值为新值。否则,什么也不做。
28.11 获取并增加
最后一个硬件原语是获取并增加(fetch-and-add)指令,它能原子地返回特定地址的旧值,并且让该值自增一。
int FetchAndAdd(int *ptr) {
int old = *ptr;
*ptr = old + 1;
return old;
}
typedef struct
lock_t {
int ticket;
int turn;
} lock_t;
void lock_init(lock_t *lock) {
lock->ticket = 0;
lock->turn
= 0;
}
void lock(lock_t *lock) {
int myturn = FetchAndAdd(&lock->ticket);
while (lock->turn != myturn)
; // spin
}
void unlock(lock_t *lock) {
FetchAndAdd(&lock->turn);
}
,这个解决方案使用了 ticket 和 turn 变量来构建锁。基本操作也很简单:
如果线程希望获取锁,首先对一个 ticket 值执行一个原子的获取并相加指令。这个值作为该线程的“turn” (顺位,即 myturn)。根据全局共享的 lock->turn 变量,当某一个线程的(myturn== turn)时,则轮到这个线程进入临界区。unlock 则是增加 turn,从而下一个等待线程可以进入临界区。
28.14 使用队列:休眠替代自旋
我们利用 Solaris 提供的支持,它提供了两个调用:park()能够让调用线程休眠,unpark(threadID)则会唤醒 threadID 标识的线程。可以用这两个调用来实现锁,让调用者在获取不到锁时睡眠,在锁可用时被唤醒。
typedef struct
lock_t {
int flag;
int guard;
queue_t *q;
} lock_t;
void lock_init(lock_t *m) {
m->flag = 0;
m->guard = 0;
queue_init(m->q);
}
void lock(lock_t *m) {
while (TestAndSet(&m->guard, 1) == 1)
; //acquire guard lock by spinning
if (m->flag == 0) {
m->flag = 1; // lock is acquired
m->guard = 0;
} else {
queue_add(m->q, gettid());
m->guard = 0;
park();
}
}
void unlock(lock_t *m) {
while (TestAndSet(&m->guard, 1) == 1)
; //acquire guard lock by spinning
if (queue_empty(m->q))
m->flag = 0; // let go of lock; no one wants it
else
unpark(queue_remove(m->q)); // hold lock (for next thread!)
m->guard = 0;
}