温故知新 —— 自旋锁

296 阅读3分钟

自选锁是通过硬件提供的原子指令来实现的,主要包括下面的三种:

1. 测试并设置 test-and-set

硬件实现了类似下面的函数,并保证是原子的。

int TestAndSet(int *old_ptr, int new_value)
{
    int old_value = *old_ptr;
    *old_ptr = new_value;
    return old_value;
}

函数将old_ptr所指向的值设置为new_value,并返回old_value。可以基于这条指令实现一个自旋锁

typedef struct
{
    int flag;
} lock_t;

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

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

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

自旋锁的lock函数里始终在尝试将lock的flag设置为1:

  1. 如果flag是从0->1,并不会进入循环,因为flag的原值为0,等式不成立,等于获取了锁,此时flag为1;
  2. 后面再进入临界区的线程也会尝试将lock设置为1,但是由于flag值当前为1,所以进入自旋,直到获取锁的线程调用unlock将flag设置为0。

简单的说就是放过lock->flag从0->1的线程,自旋1->1的线程

2. 比较并交换 compare-and-swap

这是另外一种硬件原语,类似于下面的函数实现

int CompareAndSwap(int *ptr, int expected, int value)
{
    int actual = *ptr;
    if (actual == expected)
    {
        *ptr = value;
    }
    return actual;
}

也就是测试ptr指向的值和expected是否相等,相等的话就更新ptr指向的值为value,函数的返回值一定是原值。

用CompareAndSwap实现锁的伪代码如下:

void lock(lock_t *lock)
{
    while(CompareAndSwap(lock->flag, 0, 1) == 1)
        ; // 自旋
}

和TestAndSet一定会每次把lock->flag设置为1不同,CompareAndSwap只会在flag为0时触发赋值操作,并返回0不进入自旋,如果flag为1那么什么都不做直接返回1进入自旋。

CompareAndSwap(缩写CAS)原语的使用还是很广泛的,java的concurrent包很多都是用CAS实现的,不过可能形式稍微有点不同,应该是类似下面这样

bool CompareAndSwap(int *ptr, int expected, int value)
{
    bool result = (*ptr == expected);
    if (result)
    {
        *ptr = value;
    }
    return result;
}

只有设置成功的情况下才会返回true,否则一直是false,可以再参考下java并发包的源代码,比如AtomicInteger的#incrementAndGet()

    public final int incrementAndGet() {
        return U.getAndAddInt(this, VALUE, 1) + 1;
    }

调用了unsafe的#getAndAddInt()方法,这个实现是下面这样的:

    @IntrinsicCandidate
    public final int getAndAddInt(Object o, long offset, int delta) {
        int v;
        do {
            v = getIntVolatile(o, offset);
        } while (!weakCompareAndSetInt(o, offset, v, v + delta));
        return v;
    }

也就是如果weakCompareAndSetInt由于并发导致设置失败,就会一直自旋直到成功为止。

3. 获取并增加 fetch-and-add

其实上面两种原语对锁的公平性没有什么保证,有些线程很可能会饿死都拿不到锁,下面这种实现会保证所有在临界区外等待的线程都有机会

int FetchAndAdd(int *ptr)
{
    int old = *ptr;
    *ptr = old + 1;
    return old;
}

也就是对ptr指向的值+1并返回原值。用它实现的锁如下所示:

typedef struct
{
    int ticket;
    int turn;
} lock_t;

void init(lock_t *lock)
{
    lock->ticket = 0;
    lock->turn = 0;
}

void lock(lock_t *lock)
{
    int cur_turn = FetchAndAdd(lock->ticket);
    while(lock->turn != cur_turn)
        ; // 自旋
}

void unlock(lock_t *lock)
{
    FetchAndAdd(lock->turn);
}

所有到达临界区的线程都会获得一张ticket,所有线程的ticket都不相同,但是只有第一到达的线程FetchAndAdd的返回值和它的turn相等,其他线程的cur_turn都比lock->turn大,第二个来的大1,第三个来的大2,以此类推。

当第一个线程退出临界区时,会将lock->turn也+1,这样第二个来的线程就会退出自旋,进入临界区,然后再以此类推,这样就实现了一个类似"队列"的效果,所有线程都有拿到锁的机会。

锁的自旋和休眠

前面提到的锁的实现都是用的自旋的方式,临界区如果不长,自旋的方式效率很高的,毕竟没有上下文切换的花销,但是如果临界区较长,那自旋就是在白白浪费CPU,特别是多个线程如果都是等待,那就是大家一起浪费CPU。