技术指南:从 lr/sc 到 qspinlock——RISC-V 原子操作与锁的 Linux 实践

在单核系统里,"原子性"是一个几乎免费的属性:一条指令要么执行完,要么没执行,中断只会发生在指令边界;哪怕是最朴素的 read-modify-write 序列,只要关中断就能护住临界区。而一旦进入多核 SoC,两个核可以真正同时执行,"原子"从调度器的礼物变成了内存系统的责任——一次看似无害的 counter++,在汇编层面是 load、add、store 三步,两核交错执行就可能丢掉一次自增;而要把这三步护成一个不可分割的整体,硬件与软件要联手付出的代价,远比单核时代的直觉复杂。

原子操作与锁之所以值得 RISC-V Linux 开发者系统性梳理,原因有二:

其一,它是并发正确性的最底层原语。互斥锁、引用计数、无锁队列、内核的 refcount/trefcount 家族,全部建立在"一次原子读改写 + 必要的内存序约束"之上;这个原语的语义边界(lr/sc 的限制条件、AMO 的序保证)划定了上层所有并发代码的正确性地基。

其二,它是多核扩展性的第一现场。锁是共享变量的极端形态——所有竞争者排队获取的同一份状态,正是本系列第一篇讨论的"缓存行所有权迁移"的火上浇油版;一个高竞争自旋锁的获取循环,可以让 N 核系统的实际并行度趋近于 1。锁的实现从 ticket 到 qspinlock、从自旋到自适应的每一轮演进,都是在与缓存一致性协议的代价博弈。本文围绕"原子操作如何实现、RISC-V 规范如何界定其语义、Linux 上锁的实现如何演化与排查"三个层面展开。

一、原子操作问题从哪里来

1.1 三个软件可见的场景

多核系统中,需要原子性保障的场景可以归纳为三类:

计数与统计:引用计数(refcount_inc)、网络包统计(pernet 计数器)、事件计数。单个原子加的成本不高,但高频调用叠加缓存行乒乓(第一篇的 HITM 教训),可以让它成为 profile 里的常驻热点。

同步原语的构建:自旋锁的 lock/unlock、信号量的 down/up、读写锁的计数维护,本质都是"原子读改写 + 期望值检查"的组合。锁的每一次获取与释放,都是对锁字所在缓存行的一次所有权往返——锁的实现质量,直接决定这些往返的次数与形态。

无锁数据结构:队列的 CAS 推进、指针的发布(publication)、RCU 的宽限期计数。无锁不是没有代价,而是把代价转移到了失败重试与内存序约束上;CAS 循环在高竞争下的大量失败重试,会让总线流量随核数恶化——无锁结构的扩展性上限,往往不在算法而在一致性协议的吞吐。

1.2 一个关键的认知框架:原子性 ≠ 内存序

进入指令细节之前,先确立一个对后文所有讨论都成立的框架:

"操作不可分割"(原子性)与"其他核以什么顺序观察到"(内存序)是两个正交的属性,RISC-V 把它们交给两套机制分别解决。

lr/sc 与 AMO 解决原子性——保证读改写序列不被交错;aq/rl 位与 fence 解决内存序——保证临界区内的普通访存不会被实现重排到临界区之外(这是锁正确的关键:不是锁字本身的更新原子,而是"锁保护的临界区内容"不被泄漏)。混淆这两者的典型症状是:加了原子操作却在弱序模型下依然丢数据,因为只保证了计数器自身的原子,没保证它旁边的标志位按预期顺序可见。本系列第一篇的 RVWMO 框架在此处落地:同步原语是内存模型之上的一层薄封装,封装的正确性完全依赖对内存模型的理解。

二、机制层:两种原子性与它们的代价

尽管微架构实现属于各 CPU 设计方,指令集层面的两种形态及其软件可见行为值得逐一拆解。

2.1 lr/sc:乐观的原子性

LR/SC(Load-Reserved / Store-Conditional)是 RISC-V 的条件原子对:lr.w rd, (rs1) 读一个字并留下"保留"标记;随后的 sc.w rd, rs2, (rs1) 若期间没有干扰则写入成功并返回 0,否则失败返回非 0。软件以循环重试的方式组合出任意原子操作:

c

复制

/* 原子加法的 lr/sc 实现 */
static inline void atomic_add(atomic_t *v, int i)
{
    int old;
    do {
        old = lr.w(v);        /* 打保留标记 */
    } while (sc.w(v, old + i)); /* 失败则重试,sc 返回非 0 表示放弃 */
}

必须掌握的是规范赋予 SC 失败的"不确定性":规范允许实现在任意情况下让 sc 失败(spurious failure)——哪怕没有任何其他核触碰过该地址。因此 lr/sc 循环里不能夹带假设"最多重试 N 次"的逻辑,也不能在 lr 与 sc 之间放入可能被中断拉长的代码路径(部分实现里,lr 与 sc 之间的异常返回会隐式清除保留标记,这正是"lr/sc 之间只做寄存器运算"这条纪律的由来)。此外,保留标记的粒度是"保留集"(reservation set),最小是一个字节、最大无上限,具体粒度是实现自由——跨保留集边界的复合写(先 lr 字 A 再 store 字节 B)不属于规范定义的原子性范畴。

2.2 AMO:悲观的原子性

AMO(Atomic Memory Operation)一族——amoswap、amoadd、amoand、amoor、amoxor、amomin/max(含无符号变体)——把读改写压进单条指令:硬件保证整个操作对外不可分割,无需软件循环。它的优势恰恰是 lr/sc 的短板:无条件成功、无重试开销、语义确定;代价是操作种类被指令集固定,无法表达"读出旧值、按复杂条件决定写什么"的通用 CAS 语义——那仍要回到 lr/sc。

工程上的分工由此清晰:确定性的算术原子(自增、清位、置位)用 AMO,通用 CAS 用 lr/sc。内核的 atomic_* 家族在 RISC-V 上正是按这个原则映射的:atomic_add 走 amoadd,cmpxchg 走 lr/sc 循环。

2.3 微结构视角:原子操作在总线上是什么

无论 lr/sc 还是 AMO,落到一致性协议层面都是一件事:让发起核拿到目标缓存行的独占权(M 状态),完成修改,再让出。这解释了两个实践中的关键现象:

其一,原子操作的延迟下界由"缓存行所有权往返"决定——本核已持有 M 状态时几个周期搞定,行在别的核手里时则是一次跨核事务。无竞争时 AMO 便宜,竞争时 AMO 与 lr/sc 一样昂贵,差异只在重试与否。

其二,AMO 天然是"写者"——每次执行都在争夺 M 状态,哪怕它逻辑上只是读(比如用 amoor 读标志)。对同一行的"只读式" AMO 轮询(自旋等待的经典写法)会造成所有权反复易手,比真正的读(S 状态可多核共存)昂贵一个量级——这就是各种 backoff 机制要优化的对象:自旋的成本不在指令本身,而在自旋掀起的总线流量。

三、RISC-V 的立场:指令语义、序约束与扩展演进

3.1 aq/rl:原子操作自带的内存序开关

RISC-V 给每条 AMO 与 lr/sc 附带了 aq(acquire)、rl(release)两个可选位,这是与"裸原子操作"的关键差异,也是它比许多老架构更优雅的地方:

  • rl 位(如 amoswap.w.rl):该操作之前的访存不能被重排到它之后——释放语义,用于解锁路径;
  • aq 位(如 lr.w.aq):该操作之后的访存不能被重排到它之前——获取语义,用于加锁路径。

一个最小正确的临界区只需要:加锁用 aq 访问锁字,解锁用 rl 写锁字——临界区内的普通 load/store 就被天然夹在了两个单向屏障之间,无需全量 fence。规范甚至定义了带 aq/rl 的普通 load/store(ld.aq/sd.rl),供发布-消费(publication/consumption)模式使用。对内核开发者,这意味着 smp_mb__after_spinlock() 这类接口在 RISC-V 上的展开可能为空——不是遗漏,而是锁的实现已经携带了必要的序。

3.2 Zacas 与 Zabha:原子操作粒度的扩展

基础指令集的 AMO 与 lr/sc 操作宽度是字/双字(W/D)。两个新扩展补齐了两个长期缺口:Zacas 提供 128 位(amocas.q)的 CAS——用于双指针发布、带标签指针等无锁结构的关键部件;Zabha 把 AMO 与 lr/sc 扩展到字节与半字宽度——用于位图、引用计数字段等紧凑布局的原子操作,避免"为了改一个字节而抢一整行的独占权再读改写"。两者都是可选扩展,软件须通过 ISA 字符串探测后再启用,内核的替代实现(比如用 Zacas 优化某些无锁路径)随版本演进,以当前内核为准。

3.3 一个值得强调的规范细节:fence.i 与指令序

原子操作家族管数据序,代码自修改场景还有一条独立通道:写完代码后必须 fence.i(指令流同步),才能保证取指流看到新指令。JIT、模块加载、uprobe 注入都依赖这条语义。它不属于原子操作范畴,却常与原子发布"代码指针"的场景同框出现——发布了指向新代码的指针还不够,新代码本身的可取见性要单独保证。混淆这两层是 JIT 类 bug 的高发区。

四、Linux 上的落地:锁实现的四十年,在一颗核上的投影

原子指令是原料,锁是成品。Linux 的锁实现在 RISC-V 上的选型与演化,是理解多核同步开销的最佳标本。

4.1 自旋锁:从 test-and-set 到 ticket 再到 qspinlock

朴素的 test-and-set 自旋锁(TAS 锁)在弱序模型下的问题有二:不公平(解锁瞬间所有竞争者重新抢夺,缓存行风暴)与总线流量(第一篇的"乒乓"极端化)。内核的演化走了两步:

ticket lock(公平锁):竞争者取号排队,按序获取。公平性解决了,但每次锁字变动仍要广播给所有排队者——O(竞争者数) 的缓存流量没有本质改善。

qspinlock(队列锁,当前主线默认):每个竞争者在自己的 per-CPU 缓存行上排队自旋(MCS 锁思想),锁字里只保留队列头尾的压缩摘要。排队者等待的是前驱在自己专属节点上的放行——自旋发生在私有缓存行上,零总线流量;锁字的变动只在队头交接的瞬间发生。把"广播税"从 O(N) 压缩到 O(1),正是第一篇"写者独立"原则在锁结构上的教科书级应用。

c

复制

/* 内核里使用侧的形态几十年未变——演化的全部细节藏在实现里 */
spin_lock(&lock);
/* 临界区 */
spin_unlock(&lock);

需要清醒的是:qspinlock 消除的是自旋流量,不是获取本身的代价——高竞争下获取仍要排队,临界区执行时间 × 竞争者数仍是吞吐上限。锁的最终解法永远在减少竞争,而非优化竞争。

4.2 自适应与让渡:pvqspinlock 与 osq

两个补充机制完善了谱系:pvqspinlock(paravirtualized)在虚拟化环境下让自旋失败的 VCPU 主动让出物理核(经 SBI HSM 或 hypervisor 协作),避免"自旋的 VCPU 占着物理核、持锁的 VCPU 排不上物理核"的活锁;osq(optimistic spinning queue)用于 mutex 的乐观自旋路径——等锁的线程先自旋试探(持锁者若在别的核上活跃运行,很快会释放),试探失败再睡眠,把短临界区的上下文切换成本省下来。这两个机制都依赖"知道对方在不在跑"——又是拓扑与调度信息的消费者。

4.3 per-CPU 与原子操作的分工

内核处理"多核计数"的标准答案是本系列第一篇的 per-CPU 化,但聚合读出的那一刻仍需面对原子性:percpu_counter 用批量阈值(每核攒够一定量才原子地汇入全局计数)摊薄原子操作频率,this_cpu_add 则依赖"本核独占该变量"的事实免去原子指令——后者成立的隐含前提是禁抢占,防止本核在指令中途被切走、另一个上下文踏上同一 per-CPU 变量。理解这对组合拳(结构上 per-CPU 消除共享 + 局部性免原子 + 聚合时批量摊薄),就理解了内核统计子系统的全部设计。

4.4 用户态的对应物

同样的演化在用户态重演:glibc 的 pthread mutex 带自适应自旋(futex 的 futex_wait/wake 对应内核的睡眠路径),liburcu、folly 等无锁库大量依赖 __atomic_* 内建(编译到 AMO/lr/sc + aq/rl)。RISC-V 用户态开发者的纪律与内核一致:默认用带 acquire/release 语义的内建原子操作,不手写 fence 序列;对 CAS 循环做竞争评估,警惕失败重试风暴。

五、验证:锁的代价要靠测出来

5.1 锁统计:竞争在哪里,等了多久

bash

复制

# 内核锁事件跟踪(需启用相应 tracepoint,名称以当前内核为准)
perf lock record -a -- ./workload
perf lock report

# 竞争热点的函数级定位
perf record -e lock:* -- ./workload

perf lock 报告里最值钱的字段是等待时间分布与竞争的调用栈——前者区分"低竞争高频"(优化方向是减少调用次数)与"高竞争低频"(方向是拆分临界区),后者直接指向该拆哪把锁。

5.2 微基准:从原子操作到临界区的分层测量

测量锁栈的分层代价,标准做法是三层微基准:裸 AMO(无竞争与有竞争各测一次,差值即所有权往返的裸代价,与本系列第一篇的乒乓基准呼应);无竞争锁获取/释放(测 qspinlock 快路径的指令开销);有竞争锁(N 线程 N 核打满同一把锁,吞吐随 N 的衰减曲线)。第三层的曲线形态最有诊断价值:平坦衰减说明临界区本身是瓶颈(该缩临界区),断崖式衰减则提示缓存行乒乓或调度抖动(该查拓扑落位)。

5.3 一份示例对照(量级说明用)

计数方案(8 核满载自增同一逻辑计数器)相对吞吐特征
单一全局 atomic_add1×(基准)缓存行乒乓,吞吐随核数恶化
全局计数 + 批量聚合(攒 64 次一汇)显著提升原子操作频率降为 1/64
per-CPU 计数 + 读时汇总最高且近线性写路径零共享,读路径付聚合成本

(示例仅用于说明量级与趋势:批量化与 per-CPU 化的收益本质都是"降低同一缓存行的写频率",与本系列第一篇的对照表同源;具体数字因核数、互连、锁实现而异,不可外推为普遍规律。)

5.4 排查残留问题

若锁开销仍不及预期,按顺序检查:perf lock 的 top 争用者是否集中在单把锁(该拆分或分片:shard、seqlock、RCU 替代);临界区内是否混入了 I/O、内存分配等可睡眠或长尾操作(自旋锁临界区的铁律是短且不睡);per-CPU 变量是否被 this_cpu_* 之外的路径触碰(破坏免原子前提);虚拟化环境的 pvqspinlock 是否生效(未生效时的自旋让渡问题);以及原子操作是否用在了本可只读的场景(把 amo 轮询改为普通读 + 编译器屏障,读走 S 状态副本零流量)。

六、结语:指令定义原子,协议定价竞争,结构决定排队长度

RISC-V 原子操作与锁的工程全貌,可以压缩为一句话:指令集(lr/sc、AMO、aq/rl)定义原子性与内存序的语义契约,一致性协议为每一次独占权往返定价,而数据结构与锁算法的设计决定这份价目表被翻开的频率。

0
0
0
0
评论
未登录
暂无评论