自旋锁的原理与优化

作者: wangxiyuan

本文介绍自旋锁的概念、实现以及优化

什么是自旋锁

自旋锁(spin lock)与互斥锁(mutex)类似,任时刻只有一个线程能够获得锁。当一个线程在获取锁的时候,如果锁已经被其它线程获取,那么该线程将循环等待,然后不断的判断锁是否能够被成功获取,直到获取到锁才会退出循环。

在获取锁的过程中,线程一直处于活跃状态。因此与mutex不同,spinlock不会导致线程的状态切换(用户态->内核态),一直处于用户态,即线程一直都是active的;不会使线程进入阻塞状态,减少了不必要的上下文切换,执行速度快。

由于自旋时不释放CPU,如果持有自旋锁的线程一直不释放自旋锁,那么等待该自旋锁的线程会一直浪费CPU时间。因此,自旋锁主要适用于被持有时间短,线程不希望在重新调度上花过多时间的情况。

实现

根据自旋锁的原理,我们先尝试写出伪代码实现:

1
2
3
4
5
6
7
8
9
10
11
12
//循环检查锁状态,并尝试获取,直到成功
while (true):
locked = get_lock()
if locked == false:
locked = true
break

//上锁后执行相关任务
do_something

//执行完毕,释放锁
locked = false

细心的同学可以发现上面的逻辑在并发场景会遇到问题:两个线程可能会同时进入if语句,同时获取锁,导致锁不生效。

如何解决这个问题?我们可以把查询锁(get_lock)和设置锁(locked=true)组合成一个原子操作,保证同一时间只有一个线程在执行。如下所示:

1
2
3
4
5
6
7
//这里get_and_set(locked)就是一个原子操作,执行成功后把locked设置成true。
while (get_and_set(locked) == false):
continue

do_something

locked = false

如此,就实现了一个简单的自旋锁。

那么现在的问题是如何实现get_and_set(locked)这个原子操作?这里有两个常用的方法可以使用:TAS(test and set)CAS (compare and swap)

  1. TAS:一个TAS指令包括两个子步骤,把给定的内存地址设置为1,然后返回之前的旧值。

  2. CAS:CAS指令需要三个参数,一个内存位置(V)、一个期望旧值(A)、一个新值(B)。过程如下:

    a. 比较内存V的值是否与A相等?
    b. 如果相等,则用新值B替换内存位置V的旧值
    c. 如果不相等,不做任何操作。
    d. 无论哪个情况,CAS都会把内存V原来的值返回。

很多语言都提供了封装后的TAS和CAS调用方法。

  1. 以C++ 11为例,atomic标准库提供了相关方法:std::atomic_flag::test_and_setstd::atomic::compare_exchange_strong
  2. GCC编译器也内置了相关方法:__atomic_test_and_set__atomic_compare_exchange_n.
  3. Java也提供了例如java.util.concurrent.atomic.AtomicReference.compareAndSet等方法。

使用这些方法替换伪代码中的get_and_set(locked),就能快速实现自旋锁。

参考实现

下面我们看看一些顶级开源项目中是如何实现自旋锁的。注意,以下每个软件的代码可能在很多地方都实现/使用了自旋锁,这里只选取了其中某一些加以分析。

MariaDB 10.4

代码路径:/storage/innobase/include/ib0mutex.h

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
struct TTASMutex {

...

/** Try and lock the mutex.
@return true on success */
bool try_lock() UNIV_NOTHROW
{
uint32_t oldval = MUTEX_STATE_UNLOCKED;
return m_lock_word.compare_exchange_strong(
oldval,
MUTEX_STATE_LOCKED,
std::memory_order_acquire,
std::memory_order_relaxed);
}

/** Acquire the mutex.
@param max_spins max number of spins
@param max_delay max delay per spin */
void enter(uint32_t max_spins, uint32_t max_delay,
const char*, uint32_t) UNIV_NOTHROW
{
const uint32_t step = max_spins;
uint32_t n_spins = 0;

while (!try_lock()) {
ut_delay(max_delay);
if (++n_spins == max_spins) {
os_thread_yield();
max_spins+= step;
}
}

m_policy.add(n_spins, 0);
}

...
}

如上所示,在TTASMutex结构里,有个enter方法,里面实现了上锁+自旋的功能。其中上锁动作调用了try_lock方法,里面使用了CAS原子操作。

在我们之前写的简单伪代码中,while循环内什么都没做(直接continue),即每次自旋之间无停顿、无其他操作。而mariaDB这里却做了一些动作,在每次while循环中:

  1. 执行ut_delay。即每次上锁失败后,会等待一段时间,然后再去尝试上锁。
  2. 判断自旋次数,当自旋次数达到某个阈值,不再自旋,直接线程挂起。

这样做可以防止某些自旋锁无限空转、浪费CPU资源的情况。

PostgreSQL

代码路径:src/include/storage/s_lock.h

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/*
* s_lock(lock) - platform-independent portion of waiting for a spinlock.
*/
int
s_lock(volatile slock_t *lock, const char *file, int line, const char *func)
{
SpinDelayStatus delayStatus;

init_spin_delay(&delayStatus, file, line, func);

while (TAS_SPIN(lock))
{
perform_spin_delay(&delayStatus);
}

finish_spin_delay(&delayStatus);

return delayStatus.delays;
}

我们可以看到,与MariaDB类似,while的判断中不断获取锁,while语句中加入delay。TAS_SPIN的实现如下:

1
2
3
4
5
static __inline__ int
tas(volatile slock_t *lock)
{
return __sync_lock_test_and_set(lock, 1);
}

__sync_lock_test_and_set是gcc内置的老版本的TAS原子操作,推荐使用__atomic_test_and_set

Linux kernel

Kernel的spin lock很复杂,有多种实现,以arm64为例,在4.16版本之前,使用的是汇编实现。4.16之后使用了通用的qspinlock,qspinlock较复杂,请参考这里这里。以后另开文章分析。

优化

原子操作优化

首先我们先看看TAS和CAS在汇编层面是什么样的?这里需要特别说明一下硬件架构和编译工具的选择,在不同硬件架构上使用不同版本的编译器,得到的汇编指令是不同的。本文以ARM64为例,使用gcc 8.2编译器, 编译参入为-O2 -std=c++11,分别执行__atomic_test_and_set__atomic_compare_exchange_n

__atomic_test_and_set

1
2
3
4
5
6
7
#include <atomic>

int main()
{
int val = 456;
__atomic_test_and_set(&val, 1);
}

汇编结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
main:
sub sp, sp, #16
mov w0, 456
add x2, sp, 12
str w0, [sp, 12]
mov w0, 1
.L2:
ldaxrb w1, [x2]
stxrb w3, w0, [x2]
cbnz w3, .L2
mov w0, 0
add sp, sp, 16
ret

__atomic_compare_exchange_n

1
2
3
4
5
6
7
8
#include <atomic>

int main()
{
int expected = 123;
int val = 456;
__atomic_compare_exchange_n(&val, &expected, 1 , false, __ATOMIC_ACQUIRE, __ATOMIC_ACQUIRE);
}

汇编结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
main:
sub sp, sp, #16
mov w0, 456
add x2, sp, 12
str w0, [sp, 12]
mov w0, 1
.L2:
ldaxr w1, [x2]
cmp w1, 123
bne .L3
stxr w3, w0, [x2]
cbnz w3, .L2
.L3:
mov w0, 0
add sp, sp, 16
ret

分析可以发现,TAS是直接写操作,CAS是先比较,满足条件后再写。而是一个相对耗时的操作,因此在高并发、频繁使用锁的场景,CAS性能会更好。

因此在Postgre中,使用CAS代替TAS就是一个优化点了。详见社区讨论

锁等待优化

在MariaDB和Postgre的源码中,我们可以看到在不断获取锁的过程中,都有delay的操作。适当的delay操作可以避免很多无意义的锁获取动作。那么当执行delay操作时,数据库到底在干什么?

我们先看MariaDB的ut_delay方法。

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
39
40
41
42
43
44
45
/**
Run a delay loop while waiting for a shared resource to be released.
@param delay originally, roughly microseconds on 100 MHz Intel Pentium
*/
static inline void ut_delay(unsigned delay)
{
unsigned i= my_cpu_relax_multiplier / 4 * delay;
HMT_low();
while (i--)
MY_RELAX_CPU();
HMT_medium();
}

static inline void MY_RELAX_CPU(void)
{
#ifdef _WIN32
/*
In the Win32 API, the x86 PAUSE instruction is executed by calling
the YieldProcessor macro defined in WinNT.h. It is a CPU architecture-
independent way by using YieldProcessor.
*/
YieldProcessor();
#elif defined HAVE_PAUSE_INSTRUCTION
/*
According to the gcc info page, asm volatile means that the
instruction has important side-effects and must not be removed.
Also asm volatile may trigger a memory barrier (spilling all registers
to memory).
*/
#ifdef __SUNPRO_CC
asm ("pause" );
#else
__asm__ __volatile__ ("pause");
#endif
#elif defined(_ARCH_PWR8)
__ppc_get_timebase();
#elif defined __GNUC__ && (defined __arm__ || defined __aarch64__)
/* Mainly, prevent the compiler from optimizing away delay loops */
__asm__ __volatile__ ("":::"memory");
#else
int32 var, oldval = 0;
my_atomic_cas32_strong_explicit(&var, &oldval, 1, MY_MEMORY_ORDER_RELAXED,
MY_MEMORY_ORDER_RELAXED);
#endif
}

我们可以看到ut_delay中调用了MY_RELAX_CPU方法, 而MY_RELAX_CPU方法中,根据不同架构和平台,执行了不同的命令。在X86架构中,调用了底层汇编指令PAUSE。在ARM64平台执行内存屏障(__asm__ __volatile__ ("":::"memory");),防止编译优化,保持CPU空转(即不执行任何操作)。注意,ARM64的这个内存屏障代码是在这个优化提交中新增的。

在这个提交没有合入前,在ARM64平台上,默认执行的是my_atomic_cas32_strong_explicit方法,这是一个模拟无效的CAS的方法。为什么会有这样的修改?

最早的时候,社区开发者在48核的Qualcomm Centriq 2400 ARM服务上测试后,发现模拟CAS操作能提高ARM的性能。但随着代码不断迭代以及ARM服务器的不断发展。在MariaDB 10.4分支,开发者发现,在ARM上保持CPU空转的性能更高,有大概12%的性能提升。

所以我们很难在原理上讲清为什么CPU空转比模拟CAS性能高,但事实就是如此。通过这个优化,我也学到了两点,分享给大家:

  1. 性能优化有时不是看代码就能发现的,更多的还是需要实际测试。
  2. 性能是实时变化的,以前的优化可能放到后来就是负担。性能优化需要持续不断的展开。

Comments

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×