作者: wangxiyuan
本文介绍自旋锁的概念、实现以及优化
什么是自旋锁
自旋锁(spin lock)与互斥锁(mutex)类似,任时刻只有一个线程能够获得锁。当一个线程在获取锁的时候,如果锁已经被其它线程获取,那么该线程将循环等待,然后不断的判断锁是否能够被成功获取,直到获取到锁才会退出循环。
在获取锁的过程中,线程一直处于活跃状态。因此与mutex不同,spinlock不会导致线程的状态切换(用户态->内核态),一直处于用户态,即线程一直都是active的;不会使线程进入阻塞状态,减少了不必要的上下文切换,执行速度快。
由于自旋时不释放CPU,如果持有自旋锁的线程一直不释放自旋锁,那么等待该自旋锁的线程会一直浪费CPU时间。因此,自旋锁主要适用于被持有时间短,线程不希望在重新调度上花过多时间的情况。
实现
根据自旋锁的原理,我们先尝试写出伪代码实现:
1 | //循环检查锁状态,并尝试获取,直到成功 |
细心的同学可以发现上面的逻辑在并发场景会遇到问题:两个线程可能会同时进入if
语句,同时获取锁,导致锁不生效。
如何解决这个问题?我们可以把查询锁(get_lock)和设置锁(locked=true)组合成一个原子操作,保证同一时间只有一个线程在执行。如下所示:
1 | //这里get_and_set(locked)就是一个原子操作,执行成功后把locked设置成true。 |
如此,就实现了一个简单的自旋锁。
那么现在的问题是如何实现get_and_set(locked)
这个原子操作?这里有两个常用的方法可以使用:TAS(test and set)
和CAS (compare and swap)
。
TAS
:一个TAS指令包括两个子步骤,把给定的内存地址设置为1,然后返回之前的旧值。CAS
:CAS指令需要三个参数,一个内存位置(V)、一个期望旧值(A)、一个新值(B)。过程如下:a. 比较内存V的值是否与A相等?
b. 如果相等,则用新值B替换内存位置V的旧值
c. 如果不相等,不做任何操作。
d. 无论哪个情况,CAS都会把内存V原来的值返回。
很多语言都提供了封装后的TAS和CAS调用方法。
- 以C++ 11为例,
atomic
标准库提供了相关方法:std::atomic_flag::test_and_set和std::atomic::compare_exchange_strong - GCC编译器也内置了相关方法:
__atomic_test_and_set
和__atomic_compare_exchange_n
. - Java也提供了例如
java.util.concurrent.atomic.AtomicReference.compareAndSet
等方法。
使用这些方法替换伪代码中的get_and_set(locked)
,就能快速实现自旋锁。
参考实现
下面我们看看一些顶级开源项目中是如何实现自旋锁的。注意,以下每个软件的代码可能在很多地方都实现/使用了自旋锁,这里只选取了其中某一些加以分析。
MariaDB 10.4
代码路径:/storage/innobase/include/ib0mutex.h
1 | struct TTASMutex { |
如上所示,在TTASMutex
结构里,有个enter
方法,里面实现了上锁+自旋的功能。其中上锁动作调用了try_lock
方法,里面使用了CAS原子操作。
在我们之前写的简单伪代码中,while循环内什么都没做(直接continue),即每次自旋之间无停顿、无其他操作。而mariaDB这里却做了一些动作,在每次while循环中:
- 执行
ut_delay
。即每次上锁失败后,会等待一段时间,然后再去尝试上锁。 - 判断自旋次数,当自旋次数达到某个阈值,不再自旋,直接线程挂起。
这样做可以防止某些自旋锁无限空转、浪费CPU资源的情况。
PostgreSQL
代码路径:src/include/storage/s_lock.h
1 | /* |
我们可以看到,与MariaDB类似,while的判断中不断获取锁,while语句中加入delay。TAS_SPIN
的实现如下:
1 | static __inline__ int |
__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 | #include <atomic> |
汇编结果如下:
1 | main: |
__atomic_compare_exchange_n
1 | #include <atomic> |
汇编结果如下:
1 | main: |
分析可以发现,TAS是直接写操作,CAS是先比较,满足条件后再写。而写
是一个相对耗时的操作,因此在高并发、频繁使用锁的场景,CAS性能会更好。
因此在Postgre中,使用CAS代替TAS就是一个优化点了。详见社区讨论。
锁等待优化
在MariaDB和Postgre的源码中,我们可以看到在不断获取锁的过程中,都有delay的操作。适当的delay操作可以避免很多无意义的锁获取动作。那么当执行delay操作时,数据库到底在干什么?
我们先看MariaDB的ut_delay
方法。
1 | /** |
我们可以看到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性能高,但事实就是如此。通过这个优化,我也学到了两点,分享给大家:
- 性能优化有时不是看代码就能发现的,更多的还是需要实际测试。
- 性能是实时变化的,以前的优化可能放到后来就是负担。性能优化需要持续不断的展开。