3、原子操作的灵魂:Load-Linked / Store-Conditional (LL/SC) 原理
各位同学,咱们今天聊点硬核的。上一章我讲了原子操作的基本概念,说白了就是“要么全做,要么不做”。但有个问题一直悬着:在真正的多核处理器上,原子操作到底是怎么实现的?
你可能会说:“用关中断啊!”嗯,单核上确实可以。但到了多核时代,关中断只能管住当前核,其他核照样能改你的数据。这时候,就需要请出今天的主角——LL/SC。
我个人习惯把LL/SC叫做“原子操作的灵魂”。为什么?因为它提供了一种优雅的方式,让多个核在共享内存上做“无锁”的协同。我在项目中遇到过不少坑,都是因为没理解透LL/SC的细节。今天咱们就把它掰开揉碎了讲清楚。
3.1 什么是LL/SC?
LL/SC是Load-Linked和Store-Conditional的缩写。它是一对指令,通常成对出现:
- Load-Linked (LL):从内存地址读取一个值,同时“盯住”这个地址。
- Store-Conditional (SC):尝试写入一个新值,但只有在LL之后该地址没有被其他核修改过,写入才会成功。
说白了,LL/SC就是硬件级别的“读-改-写”监控器。你读了一个值,然后做计算,最后写回去。如果中间有人动了你的数据,写操作就失败,你得重来。
核心思想:LL/SC不是直接锁住内存,而是通过“监视”内存地址的变化,来实现原子性。这是一种乐观并发控制——假设冲突很少,失败了就重试。
3.2 LL/SC的工作原理
咱们来看一个具体的例子。假设两个核都想对同一个变量 counter 做加1操作。用LL/SC实现是这样的:
// 伪代码:原子加1
retry:
old_val = LL(&counter); // 步骤1:加载并监视
new_val = old_val + 1; // 步骤2:计算新值
success = SC(&counter, new_val); // 步骤3:条件存储
if (!success) goto retry; // 步骤4:失败则重试
这里的关键在于步骤3。硬件会检查:从LL执行之后,counter这个地址有没有被其他核写过?
- 没写过:SC成功,新值写入。
- 写过:SC失败,
counter的值不变,返回0。
你想想看,如果两个核同时执行这段代码,会发生什么?
- 核A执行LL,读到counter=0。
- 核B执行LL,也读到counter=0。
- 核A计算new_val=1,执行SC——成功!counter变成1。
- 核B计算new_val=1,执行SC——失败!因为counter已经被核A改了。
- 核B跳回retry,重新LL,读到counter=1,计算new_val=2,SC成功。
最终counter的值是2,完全正确。这就是LL/SC的魔力——不需要锁,不需要关中断,全靠硬件监视。
小提示:LL/SC的监视粒度通常是缓存行(cache line)级别。也就是说,如果两个变量在同一个缓存行里,修改其中一个会导致另一个的SC也失败。这叫“伪共享”(false sharing),我在优化多核队列时吃过这个亏。
3.3 LL/SC vs. 比较并交换(CAS)
你可能听说过CAS(Compare-And-Swap),它是x86架构上常用的原子操作。LL/SC和CAS有什么区别?
| 特性 | LL/SC | CAS |
|---|---|---|
| 实现方式 | 硬件监视内存地址 | 硬件比较并交换值 |
| ABA问题 | 天然免疫(监视的是地址变化,不是值) | 容易中招(值从A变成B又变回A,CAS会误判) |
| 适用架构 | ARM、PowerPC、MIPS、RISC-V | x86、SPARC |
| 失败代价 | 重试(通常很轻量) | 重试(也轻量) |
| 实现复杂度 | 硬件稍复杂,软件简单 | 硬件简单,软件需处理ABA |
我个人更偏爱LL/SC,因为它天然解决了ABA问题。什么是ABA问题?举个例子:你用CAS实现一个栈,线程A读到头节点是A,然后被切走。线程B把A、B都弹出,再推入A。线程A恢复后,CAS发现头节点还是A,就以为没变化——但实际上栈的结构已经变了。LL/SC不会犯这个错,因为它监视的是地址的写操作次数,而不是值本身。
注意:LL/SC不是万能的。有些硬件实现有“活锁”风险——如果两个核不停地互相干扰,可能导致谁都无法成功SC。我在调试一个多核调度器时遇到过这种情况,最后靠加入随机退避(backoff)才解决。
3.4 实际硬件中的LL/SC
不同的处理器对LL/SC的实现略有差异。咱们看看几个主流架构:
ARM:LDREX / STREX
ARM架构用LDREX和STREX实现LL/SC。STREX会返回一个状态值:0表示成功,1表示失败。ARM还提供了CLREX指令,用于显式清除监视状态。
// ARM汇编:原子加1
try:
LDREX r0, [r1] // 加载counter到r0
ADD r0, r0, #1 // r0 = r0 + 1
STREX r2, r0, [r1] // 尝试存储,结果存入r2
CMP r2, #0 // 检查是否成功
BNE try // 失败则重试
RISC-V:LR / SC
RISC-V用LR(Load Reserved)和SC(Store Conditional)。用法和ARM几乎一样,但RISC-V的SC成功时返回0,失败时返回非0。
// RISC-V汇编:原子加1
try:
lr.w t0, (a0) // 加载并保留
addi t0, t0, 1 // t0 = t0 + 1
sc.w t1, t0, (a0) // 条件存储
bnez t1, try // 失败则重试
MIPS:LL / SC
MIPS是最早引入LL/SC的架构之一。它的用法和RISC-V非常相似,只是指令名称略有不同。
经验之谈:我在移植RTOS到RISC-V平台时,发现LL/SC的“监视窗口”是有限制的。比如,你不能在LL和SC之间执行太多指令,否则硬件可能自动放弃监视。通常建议在LL/SC之间只做简单的算术运算,不要调用函数或访问大量内存。
3.5 LL/SC的常见陷阱
嗯,这里要注意。LL/SC虽然强大,但用不好会踩坑。我总结了几条:
- 陷阱1:中断上下文。在中断处理函数里用LL/SC?小心!如果中断打断了LL和SC之间的代码,可能导致SC永远失败。我的建议是:在中断里尽量别用LL/SC,或者确保中断不会访问同一个地址。
- 陷阱2:缓存行对齐。LL/SC监视的是缓存行。如果变量没有对齐到缓存行边界,可能会和相邻变量“打架”。我见过一个案例,两个无关的变量因为在一个缓存行里,导致性能下降30%。
- 陷阱3:重试次数。理论上LL/SC可以无限重试,但实际中应该设置一个最大重试次数。我曾经在压力测试中遇到过连续重试几万次的情况——虽然最终成功了,但延迟高得离谱。
避坑指南:我曾经在一个多核消息队列的实现中,用LL/SC做入队操作。一开始没加退避,结果8个核同时入队时,吞吐量反而比单核还低。后来加了指数退避(第一次等1个周期,第二次等2个,第三次等4个...),性能直接翻倍。
3.6 从LL/SC到无锁数据结构
理解了LL/SC,你就掌握了构建无锁数据结构的基石。比如无锁栈、无锁队列、无锁链表,底层都是靠LL/SC或者CAS来实现的。
我举个例子,一个简单的无锁栈(Treiber Stack):
// 无锁栈的push操作
void push(Node* new_node) {
do {
new_node->next = head; // 读取当前栈顶
} while (!SC(&head, new_node)); // 尝试更新栈顶
}
你看,代码非常简洁。没有锁,没有等待,全靠LL/SC保证原子性。当然,实际生产环境要考虑内存回收(比如RCU或Hazard Pointer),但核心思想就是LL/SC。
我个人觉得,LL/SC是RTOS内核开发者必须掌握的技能。它不仅是原子操作的灵魂,更是理解现代处理器内存模型的一把钥匙。
3.7 小结
今天咱们聊了LL/SC的原理、实现、对比和陷阱。总结一下:
- LL/SC通过硬件监视内存地址,实现原子读-改-写。
- 它天然免疫ABA问题,比CAS更安全。
- 不同架构(ARM、RISC-V、MIPS)的实现大同小异。
- 使用时要小心中断、缓存行对齐和重试次数。
下一章,我会讲如何用LL/SC实现一个真正的无锁队列。到时候咱们会看到,理论怎么落地到代码里。嗯,敬请期待。