简介
ARMv8.1指令集相对于ARMv8指令集添加了不少新的功能,其中有很大的一块功能称作LSE(Large System Extensions),这其中添加了很多平台原生就支持的原子操作指令。
在这之前,如果想实现某个原子操作,必须要使用LL/SC操作,在ARMv8以前的32位系统中使用LDREX和STREX指令,从ARMv8起,它们被改名成了LDXR和STXR。
LL/SC操作本质上是很多CPU核去抢某个内存变量的独占访问,以前ARM主要用来在低功耗设备上运行,CPU核也不会太多,不会存在太大的问题。但是,现在ARM已经往数据中心发展了,几十核的ARM处理器都已经出现了,如果还是大家一起抢可能会存在严重的性能问题。因此,为了支持这种大型系统,在ARMv8.1中特意加入了大量原生原子操作指令。
ARMv8.1 LSE 与 LL/SC 的区别对比
在 ARMv8-A 架构中,Load-Link/Store-Conditional (LL/SC) 曾是实现原子读-修改-写 (RMW) 操作的传统机制。随着 ARMv8.1-A 引入了 大型系统扩展 (LSE),提供了新的、单指令的原子操作。两者都旨在多线程环境中确保操作的原子性,但它们实现的方式不同,LSE 通常提供更高的性能和更简单的使用。
ARMv8.1 LSE vs. LL/SC:对比表
以下是 LL/SC 和 LSE 原子操作在 ARMv8.1-A 中关键区别的对比表:
特性 |
LL/SC (Load-Link / Store-Conditional) |
LSE (大型系统扩展) 原子操作 (ARMv8.1-A+) |
指令 |
一对指令: |
单条指令: |
操作类型 |
乐观性: 尝试执行操作,然后检查是否成功。如果被中断或发生其他写入, |
单条指令的原子读-修改-写 (RMW): 整个 RMW 序列被保证原子性完成。 |
实现方式 |
依赖于独占监视器(每个 CPU 的硬件状态),该监视器跟踪 |
通常使用缓存一致性协议(如 MESI)配合缓存行锁定或类似的硬件机制,以确保单指令的原子性。 |
编程/编译复杂性 |
使用起来更复杂。如果 |
使用更简单,因为它是单条、保证原子的指令。编译器通常更倾向于在可用时生成 LSE 指令。 |
伪失败 |
容易出现伪失败: 即使没有其他冲突写入, |
较少出现伪失败: 作为单条指令,它在执行窗口内受外部事件中断或失效的可能性较小。 |
ABA 问题 |
天然免疫于 ABA 问题,因为它监视的是内存位置的任何修改,而不仅仅是值的变化。如果值从 A 变为 B 又变回 A, |
如果不结合版本计数器,则容易受到 ABA 问题的影响。 |
性能 |
在高竞争下可能因重复重试而导致性能下降。然而,在某些微架构(如 A64FX)上,对于非常高的线程数,LL/SC 可能显示出比 CAS 更好的扩展性。 |
通常为原子操作提供更好的性能和更低的延迟,因为它是一条单指令并避免了重试循环。消除了独占监视器管理和潜在上下文切换的开销。 |
用例 |
在 ARMv8.1 之前,是实现更高级原子原语、自旋锁和无锁数据结构的基础。在需要细粒度控制或对 ABA 问题免疫的场景下仍然相关。 |
大多数常见原子操作的首选,例如增量、减量、位操作和标准比较并交换 (CAS)。被编译器和原子库(如 C++ |
功能标志 |
ARMv8-A 的基线功能。 |
ARMv8.0 中的可选功能,从 ARMv8.1-A 开始强制要求(由 |
核心要点
LSE 是直接的优化: ARMv8.1-A 中引入 LSE 指令是为了提供更高效的单指令原子操作,这些操作以前需要通过多条指令的 LL/SC 序列来实现。
简洁性和性能: 对于大多数常见的原子操作,LSE 更简单易用,并通常提供更好的性能,特别是在中高竞争条件下,因为它减少了对重试循环的需求。
ABA 问题: 尽管 LL/SC 由于其独占监视器机制天然解决了 ABA 问题,但 LSE 的
CAS
指令(类似于 x86 的CMPXCHG
)如果不与版本计数器结合使用,则容易受到影响。共存: 两种机制并存。编译器在面向 ARMv8.1-A 或更高架构时,如果 LSE 可用,通常会生成 LSE 指令;对于旧目标或 LSE 未涵盖的操作,则会回退到 LL/SC。
在现代 ARMv8.1+ 系统中,LSE 是在底层代码中实现原子操作的首选方式,并被编译器广泛用于 C++ 中的 std::atomic
和类似结构。
关于ABA问题:这也是为什么smp_cond_load_acquire->__cmpwait_relaxed使用ll_sc的原因,由于其会wfe进入低功耗,为了唤醒需要不错过任何修改。
ARM平台下独占访问指令LDREX和STREX的原理与使用详解-CSDN博客:
在ARMv8指令集下,LDREX指令被改名成了LDXR指令,而STREX指令被改名成了STXR指令,功能基本上是一样的,除了添加了一个新的特性。当全局监视器标记的对某段内存的独占访问被清空后,将向所有标记了对该段内存独占访问的CPU核都发送事件,将它们从WFE指令中唤醒,继续执行。
ARM64平台下WFE和SEV相关指令解析_sev指令-CSDN博客 :
在ARMv8指令集中,还添加了一种情况,用来发送事件。当全局监视器标记的对某段内存的独占访问被清空后,将向所有标记了对该段内存独占访问的CPU核都发送事件。也就是说,当系统在多个CPU核上,通过LDREX或者LDXR指令读取某段内存后,系统全局监视器会将该段内存标记为独占(Exclusive),这之后又调用了WFE指令进入低功耗模式了。当系统中又有一个CPU,通过STREX或者STXR指令对该段内存进行了写入,这将清空全局监视器对该段内存的独占标记为,那么系统会自动给前面那些CPU核发送事件,将它们唤醒。
LL_SC指令
ARM平台下独占访问指令LDREX和STREX的原理与使用详解-CSDN博客
LSE指令
在这之前,如果想实现某个原子操作,必须要使用LL/SC操作,在ARMv8以前的32位系统中使用LDREX和STREX指令,从ARMv8起,它们被改名成了LDXR和STXR。
LL/SC操作本质上是很多CPU核去抢某个内存变量的独占访问,以前ARM主要用来在低功耗设备上运行,CPU核也不会太多,不会存在太大的问题。但是,现在ARM已经往数据中心发展了,几十核的ARM处理器都已经出现了,如果还是大家一起抢可能会存在严重的性能问题。因此,为了支持这种大型系统,在ARMv8.1中特意加入了大量原生原子操作指令。
加原子操作
LDADD <Ws>, <Wt>, [<Xn|SP>]
LDADD <Xs>, <Xt>, [<Xn|SP>]
STADD <Ws>, [<Xn|SP>]
STADD <Xs>, [<Xn|SP>]
LDADD指令从第三个参数,也就是Xn或SP寄存器指定的内存位置读出32位或64位的值,将其存放进第二个参数,也就是Wt或Xt寄存器中,然后再将这个读出的值和第一个参数,也就是Ws或Xs寄存器中的值相加,再存入第三个参数指定的内存中(*(Xn|SP) += Xs),并且保证这些步骤都是原子的。
STADD指令和LDADD指令基本功能相同,只不过没有第二个参数,也就是Wt或Xt寄存器,不会返回指定内存位置上没修改之前的值。
所以,ST打头的指令和LD打头的指令,基本功能上没有什么区别,只不过LD打头的指令会把在执行该原子指令之前内存中的值存入第二个参数指定的寄存器中,而ST打头的指令没有这个功能。因此,后面只介绍LD打头指令的功能。
置位原子操作
LDSET <Ws>, <Wt>, [<Xn|SP>]
LDSET <Xs>, <Xt>, [<Xn|SP>]
STSET <Ws>, [<Xn|SP>]
STSET <Xs>, [<Xn|SP>]
LDSET指令从第三个参数,也就是Xn或SP寄存器指定的内存位置读出32位或64位的值,将其存放进第二个参数,也就是Wt或Xt寄存器中,然后再将这个读出的值和第一个参数,也就是Ws或Xs寄存器中的值进行位的或操作,再存入第三个参数指定的内存中(*(Xn|SP) |= Xs),并且保证这些步骤都是原子的。
清除位原子操作
LDCLR <Ws>, <Wt>, [<Xn|SP>]
LDCLR <Xs>, <Xt>, [<Xn|SP>]
STCLR <Ws>, [<Xn|SP>]
STCLR <Xs>, [<Xn|SP>]
LDCLR指令从第三个参数,也就是Xn或SP寄存器指定的内存位置读出32位或64位的值,将其存放进第二个参数,也就是Wt或Xt寄存器中,然后再将这个读出的值和第一个参数,也就是Ws或Xs寄存器中的值取反之后进行位的与操作,再存入第三个参数指定的内存中(*(Xn|SP) &= (NOT Xs)),并且保证这些步骤都是原子的。
异或原子操作
LDEOR <Ws>, <Wt>, [<Xn|SP>]
LDEOR <Xs>, <Xt>, [<Xn|SP>]
STEOR <Ws>, [<Xn|SP>]
STEOR <Xs>, [<Xn|SP>]
LDEOR指令从第三个参数,也就是Xn或SP寄存器指定的内存位置读出32位或64位的值,将其存放进第二个参数,也就是Wt或Xt寄存器中,然后再将这个读出的值和第一个参数,也就是Ws或Xs寄存器中的值进行位的异或操作,再存入第三个参数指定的内存中(*(Xn|SP) ^= Xs),并且保证这些步骤都是原子的。
比较存储原子操作
LDSMAX <Ws>, <Wt>, [<Xn|SP>]
LDSMAX <Xs>, <Xt>, [<Xn|SP>]
LDUMAX <Ws>, <Wt>, [<Xn|SP>]
LDUMAX <Xs>, <Xt>, [<Xn|SP>]
STSMAX <Ws>, [<Xn|SP>]
STSMAX <Xs>, [<Xn|SP>]
STUMAX <Ws>, [<Xn|SP>]
STUMAX <Xs>, [<Xn|SP>]
LDSMAX指令从第三个参数,也就是Xn或SP寄存器指定的内存位置读出32位或64位的值,将其存放进第二个参数,也就是Wt或Xt寄存器中,然后再将这个读出的值和第一个参数,也就是Ws或Xs寄存器中的值比较大小,再将大的那个值存入第三个参数指定的内存中(*(Xn|SP) = MAX(*(Xn|SP), Xs)),并且保证这些步骤都是原子的。大小比较的时候,将这个数值作为有符号数比。而LDUMAX指令,顾名思义,和LDSMAX指令功能基本相同,只是比较大小的时候,将这个数值作为无符号数比。
有比较过后将较大的值存入的指令,那就一定会有比较过后将较小的值存入的指令:
LDSMIN <Ws>, <Wt>, [<Xn|SP>]
LDSMIN <Xs>, <Xt>, [<Xn|SP>]
LDUMIN <Ws>, <Wt>, [<Xn|SP>]
LDUMIN <Xs>, <Xt>, [<Xn|SP>]
STSMIN <Ws>, [<Xn|SP>]
STSMIN <Xs>, [<Xn|SP>]
STUMIN <Ws>, [<Xn|SP>]
STUMIN <Xs>, [<Xn|SP>]
交换原子操作
SWP <Ws>, <Wt>, [<Xn|SP>]
SWP <Xs>, <Xt>, [<Xn|SP>]
SWP指令从第三个参数,也就是Xn或SP寄存器指定的内存位置读出32位或64位的值,将其存放进第二个参数,也就是Wt或Xt寄存器中,然后再将第一个参数,也就是Ws或Xs寄存器中的值存入第三个参数指定的内存中,并且保证这些步骤都是原子的。
比较交换原子操作
CAS <Ws>, <Wt>, [<Xn|SP>{,#0}]
CAS <Xs>, <Xt>, [<Xn|SP>{,#0}]
CASP <Ws>, <W(s+1)>, <Wt>, <W(t+1)>, [<Xn|SP>{,#0}]
CASP <Xs>, <X(s+1)>, <Xt>, <X(t+1)>, [<Xn|SP>{,#0}]
CAS指令从第三个参数,也就是Xn或SP寄存器指定的内存位置读出32位或64位的值,然后再将这个读出的值和第一个参数,也就是Ws或Xs寄存器中的值进行比较,如果它们相同的话,就把第二个参数,也就是Wt或Xt寄存器中的值存入第三个参数指定的内存中,最后不管前面比较的结果相不相同,都需要将前面读取出来的内存位置的原始值存入第一个参数指定的寄存器中,并且保证这些步骤都是原子的。
CASP也是比较交换原子操作,多出来的P表示Pair。和CAS不同的是,它一次性操作两个连续成对的寄存器。
前面介绍的都是基本的原子操作,操作的寄存器都是32位或64位的,并且没有任何内存屏障的语义。
在上面的基本操作基础上,ARMv8.1还提供了带Load-Acquire或Store-Release单向内存屏障语义的指令。具体来说,如果想在一条基本的原子操作指令上加上Load-Acquire语义,可以在基本指令后面加上A;而如果想在一条基本的原子操作指令上加上Store-Release语义,可以在基本指令后面加上L;还可以两个都加,可以在基本指令后面同时加上AL,那就等同于一个数据内存屏障。
例如,对于LDADD指令来说,有如下自带内存屏障语义的版本:
LDADDA <Xs>, <Xt>, [<Xn|SP>]
LDADDAL <Xs>, <Xt>, [<Xn|SP>]
LDADDL <Xs>, <Xt>, [<Xn|SP>]
但是,对于以ST打头的指令,由于它们不会返回从内存中读取出来的值,所以不需要Load-Acquire语义,就没有包含L的版本。
例如,对于STADD指令,只提供下面一个带Store-Release的版本:
STADDL <Xs>, [<Xn|SP>]
AI写代码
还有,前面说的基本指令都是操作32位或64位数的,如果想操作16位的数,需要在基本指令后面加上H(Halfword);而如果想操作8位的数,需要在基本指令后面加上B(Byte)。
如果原子操作指令又要包含Load-Acquire或Store-Release单向内存屏障语义,又要操作8位或16位的数,那么在基本原子操作指令的后面,先添加表示单向内存屏障语义的A或L,后添加表示操作数位数的B或H。
例如,还是对于基本的LDADD指令,如果想操作8位的数,则有如下版本:
LDADDB <Ws>, <Wt>, [<Xn|SP>]
LDADDAB <Ws>, <Wt>, [<Xn|SP>]
LDADDLB <Ws>, <Wt>, [<Xn|SP>]
LDADDALB <Ws>, <Wt>, [<Xn|SP>]