互斥锁 (mutex lock) 确保关键区同一时刻最多只能有一个线程进入。

API

评估指标

互斥 (mutual exclusion):最基本的要求。它能够实现关键区的互斥访问吗?
公平 (fairness):饥饿线程?
效率 (performance):锁的开销 (overhead) 几何?

禁用中断

当线程 A 进入关键区后,禁用中断以保证不会切换到其他线程。

  • 禁用中断是一个受限操作,将该权限转让给用户程序会产生诸多安全问题
  • 多核/多处理器?
  • 中断本身十分重要!

OS 代码有时会采取禁用中断实现互斥锁。

原子指令与自旋锁

依赖硬件支持的两种原子指令 (atomic instructions) 来实现互斥锁。

Test-and-Set
1
2
3
4
5
6
7
8
int TestAndSet(int *ptr, 
int new) {
int old = *ptr;
*ptr = new;
return old;
}


Compare-and-Swap
1
2
3
4
5
6
7
8
void CompareAndSwap(int *ptr, 
int expected,
int new) {
int temp = *ptr;
if (temp == expected)
*ptr = new;
return temp;
}

使用这两种原子指令实现一个简单的自旋锁 (spin lock),未能获取 (acquire) 锁的线程在 while 循环中自旋等待 (spin waiting)。

1
2
3
4
5
int flag = 0;
while (TestAndSet(&flag, 1) == 1);
// or while (CompareAndSwap(&flag, 0, 1));
critical_section();
flag = 0;

评估:

  • 能够保证互斥访问
  • 不保证公平:可能会有长期饥饿的线程
  • 单核效率极低,多核(尤其是核数大致与线程数一致)效率较好

让步

很自然的想法。与其自旋等待占用 CPU 资源,不如主动让出 (yield) 自己的时间片,结束本轮的调度。

1
2
3
4
5
int flag = 0;
while (TestAndSet(&flag, 1) == 1) // or while (CompareAndSwap(&flag, 0, 1))
yield();
critical_section();
flag = 0;
  • 单核线程较多时,开销还是很大
  • 仍未保证公平

队列,睡眠与唤醒

Solaris 添加了三个新的系统调用:

  • park():使当前线程睡眠,不再参与调度(而不仅仅只是让出本轮的时间片)
  • unpark(threadID):唤醒指定的线程
  • setpark():标记当前线程即将进入睡眠,使得若在真正执行 park() 之前收到了 unpark()park() 会立即返回而不会阻塞。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
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());
setpark();
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;
}
  • 引入的队列保证了公平,线程按照 FIFO 顺序被唤醒
  • guard 自旋锁控制队列的入口,但其保护的代码是简单的队列逻辑 (L. 17-25 与 L. 32-37) 而非用户指定的关键区,所以自旋开销很小
  • 非自旋锁 flag 控制关键区的入口

为什么需要 setpark()

  • 如果在释放 guard 锁与 park() 间恰好发生了中断,其他线程可能会在当前线程沉睡之前就尝试 unpark() 唤醒它,从而导致当前进程永久睡眠。