聊聊那些锁事

Catalogue
  1. 1. 前言
  2. 2. 锁的基本思想
  3. 3. 如何实现一个锁
    1. 3.1. 测试并设置指令(test-and-set instruction)
    2. 3.2. 比较并交换 (compare-and-swap)
    3. 3.3. 获取并增加 (fetch-and-add)
    4. 3.4. 如何避免过多的自旋
      1. 3.4.1. 使用队列:休眠替代自旋
      2. 3.4.2. Java中的自旋锁
  4. 4. 参考资料

前言

最近看了一本书,名字叫做《Operating Systems: Three Easy Pieces》,它的中文版是《操作系统导论》,原书在豆瓣评分9.7分,质量还不错。该书围绕虚拟化、并发和持久性这三个主要概念展开,行文诙谐幽默却又鞭辟入里,不同于寻常的操作系统书籍。这些天看了并发的几个章节,我主要关注了”锁”的部分,细读下来,有了更深刻的认识。

所以这篇文章就是对《操作系统导论》中讲解锁的章节的一个读书笔记,实在是忍不住想分享出来。

锁的基本思想

锁其实是一个变量,我们需要声明某种类型的锁变量才能使用,比如下例:

1
2
3
4
5
lock_t mutex; //声明
...
lock(&mutex); //加锁
balance = balance + 1;
unlock(&mutex); //解锁

锁变量保存了锁在某一时刻的状态,它要么是可用的(avaliable,或unlocked,或free),表示没有线程持有锁,要么是被占用的(acquired,或locked,或held),表示有一个线程持有锁,正处于临界区。

锁为程序员提供了最小程度的调度控制,线程可以视为程序员创建的实体,但是被操作系统调度,具体方式由操作系统选择,而锁让程序员获得一些控制权,通过给临界区加锁,可以保证临界区内只有一个线程活跃。

此外,POSIX库将锁称为互斥量(mutex),因为它被用来提供线程之间的互斥,即当一个线程在临界区,它能够阻止其他线程进入直到本线程离开临界区。

如何实现一个锁

我们已经从程序员的角度,对锁如何工作有一定的理解。那如何实现一个锁呢?我们需要什么硬件支持?需要什么操作系统的支持?下面会进行解答。

而在实现锁之前,我们还需要明确目标,需要设立一些标准才能让“锁”工作的好,主要有3个标准:

  • 提供互斥:最基本的,锁应该能够阻止多个线程进入临界区
  • 公平性:当锁可用时,是否每一个竞争线程有公平的机会抢到锁?
  • 性能: 需要考虑使用锁之后增加的时间开销。比如以下几种场景:
    • 没有竞争的情况下,即只有一个线程抢锁、释放锁的开支如何?
    • 一个CPU上多个线程竞争,性能如何?
    • 多个CPU、多个线程竞争时的性能?

这三个标准映射到Java层面来说:

  • 互斥锁: JDK中的synchronized和JUC中的Lock就是互斥锁,保证一次最多只能由一个线程持有该锁
  • 公平锁与非公平锁: Java中的公平锁是指多个线程在等待同一个锁时,必须按照申请锁的先后顺序来获取锁,而非公平锁是可以抢占的,公平锁可以使用new ReentrantLock(true)实现
  • 锁的性能: JDK中的synchronized的锁升级——偏向锁、轻量级锁和重量级锁,这几个锁的实现与转换,就是为了提升synchronized锁的性能

测试并设置指令(test-and-set instruction)

测试并设置指令 (test-and-set instruction) ,在x86系统上,具体是指xchg (atomic exchange,原子交换) 指令,我们用如下的C代码片段来定义测试并设置指令做了什么:

1
2
3
4
5
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 的新值。同时需要注意的是,上述的伪代码是为了说明使用,直接传统编译肯定不能保证原子性,需要操作系统的硬件指令 (x86的xchg指令) 支持以保证原子性。

因为既可以测试旧值,又可以设置新值,所以把这条指令叫作 “测试并设置”。依靠这一条指令完全可以实现一个简单的自旋锁 (spin lock),代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
typedef struct lock_t {
int flag;
} lock_t;

void init(lock_t *lock) {
// 0 表示锁可用,1表示锁已经被抢占
lock->flag = 0;
}

void lock(lock_t *lock) {
while (TestAndSet(&lock->flag,1) == 1)
{
; // 自旋等待 (do something)
}
}

void unlock(lock_t *lock) {
lock->flag = 0;
}

首先假设一个线程在运行,调用lock(),没有其他线程持有锁,所以flag = 0,当调用 TestAndSet(flag,1) 方法,返回0,线程会跳出 while 循环,获取锁。同时也还会原子的设置flag为1,标志锁已经被持有。当线程离开临界区,调用 unlock() 将 flag 清理为0。

依靠操作系统的硬件原语,将测试 (test 旧的锁值) 和设置 (set 新的值) 合并为一个原子操作之后,我们保证了只有一个线程能获取锁,这就实现了一个有效的互斥原语!

上面的代码也就是自旋锁 (spin lock) 的实现,这是最简单的一种锁,一直自旋,利用CPU周期,直到锁可用。现在按照之前的标准来评价基本的自旋锁:

  • 能够互斥:自旋锁一次只允许一个线程进入临界区,能够保证锁的正确性
  • 公平性: 自旋锁不提供任务公平性保证,实际上,自旋的线程在竞争条件下可能会永远自旋。自旋锁没有公平性,可能会导致饿死。
  • 性能问题: 对于自旋锁,在单CPU的情况下,性能开销非常大,多个线程在竞争锁,放弃CPU之前,都会自旋一个时间片,浪费CPU周期。而在多CPU上,自旋锁性能不错(如果线程数大致等于CPU数),假设线程A在CPU1,线程B在CPU2竞争同一个锁。线程A(CPU1)占有锁时,线程B竞争锁就会自旋 (在CPU2上)。然而,临界区一般都很短,因此很快锁就可用,然后线程B获得锁

比较并交换 (compare-and-swap)

某些系统提供了另一个硬件原语,即比较并交换指令 (SPARC系统是compare-and-swap,x86系统是compare-and-exchange),下面是这条指令的C语言伪代码。

1
2
3
4
5
6
7
8
int CompareAndSwap(int *ptr,int expected,int new) {
int actual = *ptr;
if (actual == expected)
{
*ptr = new;
}
return actual;
}

比较并交换的基本思路是检测 ptr 指向的值是否和 expected 相等;如果是,更新 ptr 所指的值为新值。否则,什么也不做。不论哪种情况,都返回该内存地址的值

有了比较并交换指令,就可以实现一个锁,类似于用测试并设置那样。例如,我们只需要用下面的代码替换lock()函数:

1
2
3
4
void lock(lock_t *lock) {
while (CompareAndSwap(&lock->flag,0,1) == 1)
; //spin
}

比较并交换指令实际上就是CAS指令,在Java开发工作中,我们也会常常遇到,比如使用原子类AtomicXXX,内部实现就是使用了CAS操作。

获取并增加 (fetch-and-add)

还有一个硬件原语是: 获取并增加 (fetch-and-add) 指令,它能原子地返回特定地址的旧值,并且让该值自增1,在x86系统中,是xadd指令。获取并增加的C语言伪代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
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锁,其实就是一个公平锁。

该实现不是利用一个值,而是使用了ticket 和 turn 两个变量来构建。基本思想是:如果线程希望获得锁,首先对一个ticket值执行一个原子的获取并增加指令。这个值作为该线程的”turn”(即myturn,为该线程设定获取锁的顺序)。根据全局共享的lock->turn 变量,当某一个线程的 myturn == turn 时,则轮到这个线程进入临界区。unlock 则是增加 turn,从而下一个等待线程可以进入临界区。

本方法能够保证所有线程都能获到锁,而且是按线程来的先后顺序,也就是一种先进先出 (FIFO) 的公平性机制,该锁还有一种说法,叫做 排号自旋锁 (Ticket Lock)

如何避免过多的自旋

前面几种指令都可以实现自旋锁,其实现也是非常简单,但是自旋过多,怎么办呢?比如以两个线程运行在单处理器为例,当一个线程(线程0)持有锁时,被中断。第二个线程(线程1)去获取锁,发现锁已经被持有。因此,它就开始自旋,接着自旋。如果线程0长时间持有锁,那么线程2会一直自旋,浪费CPU时间,所以如何让锁减少不必要地自旋?

只有硬件支持是不够的,我们还需要操作系统支持!

使用队列:休眠替代自旋

我们可以利用 Solaris 提供的支持,它提供了两个调用:

  • park() 能够让调用线程休眠
  • unpark(threadID) 则会唤醒threadID 标识的线程

可以用这两个调用来实现锁,让调用者在获取不到锁时睡眠,在锁可用时被唤醒。下面是C语言实现的伪代码。

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
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;
queueu_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;on one wants it;
} else {
unpark(queue_remove(m->q)) ;//hold lock(for next thread!)
}
m->guard = 0;
}

我们将之前的测试并设置和等待队列结合,实现了一个更高性能的锁,guard基本上起到了自旋锁的作用。其次,我们通过队列来控制谁会获得锁,避免饿死。

Java中的自旋锁

长时间的自旋等待会消耗处理器时间。对此,Java中的自旋锁也有一定的处理措施:自旋等待的时间必须要有一定的限度,如果自旋超过了限定次数没有成功获得锁,就应当挂起线程,限定次数默认为10次,可以使用 -XX:PreBlockSpin来更改。

自旋锁在JDK1.4.2中引入,使用-XX:+UseSpinning来开启,其实现原理就是之前提到的CAS。JDK 6中变为默认开启,并且引入了自适应的自旋锁(适应性自旋锁)。

自适应意味着自旋的时间(次数)不再固定,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也是很有可能再次成功,进而它将允许自旋等待持续相对更长的时间

参考资料

Bagikan Komentar