自旋锁是一种低级同步原语,它使线程在等待锁释放时持续忙等(自旋),而不是休眠,因此适用于锁持有时间极短的场景。它避免了线程上下文切换的开销,但如果锁被长时间占用,会浪费CPU周期。
有哪些自旋锁?分别是怎么实现的?
常见的自旋锁实现方式及其原理包括:
- Test-and-Set (TAS) 自旋锁: 使用原子指令将一个内存位置设为特定值(如1),并返回其旧值。如果旧值为0,表示成功获取锁;否则,表示锁已被占用,线程继续自旋重试。
- Compare-and-Swap (CAS) 自旋锁: 使用原子指令比较内存中的值是否与预期值相等,如果相等则更新为新值。线程尝试将锁标志从“未锁定”状态(如0)更新为“锁定”状态(如1)。如果更新成功,则获取锁;否则,继续自旋重试。
- Ticket Lock(排队自旋锁): 通过维护两个原子计数器:“当前服务号”和“下一个排队号”。每个尝试获取锁的线程先原子地获取一个“下一个排队号”,然后自旋等待,直到自己的排队号等于“当前服务号”。这种方式保证了公平性。
- MCS Lock(基于链表的自旋锁): 是一种可伸缩的、基于队列的自旋锁。每个等待线程都在自己的本地内存(或缓存行)上自旋,而不是在一个共享的锁变量上自旋。线程将自己添加到等待队列的末尾,并只在自己的节点上等待前驱释放锁。这极大地减少了缓存一致性协议的开销。
接下来,我们将详细探讨这些自旋锁的实现细节和各自的优缺点。
什么是自旋锁?
在多线程编程中,当多个线程需要访问共享资源时,为了避免数据竞争和不一致性,我们需要使用同步机制。自旋锁(Spinlock)便是其中一种。与互斥锁(Mutex)不同,当一个线程尝试获取已被其他线程持有的自旋锁时,它不会被操作系统挂起(即不会发生上下文切换),而是会在一个紧凑的循环中持续检查锁是否可用,这个过程被称为“自旋”或“忙等”。
自旋锁的核心特性
- 忙等(Busy-waiting): 这是自旋锁最显著的特点。线程不会放弃CPU,而是持续消耗CPU周期检查锁状态。
- 无上下文切换开销: 由于不涉及线程的挂起和唤醒,自旋锁避免了操作系统上下文切换的开销,这对于锁持有时间极短的场景是巨大的优势。
- 低延迟: 一旦锁被释放,等待的线程可以立即获取锁并继续执行,响应速度快。
自旋锁的优缺点
优点:
- 开销小: 在锁竞争不激烈且锁持有时间短的情况下,性能远超互斥锁,因为它避免了昂贵的上下文切换。
- 适用性广: 尤其适用于内核态的同步,因为内核函数通常不能长时间阻塞。
缺点:
- CPU浪费: 如果锁被长时间持有,自旋的线程会持续占用CPU资源,导致性能下降。
- 优先级反转: 如果一个高优先级的线程在等待一个低优先级线程持有的自旋锁,而低优先级线程因调度问题长时间无法释放锁,高优先级线程会一直忙等,导致优先级反转。
- 缓存一致性问题: 多个CPU核心在同一个共享变量上自旋会导致大量的缓存失效和总线流量,降低系统性能。
常见的自旋锁实现机制及其原理
1. Test-and-Set (TAS) 自旋锁
TAS (Test-and-Set) 是一种基于原子指令的自旋锁实现。几乎所有的现代处理器都提供了类似于 `test_and_set` 或 `xchg`(交换)的原子指令。
实现原理:
TAS 自旋锁通常使用一个布尔变量(或整数)作为锁状态。
- 获取锁: 线程调用原子指令,将锁变量设置为“锁定”状态(如1),并返回其操作前的旧值。如果旧值为0(表示未锁定),则表示成功获取锁。如果旧值为1(表示已锁定),则表示获取失败,线程将进入一个循环,反复执行Test-and-Set操作,直到成功。
- 释放锁: 线程简单地将锁变量设置为“未锁定”状态(如0)。
示例伪代码:
// 假设 lock_var 为一个原子整数,初始为 0 (未锁定)
int lock_var = 0;
void acquire_tas_lock() {
while (atomic_test_and_set(&lock_var) == 1) { // 原子地将lock_var设为1并返回旧值
// 自旋等待
}
}
void release_tas_lock() {
atomic_store(&lock_var, 0); // 原子地将lock_var设为0
}
优缺点:
- 优点: 实现简单,开销极小。
- 缺点:
- 高缓存竞争: 当多个线程在同一个锁变量上自旋时,每次Test-and-Set操作都会导致该缓存行的独占权在不同CPU核心之间频繁切换,产生大量的缓存失效(cache invalidation)和总线流量,严重影响性能,尤其是在多核系统中。
- 非公平性: 无法保证等待线程的获取顺序,可能导致某些线程“饥饿”。
2. Compare-and-Swap (CAS) 自旋锁
CAS (Compare-and-Swap) 是一种更通用的原子操作,它比较内存中的值是否与预期值相等,如果相等则更新为新值。CAS 是许多无锁(lock-free)数据结构和自旋锁的基础。
实现原理:
CAS 自旋锁也使用一个变量来表示锁状态。
- 获取锁: 线程尝试使用CAS指令,将锁变量从“未锁定”状态(如0)更新为“锁定”状态(如1)。CAS操作返回一个布尔值,指示更新是否成功。如果成功,则获取锁。如果失败,线程将进入一个循环,反复尝试CAS操作。
- 释放锁: 线程使用CAS指令,将锁变量从“锁定”状态(如1)更新为“未锁定”状态(如0)。(注意:对于简单的自旋锁,通常直接原子写入0即可,但如果涉及到复杂状态,CAS更安全)。
示例伪代码:
// 假设 lock_var 为一个原子整数,初始为 0 (未锁定)
int lock_var = 0;
void acquire_cas_lock() {
int expected = 0; // 期望锁是未锁定状态
int desired = 1; // 期望将锁设为锁定状态
while (atomic_compare_exchange(&lock_var, &expected, desired) == false) {
// 自旋等待
// 每次循环前需要重新设置 expected 为 0,因为 atomic_compare_exchange 可能会更新 expected
expected = 0;
}
}
void release_cas_lock() {
atomic_store(&lock_var, 0); // 原子地将lock_var设为0
}
优缺点:
- 优点:
- 比TAS更灵活,可以实现更复杂的状态转换。
- 是构建许多高级同步原语(包括无锁数据结构)的基础。
- 缺点:
- 同样存在高缓存竞争问题,当多个线程在同一个锁变量上自旋时,缓存行频繁失效。
- ABA问题: 虽然对于简单的自旋锁不构成问题,但在无锁编程中,CAS操作可能会遇到ABA问题,即一个值从A变为B再变回A,CAS会认为没有变化。
- 非公平性。
3. Ticket Lock(排队自旋锁)
为了解决TAS和CAS自旋锁的非公平性以及高竞争下的性能问题,Ticket Lock被提出。它通过引入“票号”机制,确保了锁获取的公平性。
实现原理:
Ticket Lock 维护两个原子计数器:
now_serving:表示当前正在服务的票号。next_ticket:表示下一个可分配的票号。
- 获取锁:
- 线程原子地递增
next_ticket,并获取递增前的旧值,作为自己的“我的票号”(my_ticket)。 - 线程然后自旋等待,直到
now_serving的值等于my_ticket。
- 线程原子地递增
- 释放锁:
- 持有锁的线程原子地递增
now_serving,允许下一个票号的线程获取锁。
- 持有锁的线程原子地递增
示例伪代码:
// 假设 now_serving 和 next_ticket 都是原子整数,初始为 0
atomic_int now_serving = 0;
atomic_int next_ticket = 0;
void acquire_ticket_lock() {
int my_ticket = atomic_fetch_add(&next_ticket, 1); // 原子地获取票号并递增 next_ticket
while (atomic_load(&now_serving) != my_ticket) {
// 自旋等待轮到自己
}
}
void release_ticket_lock() {
atomic_fetch_add(&now_serving, 1); // 原子地递增当前服务号
}
优缺点:
- 优点:
- 公平性: 保证了线程按照请求锁的顺序获取锁,避免了饥饿问题。
- 缺点:
- 虽然解决了公平性,但仍然存在缓存竞争问题。所有等待线程都在同一个
now_serving变量上自旋,每次now_serving改变都会导致缓存行在所有自旋的CPU核心之间无效和传输,在高并发下依然会造成总线流量瓶颈。 - 所有线程在同一个地址上忙等,可能导致伪共享(False Sharing)。
- 虽然解决了公平性,但仍然存在缓存竞争问题。所有等待线程都在同一个
4. MCS Lock(基于链表的自旋锁)
MCS Lock(McKinley, Conty, and Scherer Lock)是一种高度可伸缩的、基于队列的自旋锁,旨在解决Ticket Lock和CAS/TAS锁在高并发下的缓存竞争问题。
实现原理:
MCS Lock的关键思想是让每个等待的线程在它自己的本地内存位置上自旋,而不是在共享的锁变量上。它通过构建一个等待线程的链表来实现这一目标。
- 每个线程维护一个私有的
qnode(队列节点)结构,其中包含一个布尔标志(表示该线程是否被锁定)和一个指向下一个qnode的指针。 - 锁本身只持有一个指向链表尾部的原子指针
tail。
- 获取锁:
- 当前线程创建自己的
qnode,并将其locked标志设为true。 - 线程原子地将这个
qnode加入到锁的等待队列末尾(通过CAS操作更新tail指针)。同时,获取到前一个等待线程的qnode(predecessor)。 - 如果当前线程是第一个进入队列的(即
predecessor为空),则直接获取锁。 - 如果不是第一个,则当前线程将其
qnode的next指针指向自己,并自旋等待其locked标志变为false。
- 当前线程创建自己的
- 释放锁:
- 持有锁的线程首先检查其
qnode的next指针是否为空。 - 如果为空,表示没有其他线程在等待,线程尝试原子地将
tail指针从自己的qnode设为NULL。如果成功,则锁被完全释放。 - 如果
next指针不为空(即有后续等待线程),则持有锁的线程将其后续线程的qnode的locked标志设为false,从而唤醒下一个等待线程。
- 持有锁的线程首先检查其
优缺点:
- 优点:
- 极高的可伸缩性: 由于每个线程都在自己的本地内存上自旋,而不是在共享变量上,MCS Lock大大减少了缓存一致性协议的开销,降低了总线流量,在高并发下表现出色。
- 公平性: 线程按照进入队列的顺序获取锁。
- 避免伪共享: 每个线程自旋在其私有缓存行,避免了伪共享。
- 缺点:
- 实现复杂: 相比TAS或CAS锁,MCS Lock的实现逻辑更为复杂。
- 需要为每个等待线程动态分配
qnode(尽管通常可以在栈上分配)。
MCS Lock通常被认为是实现高性能、可伸缩自旋锁的最佳实践之一,尤其适用于需要处理大量并发请求的场景。
其他高级自旋锁考量与优化
公平性与饥饿问题
在多线程环境中,非公平锁(如TAS和CAS自旋锁)可能会导致某些线程长时间无法获取锁,即“饥饿”。Ticket Lock和MCS Lock通过队列机制解决了这个问题,确保了公平性。
缓存一致性与伪共享
在多核处理器中,为了提高性能,每个核心都有自己的高速缓存。当多个核心访问同一个内存地址时,会涉及缓存一致性协议来保证数据视图的统一。自旋锁的实现对缓存一致性影响巨大:
- 共享变量自旋: TAS和CAS锁让所有等待线程在一个共享变量上自旋,这导致该变量所在的缓存行在不同核心之间频繁“跳动”(缓存行失效和传输),产生大量总线流量,严重影响性能。
- 伪共享(False Sharing): 如果不同的自旋锁变量(或与锁无关的数据)恰好位于同一个缓存行中,即使它们逻辑上不相关,对其中一个变量的修改也会导致整个缓存行失效,进而影响其他变量的访问,这就是伪共享。解决方案通常是通过填充(padding)将变量放置在不同的缓存行中。
- MCS Lock的优势: MCS Lock通过让每个线程在其本地
qnode上自旋,有效避免了共享缓存行的竞争和伪共享问题,因此具有更好的可伸缩性。
适应性自旋锁(Adaptive Spinlock)
为了结合自旋锁和互斥锁的优点,一些操作系统(如Linux内核)实现了适应性自旋锁。这种锁会根据情况动态决定是自旋还是休眠:
- 如果持有锁的线程正在运行,那么等待线程可能会选择自旋一段时间。
- 如果持有锁的线程被阻塞(例如,因为它在等待I/O),那么等待线程就会选择休眠(上下文切换)而不是继续自旋,以避免浪费CPU资源。
- 如果锁持有者在另一个CPU上运行,并且很快就会释放锁,那么自旋是高效的。
总结
自旋锁是多线程编程中一种重要的同步原语,尤其适用于锁持有时间极短、且避免上下文切换开销优于CPU利用率的场景。从最简单的Test-and-Set和Compare-and-Swap,到解决公平性的Ticket Lock,再到解决高并发下缓存竞争和可伸缩性问题的MCS Lock,每种实现都有其特定的应用场景和优缺点。
在选择和实现自旋锁时,需要根据实际的并发程度、锁的粒度、锁的持有时间以及对公平性的要求进行权衡。对于大多数高性能并发系统,MCS Lock或其变种因其卓越的可伸缩性而成为首选。同时,对缓存一致性和伪共享的理解也是优化自旋锁性能的关键。