1. 从一次线上卡顿说起:CLH自旋锁到底解决了什么问题
去年帮一个团队排查一个多线程服务的性能抖动问题,现象很典型:CPU占用率飙到80%以上,但吞吐量反而掉了三成。用性能分析工具抓了一阵子,发现大量时间耗在一个全局互斥锁的争抢上。当时他们用的是最朴素的那种基于原子变量轮询的自旋锁,几十个线程围着一个变量疯狂做原子操作,缓存一致性流量直接把总线打满了。后来把锁换成CLH队列锁,同样的压力测试下,CPU占用降到40%左右,吞吐量翻了一倍多。这个案例让我觉得有必要把CLH自旋锁的原理和落地细节好好梳理一遍。
CLH锁的名字来自三位提出者Craig、Landin、Hagersten的姓氏首字母,它是一种基于链式队列的自旋锁。和传统自旋锁最大的区别在于:每个等待线程不再盯着同一个共享变量,而是盯着自己前驱节点的一个状态字段。这个设计上的小转变,带来的是缓存一致性流量的数量级下降。它适合谁看?如果你在做高并发中间件、线程池、无锁数据结构,或者单纯想搞明白Java里AbstractQueuedSynchronizer(AQS)的底层队列是怎么转的,那CLH这套思路你绕不开。哪怕你平时写业务代码,理解它也能帮你在遇到锁竞争瓶颈时,知道该往哪个方向调优。
这篇文章我会从设计动机讲起,把CLH的节点结构、加锁解锁流程、内存序问题、NUMA场景下的变体,以及实际写代码时容易踩的坑,一层层拆开。代码示例我会用C和Java两种语言对照着给,方便不同背景的读者直接抄作业。
2. CLH锁的核心设计思路拆解
2.1 传统自旋锁为什么会在高竞争下崩掉
先说说最朴素的自旋锁长什么样。一个atomic_flag或者atomic_int,加锁就是while(test_and_set(&lock))空转,解锁就是lock = 0。单线程或者低竞争场景下,这东西快得飞起,因为它没有任何队列维护开销,一条原子指令就搞定。
问题出在竞争激烈的时候。假设有32个线程同时抢这把锁,那么这32个线程会不停地对同一个内存地址执行原子交换操作。每次原子操作都会让这个缓存行在多个CPU核心之间来回弹跳。在典型的x86架构下,一个缓存行在核心间迁移的代价大约是几十到上百个时钟周期,而原子操作还会锁总线或者锁缓存行。32个线程乘以每秒数百万次的轮询,产生的缓存一致性流量足以让内存总线饱和。更糟糕的是,持有锁的那个线程在临界区里的工作也会被这些流量拖慢,因为它访问自己栈上或者堆上的数据时,缓存可能被那些原子操作挤出去。
这就是所谓的“缓存行乒乓”问题。锁本身没做错什么,错的是所有等待者都盯着同一个位置。
2.2 CLH的破局思路:把“抢”变成“排队等通知”
CLH锁的核心思想特别像现实生活中的排队办事。传统自旋锁是一群人挤在一个窗口前,谁胳膊长谁先够到窗口。CLH则是发号机加叫号屏:每个人拿到一个号,站在自己前一个人后面,只盯着前一个人的后脑勺。前一个人办完事走了,拍一下你的肩膀,你就知道轮到你了。
映射到技术层面:每个线程有一个自己的节点(QNode),节点里有一个locked字段。线程要加锁时,把自己的节点挂到队列尾部,然后自旋等待自己前驱节点的locked字段变为false。前驱节点释放锁时,把自己的locked置为false,后继线程看到后就可以进入临界区了。
这个设计的关键收益是:每个线程自旋的是自己前驱节点的locked字段,而这个字段通常位于前驱线程自己的节点对象里。不同线程自旋的是不同的内存地址,这些地址大概率落在不同的缓存行上。于是缓存行不再乒乓,每个核心安安静静地读自己那份数据,直到前驱通知它。
2.3 为什么是“隐式链表”而不是显式队列
你可能会问,为什么不用一个显式的数组或者链表来管理等待队列?答案是:CLH用的是隐式链表,每个节点只需要一个指向前驱的指针(或者在某些实现里连指针都不需要,靠局部变量维持)。线程在本地栈上持有自己的节点,通过原子交换把自己挂到队尾,同时拿到前驱的引用。这个操作只需要一次原子交换,不需要额外的锁来保护队列本身。
显式队列通常需要额外的同步机制来保护入队和出队操作,比如用另一个锁或者复杂的无锁算法。CLH巧妙地把队列维护和锁获取合并成了一次原子操作,这是它简洁高效的根本原因。
注意:CLH的隐式链表意味着你没法从队列头遍历到队列尾,只能从每个节点往前看。这在调试的时候会有点麻烦,因为你没法直接打印整个等待队列。排查死锁时得靠额外的日志或者调试工具。
2.4 CLH与MCS锁的对比选型
提到CLH就不得不提MCS锁,两者经常被放在一起比较。MCS锁也是队列锁,但它是显式链表,每个节点有一个next指针指向后继。加锁时自旋在自己的节点上,前驱释放时修改后继节点的locked字段来通知它。
两者的核心差异在于自旋的位置:CLH自旋在前驱节点上,MCS自旋在自己的节点上。这个差异导致CLH更适合做自旋锁,MCS更适合做阻塞锁。因为CLH的等待线程自旋的是前驱的缓存行,如果前驱在另一个NUMA节点上,跨节点访问延迟会比较高。而MCS自旋自己的节点,缓存行始终在本地,NUMA友好性更好。但MCS需要显式的next指针,入队和通知的时序更复杂,实现难度更高。
实际选型时,如果你的场景是短临界区、低到中等竞争,CLH足够用且实现简单。如果是长临界区、高竞争、NUMA架构明显,MCS或者它的变体更合适。Java的AQS早期版本用的是CLH的变体,后来也做了不少优化来适应不同场景。
3. CLH锁的节点结构与内存布局细节
3.1 QNode的字段设计
一个最简的CLH节点通常长这样:
typedef struct QNode { atomic_bool locked; // 前驱释放时置为false,表示我可以进入临界区 struct QNode *prev; // 指向前驱节点 // 可能还有padding字段用于避免伪共享 } QNode;locked字段是核心,初始为true。线程加锁时把自己的节点locked设为true,然后挂到队尾,接着自旋等待前驱的locked变为false。解锁时把自己的locked置为false。
prev指针用于在解锁后找到前驱节点,以便后续可能的清理操作。有些实现不需要prev,因为线程在加锁过程中已经把前驱引用保存在局部变量里了。
3.2 伪共享问题与缓存行填充
虽然CLH已经大幅减少了缓存行争用,但还有一个隐患:如果多个QNode对象在内存中挨得很近,它们可能落在同一个缓存行里。比如线程A的节点和线程B的节点共享一个缓存行,线程A自旋读自己的locked字段时,会把整个缓存行拉到自己的核心。线程B修改自己的locked字段时,又会让这个缓存行失效。虽然比所有人抢同一个变量好得多,但仍然存在不必要的缓存行迁移。
解决办法是缓存行填充。在QNode里加一些无用的填充字段,让每个节点独占一个缓存行。典型的缓存行大小是64字节,所以填充到64字节的倍数即可:
typedef struct QNode { atomic_bool locked; char padding[63]; // 填充到64字节 struct QNode *prev; } QNode;实际写代码时可以用alignas(64)或者编译器特定的属性来对齐。Java里可以用@Contended注解,不过需要开启JVM参数才能生效。
提示:填充不是越多越好。过度填充会浪费内存,增加缓存压力。一般填充到一个缓存行大小就够了,除非你的场景有特殊的访问模式。
3.3 队列头尾指针的管理
CLH锁需要维护两个全局指针:tail指向队尾,head指向队头(或者叫当前持有锁的节点)。加锁时用原子交换把tail指向自己的节点,同时拿到旧tail作为前驱。解锁时把head指向自己的节点。
这里有个细节:head指针的更新时机。有些实现是在解锁时更新head,有些是在加锁时更新。两种方式各有优劣。解锁时更新head可以让后续的清理操作更及时,但需要额外的同步。加锁时更新head则更简单,但可能导致head指向一个已经释放的节点。
实际工程中,为了避免节点释放后的悬空指针问题,通常会延迟回收节点,或者用epoch-based reclamation之类的技术。这部分比较复杂,后面在“常见问题”里会展开。
4. 加锁与解锁的完整流程实现
4.1 加锁流程的逐步拆解
加锁操作可以分解为以下几个步骤:
- 准备自己的节点:在本地栈上声明一个
QNode,把locked设为true。 - 原子交换:用
atomic_exchange把全局tail指向自己的节点,同时拿到旧tail作为前驱。 - 判断前驱:如果前驱为
NULL,说明队列为空,自己直接获得锁,把head指向自己。 - 自旋等待:如果前驱不为
NULL,把自己的prev指向前驱,然后自旋读前驱的locked字段,直到它变为false。 - 获得锁:前驱的
locked变为false后,自己进入临界区。
用C11的原子操作写出来大概是这样:
void clh_lock(CLHLock *lock, QNode *node) { node->locked = true; QNode *prev = atomic_exchange(&lock->tail, node); node->prev = prev; if (prev != NULL) { while (atomic_load(&prev->locked)) { // 自旋,可以加pause指令降低功耗 __builtin_ia32_pause(); } } // 获得锁 }这里有个容易忽略的点:atomic_exchange返回的是旧值,也就是前驱节点。如果旧值是NULL,说明当前没有其他线程在等待,自己就是第一个。但注意,第一个线程并不需要把head指向自己,因为head的更新可以在解锁时做。
4.2 解锁流程与队列推进
解锁操作相对简单:
- 把自己的节点的
locked置为false,通知后继线程。 - 更新
head指针指向自己(可选,取决于实现)。 - 清理自己的节点(如果节点是动态分配的,需要释放;如果是栈上的,函数返回后自动失效)。
void clh_unlock(CLHLock *lock, QNode *node) { atomic_store(&node->locked, false); // 可选:lock->head = node; }这里的关键是:解锁时只需要修改自己的locked字段,不需要任何原子操作(如果locked是原子类型且只有一个写者)。后继线程会自旋读到这个变化。
但有个陷阱:如果后继线程还没开始自旋,或者前驱节点已经被释放了怎么办?这就是为什么很多实现要求节点不能立即释放,或者用更复杂的回收机制。
4.3 内存序与屏障的正确使用
CLH锁的正确性依赖于内存序。考虑这个场景:线程A在临界区里修改了共享数据,然后解锁把自己的locked置为false。线程B看到locked为false后进入临界区,它必须能看到线程A对共享数据的所有修改。
在弱内存序架构(如ARM、PowerPC)上,这需要显式的内存屏障。解锁时的store必须是release语义,加锁时的load必须是acquire语义。C11的原子操作默认是seq_cst,性能开销较大,实际工程中通常用memory_order_release和memory_order_acquire来优化。
// 解锁 atomic_store_explicit(&node->locked, false, memory_order_release); // 加锁自旋 while (atomic_load_explicit(&prev->locked, memory_order_acquire)) { // 自旋 }release保证解锁前的所有写操作对获取锁的线程可见,acquire保证获取锁后的读操作不会重排到获取之前。这一对语义是CLH正确性的基石,写代码时千万不能省。
注意:在x86这种强内存序架构上,
acquire和release通常就是编译器屏障,硬件层面不需要额外指令,所以性能影响很小。但在ARM上,这两条指令会生成真正的内存屏障指令,开销不可忽略。如果你的服务部署在ARM服务器上,这一点要特别留意。
4.4 一个完整的C语言实现示例
把上面的片段拼起来,一个可用的CLH锁实现大概是这样:
#include <stdatomic.h> #include <stdbool.h> #include <stdlib.h> typedef struct QNode { atomic_bool locked; struct QNode *prev; char padding[64 - sizeof(atomic_bool) - sizeof(struct QNode *)]; } QNode; typedef struct { _Atomic(QNode *) tail; } CLHLock; void clh_init(CLHLock *lock) { atomic_store(&lock->tail, NULL); } void clh_lock(CLHLock *lock, QNode *node) { atomic_store(&node->locked, true); QNode *prev = atomic_exchange(&lock->tail, node); node->prev = prev; if (prev != NULL) { while (atomic_load_explicit(&prev->locked, memory_order_acquire)) { __builtin_ia32_pause(); } } } void clh_unlock(CLHLock *lock, QNode *node) { atomic_store_explicit(&node->locked, false, memory_order_release); }使用的时候,每个线程需要自己准备一个QNode,通常放在线程局部存储或者栈上。注意节点不能在线程还没完全退出队列时就释放,否则后继线程可能访问到已释放的内存。
5. Java中CLH的变体与AQS的关联
5.1 AQS中的CLH队列长什么样
Java的AbstractQueuedSynchronizer(AQS)是CLH锁最著名的应用之一。AQS内部维护了一个双向链表,每个节点是一个Node对象,包含waitStatus、prev、next、thread等字段。虽然AQS的队列比最简CLH复杂得多,但核心思想一脉相承:每个等待线程自旋或阻塞在自己的前驱节点上。
AQS的Node结构大致如下:
static final class Node { volatile int waitStatus; volatile Node prev; volatile Node next; volatile Thread thread; // ... }waitStatus承担了CLH中locked字段的角色,但语义更丰富,可以表示取消、条件等待、传播等多种状态。AQS的加锁流程是:尝试快速获取锁,失败则创建节点入队,然后在一个循环里检查前驱状态,必要时阻塞线程。
5.2 为什么AQS要改成双向链表
纯CLH是单向的,每个节点只知道前驱。AQS改成双向链表主要是为了支持取消操作。在线程等待过程中,如果被中断或者超时,需要把自己从队列中摘除。单向链表摘除节点需要遍历整个队列找到前驱,代价太高。双向链表可以直接通过prev指针找到前驱,把前驱的next指向自己的后继,O(1)完成摘除。
另外,AQS的阻塞机制也需要双向链表。当线程决定阻塞时,它需要确保前驱节点会在释放锁时唤醒自己。双向链表让前驱可以方便地找到后继并调用unpark。
5.3 自旋与阻塞的权衡
纯CLH是纯自旋锁,线程一直忙等。AQS则引入了阻塞机制:线程自旋一段时间后如果还没拿到锁,就调用LockSupport.park挂起自己,让出CPU。这个设计是因为Java的线程调度和操作系统紧密相关,长时间自旋会浪费CPU资源。
自旋多久才阻塞?AQS没有硬性规定,但通常会在自旋几次后发现前驱还没释放,就选择阻塞。这个阈值需要根据实际场景调优。自旋太短会导致频繁的线程切换,自旋太长会浪费CPU。经验值是在单核或者低竞争场景下多自旋,多核高竞争场景下早点阻塞。
提示:如果你在写自己的锁,可以参考AQS的策略:先自旋几次,如果还没成功就阻塞。自旋次数可以用
Runtime.getRuntime().availableProcessors()来动态调整。
6. 实际落地中的常见问题与排查技巧
6.1 节点生命周期管理的坑
CLH锁最容易出问题的地方就是节点生命周期。考虑这个场景:线程A持有锁,线程B在自旋等待A的locked变为false。A解锁后立即释放了自己的节点(比如节点是malloc出来的,A调用了free)。B此时还在读A节点的locked字段,就访问了已释放内存,行为未定义。
解决办法有几种。最简单的是让节点生命周期覆盖整个等待过程:线程在加锁前分配节点,解锁后不立即释放,而是等到确定没有后继在等待时再释放。但这需要额外的同步。更工程化的做法是用epoch-based reclamation或者hazard pointer,但这些技术本身就很复杂。
实际项目中,如果锁的竞争不激烈,可以把节点放在线程局部存储里,线程退出时才释放。这样节点生命周期和线程一样长,不会有悬空问题。代价是每个线程都要占一个节点的内存,线程多了内存开销会上去。
6.2 伪共享导致的性能退化
前面提过伪共享,这里展开说一个实际案例。某团队用CLH锁保护一个高频访问的计数器,压测时发现性能不如预期。用性能分析工具一看,发现QNode数组在内存中是连续分配的,多个节点的locked字段落在同一个缓存行里。虽然每个线程自旋的是不同节点,但缓存行仍然在核心间迁移。
解决办法很简单:把QNode按缓存行对齐,或者每个节点单独分配。改完之后,同样的压测下吞吐量提升了约40%。这个案例说明,即使算法层面正确,内存布局不对照样会拖垮性能。
6.3 死锁与活锁的排查思路
CLH锁本身不容易死锁,因为它的等待关系是线性的:每个线程只等前驱,前驱只等它的前驱,最终等到持有锁的线程。只要持有锁的线程能正常释放,就不会死锁。
但有两种情况会导致问题。一是持有锁的线程在临界区内崩溃或者被永久挂起,整个队列就卡住了。二是节点状态被错误修改,比如某个线程的locked字段被意外置为false,导致多个线程同时进入临界区。
排查时可以用以下方法:
| 现象 | 可能原因 | 排查手段 |
|---|---|---|
| 所有线程卡住 | 持有锁线程崩溃 | 检查临界区是否有异常退出路径 |
| 多个线程同时进入临界区 | 内存序错误或状态被误改 | 用内存检测工具检查原子操作 |
| 性能远低于预期 | 伪共享或缓存行迁移 | 用性能分析工具看缓存未命中率 |
| 偶发数据竞争 | 节点生命周期问题 | 用地址检测工具检查悬空访问 |
注意:CLH锁的调试比普通锁困难,因为等待队列是隐式的。建议在开发阶段加一些日志,记录每个节点的入队和出队时间,方便事后分析。
6.4 NUMA架构下的性能调优
在NUMA架构下,CLH锁有一个天然劣势:线程自旋的是前驱节点的内存,如果前驱在另一个NUMA节点上,跨节点访问延迟会很高。MCS锁在这方面表现更好,因为线程自旋自己的节点,内存始终在本地。
如果你的服务跑在NUMA机器上,可以考虑以下优化:
- 用MCS锁替代CLH锁,或者用CLH的NUMA感知变体。
- 把节点分配在本地NUMA节点上,减少跨节点访问。
- 调整线程亲和性,让相关线程尽量在同一个NUMA节点上。
实测下来,在双路服务器上,NUMA优化可以让CLH锁的吞吐量提升20%到30%。具体提升幅度取决于临界区长度和跨节点访问比例。
7. 从CLH延伸出去:队列锁的演进与选型建议
CLH锁诞生于1993年,那时候多核处理器才刚刚起步。三十多年过去,硬件架构发生了翻天覆地的变化,CLH的思想也在不断演进。从最初的纯自旋CLH,到MCS的NUMA优化,再到AQS的阻塞加取消支持,队列锁的设计一直在适应新的硬件和应用场景。
如果你现在要选一把锁,我的建议是这样的:低竞争场景直接用原子变量或者互斥量,别过度设计。中等竞争、短临界区用CLH或者它的变体。高竞争、NUMA架构明显用MCS。需要阻塞和取消支持用AQS风格的队列锁。如果临界区极短且竞争极低,甚至可以考虑无锁数据结构,但那又是另一个话题了。
写CLH锁的代码不难,难的是正确处理内存序、节点生命周期和伪共享。我见过太多实现因为忽略了其中一个细节,在压测时表现正常,一到生产环境就出各种诡异问题。所以如果你打算在自己的项目里用CLH锁,建议先用压力测试工具跑够时长,再用内存检测工具过一遍,确认没有悬空访问和内存序问题。
最后分享一个我自己的习惯:每次实现队列锁,我都会写一个简单的测试用例,用多个线程反复加锁解锁,同时检查一个共享计数器是否等于预期值。这个测试虽然简单,但能抓住大部分正确性问题。再配合性能分析工具看缓存未命中率,基本就能判断实现是否达标。