自旋锁(Spinlock)详解
什么是自旋锁?
自旋锁是一种轻量级的同步机制。当线程尝试获取锁但锁已被占用时,线程不会进入睡眠状态,而是在原地"自旋"(忙等待),不断检查锁是否可用。
与互斥锁(mutex)的区别在于:互斥锁在获取失败时会让线程睡眠,涉及操作系统调度和上下文切换;自旋锁则保持线程运行,持续尝试获取锁。
适用场景
自旋锁适合以下情况:
临界区非常短,预期锁很快就会被释放。上下文切换的开销比自旋等待更大。在高频交易(HFT)等对延迟极度敏感的系统中尤其常见。
不适合的情况包括:临界区较长、锁竞争激烈、或者线程数远超 CPU 核心数。
基础实现
最简单的自旋锁可以用std::atomic_flag实现:
#include<atomic>classSpinlock{std::atomic_flag flag=ATOMIC_FLAG_INIT;public:voidlock(){while(flag.test_and_set(std::memory_order_acquire));}voidunlock(){flag.clear(std::memory_order_release);}};test_and_set是一个原子操作,它返回 flag 当前的值,并将 flag 设置为 true。如果返回 false,说明之前没有人持有锁,我们成功获取;如果返回 true,说明锁已被占用,继续自旋。
内存序的选择
为什么用memory_order_acquire和memory_order_release?
acquire语义确保:在获取锁之后的所有读写操作,不会被重排到获取锁之前。这样我们能看到前一个持有者在临界区内的所有写入。
release语义确保:在释放锁之前的所有读写操作,不会被重排到释放锁之后。这样下一个获取锁的线程能看到我们在临界区内的所有写入。
这两者配合,形成了一个同步点,保证临界区的可见性。
性能问题:缓存行颠簸
上面的基础实现有一个问题。在高竞争情况下:
while(flag.test_and_set(std::memory_order_acquire));test_and_set每次都会写入 flag,即使锁已经被占用。多个核心同时写同一个缓存行会导致缓存行在核心之间不断传递,这叫做"缓存行颠簸"或"乒乓效应",严重影响性能。
优化:Test-and-Test-and-Set (TTAS)
改进方案是先只读检查,只有当锁看起来可用时才尝试获取:
voidlock(){while(true){// 第一步:只读等待while(flag.test(std::memory_order_relaxed));// 第二步:尝试获取if(!flag.test_and_set(std::memory_order_acquire))return;}}test是只读操作,不会使其他核心的缓存行失效。多个线程可以同时在本地缓存上自旋,不产生总线流量。只有当锁释放时,缓存行才会更新,线程才会尝试test_and_set。
进一步优化:PAUSE 指令
在自旋循环中加入pause指令可以进一步优化:
#include<immintrin.h>voidlock(){while(true){while(flag.test(std::memory_order_relaxed)){_mm_pause();}if(!flag.test_and_set(std::memory_order_acquire))return;}}_mm_pause是 x86 的 PAUSE 指令,它告诉 CPU “我在自旋等待”。好处包括:降低功耗、减少流水线刷新、在超线程环境下让出资源给兄弟线程。延迟大约 10-40 个时钟周期,具体取决于 CPU 型号。
如果用 GCC 或 Clang,也可以用__builtin_ia32_pause()代替,不需要额外头文件。
跨平台考虑
PAUSE 指令是 x86 特有的。ARM 架构有类似的 YIELD 指令:
#ifdefined(__x86_64__)||defined(_M_X64)_mm_pause();#elifdefined(__aarch64__)asmvolatile("yield");#endif混合策略
如果锁可能被持有较长时间,可以采用混合策略。先自旋一段时间,如果还没获取到就让出 CPU:
voidlock(){// 先自旋for(inti=0;i<1000;i++){if(!flag.test(std::memory_order_relaxed)){if(!flag.test_and_set(std::memory_order_acquire))return;}_mm_pause();}// 自旋太久,退让while(flag.test_and_set(std::memory_order_acquire)){std::this_thread::yield();}}yield会让出当前时间片,让操作系统调度其他线程。开销比 pause 大得多(微秒级 vs 纳秒级),但避免了长时间空转。
公平性问题
上面的实现都是不公平的。多个线程竞争时,不保证先来的先获取。在极端情况下可能导致某些线程饥饿。
如果需要公平性,可以实现票据锁(Ticket Lock):
#include<atomic>#include<immintrin.h>classTicketLock{std::atomic<size_t>next_ticket{0};std::atomic<size_t>now_serving{0};public:voidlock(){size_t my_ticket=next_ticket.fetch_add(1,std::memory_order_relaxed);while(now_serving.load(std::memory_order_acquire)!=my_ticket){_mm_pause();}}voidunlock(){now_serving.fetch_add(1,std::memory_order_release);}};每个线程取一个号码,按号码顺序获取锁。严格先来先服务。
完整代码
最终优化版本:
#include<atomic>#include<immintrin.h>classSpinlock{std::atomic_flag flag=ATOMIC_FLAG_INIT;public:voidlock(){while(true){// 只读自旋,避免缓存行颠簸while(flag.test(std::memory_order_relaxed)){_mm_pause();}// 尝试获取if(!flag.test_and_set(std::memory_order_acquire))return;}}voidunlock(){flag.clear(std::memory_order_release);}};总结
实现自旋锁需要注意以下几点。使用正确的内存序保证可见性。用 TTAS 模式避免缓存行颠簸。在自旋循环中加入 PAUSE 指令。根据场景选择是否需要公平性。明确自旋锁的适用场景,不要滥用。
在高频交易等延迟敏感场景,自旋锁比互斥锁更合适,因为它避免了上下文切换的开销。但在锁竞争激烈或临界区较长的情况下,互斥锁可能是更好的选择。