3、原子操作的灵魂:Load-Linked / Store-Conditional (LL/SC) 原理

各位同学,咱们今天聊点硬核的。上一章我讲了原子操作的基本概念,说白了就是“要么全做,要么不做”。但有个问题一直悬着:在真正的多核处理器上,原子操作到底是怎么实现的?

你可能会说:“用关中断啊!”嗯,单核上确实可以。但到了多核时代,关中断只能管住当前核,其他核照样能改你的数据。这时候,就需要请出今天的主角——LL/SC

我个人习惯把LL/SC叫做“原子操作的灵魂”。为什么?因为它提供了一种优雅的方式,让多个核在共享内存上做“无锁”的协同。我在项目中遇到过不少坑,都是因为没理解透LL/SC的细节。今天咱们就把它掰开揉碎了讲清楚。

3.1 什么是LL/SC?

LL/SC是Load-LinkedStore-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。

你想想看,如果两个核同时执行这段代码,会发生什么?

  1. 核A执行LL,读到counter=0。
  2. 核B执行LL,也读到counter=0。
  3. 核A计算new_val=1,执行SC——成功!counter变成1。
  4. 核B计算new_val=1,执行SC——失败!因为counter已经被核A改了。
  5. 核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架构用LDREXSTREX实现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实现一个真正的无锁队列。到时候咱们会看到,理论怎么落地到代码里。嗯,敬请期待。