自选锁是通过硬件提供的原子指令来实现的,主要包括下面的三种:
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:
- 如果flag是从0->1,并不会进入循环,因为flag的原值为0,等式不成立,等于获取了锁,此时flag为1;
- 后面再进入临界区的线程也会尝试将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。