4、x86的答案:Compare-And-Swap (CAS) 指令详解与汇编实战

好,咱们接着聊。上一章我讲了原子操作的基本概念,也提到了x86平台上的关键指令。今天咱们就深入看看x86给出的核心答案——Compare-And-Swap (CAS)。说白了,CAS就是无锁编程的基石,你想想看,几乎所有的高性能并发组件,底层都离不开它。

我个人习惯把CAS叫做「硬件级的乐观锁」。为什么?因为它不直接阻塞线程,而是先尝试操作,失败了再重试。这种思路,在RTOS内核里特别实用。

4.1 什么是CAS?

CAS的全称是 Compare And Swap,翻译过来就是「比较并交换」。它做的事情很简单:

  1. 读取内存中的某个值,记为 old
  2. 把这个 old 和一个期望值 expected 做比较
  3. 如果相等,就把一个新值 new 写入这个内存地址
  4. 如果不相等,什么都不做

整个过程是原子完成的,中间不会被任何中断或其它CPU核心打断。

核心要点:CAS是一条CPU指令,不是一组指令。在x86上,它就是 CMPXCHG。一条指令搞定比较和交换,这才是真正的原子性。

4.2 x86上的CAS指令:CMPXCHG

在x86架构里,CAS对应的指令是 CMPXCHG。我记得第一次在数据手册里看到这个指令时,觉得名字挺绕口。其实拆开看就明白了:

  • CMP:Compare,比较
  • XCHG:Exchange,交换

合起来就是「比较并交换」。嗯,名字很直白。

CMPXCHG 指令的操作数有三个(隐含一个):

操作数 说明
累加器 (EAX/RAX) 隐含操作数,存放期望值 expected
目标操作数 (dst) 内存地址,存放要操作的值
源操作数 (src) 新值 new,要写入的值

指令执行逻辑:

  • 比较 EAX[dst]
  • 如果相等:[dst] = src,并且设置 ZF(零标志位)为1
  • 如果不相等:EAX = [dst],并且设置 ZF 为0

小技巧:执行完 CMPXCHG 后,检查 ZF 标志位就知道操作是否成功。如果 ZF=1,说明交换成功;如果 ZF=0,说明有人在你之前改了值,需要重试。

4.3 汇编实战:手写一个CAS函数

光说不练假把式。咱们直接上手写汇编。我在做RTOS内核时,经常需要封装这些底层指令。下面是一个32位CAS的汇编实现:

; 函数原型:int cas_32(uint32_t* ptr, uint32_t expected, uint32_t new_value);
; 返回值:1表示成功,0表示失败
; 参数:
;   ptr      - [esp+4]  目标内存地址
;   expected - [esp+8]  期望值
;   new_value - [esp+12] 新值

cas_32:
    push    ebx
    mov     ecx, [esp+8]    ; ecx = ptr
    mov     eax, [esp+12]   ; eax = expected
    mov     ebx, [esp+16]   ; ebx = new_value

    lock cmpxchg [ecx], ebx ; 原子比较并交换

    jz      .success        ; 如果ZF=1,跳转到成功
    xor     eax, eax        ; 返回0(失败)
    pop     ebx
    ret

.success:
    mov     eax, 1          ; 返回1(成功)
    pop     ebx
    ret

这里有个关键点——LOCK 前缀。我在项目中遇到过一个问题:在多核系统上,不加 LOCK 前缀的 CMPXCHG 并不是原子的。它会先读后写,中间可能被另一个核心插一脚。

注意:在单核系统上,CMPXCHG 不加 LOCK 前缀也是原子的(因为不会被中断打断)。但在多核系统上,必须加 LOCK 前缀,否则其它核心可能看到中间状态。我曾经在调试一个双核系统时,就因为漏了这个前缀,导致数据竞争,查了两天才找到原因。

4.4 64位CAS:CMPXCHG8B 与 CMPXCHG16B

32位CAS够用吗?说实话,在64位系统上,很多时候我们需要操作64位甚至128位的数据。比如在RTOS里,一个64位的状态标志位,或者一个128位的指针+计数器组合。

x86提供了对应的指令:

  • CMPXCHG8B:比较并交换8字节(64位)
  • CMPXCHG16B:比较并交换16字节(128位)

CMPXCHG8B 举例,它的操作数更复杂一些:

寄存器 作用
EDX:EAX 64位期望值(高32位在EDX,低32位在EAX)
ECX:EBX 64位新值(高32位在ECX,低32位在EBX)
[dst] 目标内存地址(8字节对齐)

下面是一个64位CAS的汇编示例:

; 函数原型:int cas_64(uint64_t* ptr, uint64_t expected, uint64_t new_value);
; 注意:64位值需要拆成高低32位传入

cas_64:
    push    ebx
    push    ebp
    mov     ebp, [esp+12]   ; ebp = ptr
    mov     eax, [esp+16]   ; eax = expected的低32位
    mov     edx, [esp+20]   ; edx = expected的高32位
    mov     ebx, [esp+24]   ; ebx = new_value的低32位
    mov     ecx, [esp+28]   ; ecx = new_value的高32位

    lock cmpxchg8b [ebp]    ; 原子比较并交换8字节

    jz      .success64
    xor     eax, eax
    pop     ebp
    pop     ebx
    ret

.success64:
    mov     eax, 1
    pop     ebp
    pop     ebx
    ret

避坑指南:我曾经在写一个无锁队列时,用 CMPXCHG8B 来原子更新头指针和计数器。结果发现,如果目标地址没有8字节对齐,指令会触发 #GP(通用保护异常)。所以,用这些指令前,一定要确保内存对齐。我的习惯是在结构体定义里加 __attribute__((aligned(8)))__declspec(align(8))

4.5 CAS的ABA问题

说到CAS,就绕不开一个经典问题——ABA问题。你想想看,CAS只比较值是否相等,但如果一个值从A变成B,又变回A,CAS会认为它没变过。这在某些场景下会出大问题。

举个例子:

  1. 线程1读取指针值为 0x1000(节点A)
  2. 线程2把节点A释放了,分配了新节点B(地址也是 0x1000
  3. 线程2又把节点B释放了,分配了新节点C(地址还是 0x1000
  4. 线程1执行CAS,发现值还是 0x1000,以为没变,直接更新

结果呢?线程1操作的是已经被释放过的内存,数据早就不是原来的了。

怎么解决?我常用的方法是带标签的指针。就是在指针的高位加一个版本号或标签,每次修改都递增标签。这样即使地址相同,标签不同,CAS也能识别出来。

// 带标签的指针示例(32位系统)
typedef struct {
    uint32_t ptr;    // 实际指针(低30位)
    uint32_t tag;    // 标签(高2位)
} tagged_ptr_t;

// 使用CMPXCHG8B原子更新64位的指针+标签组合
int cas_tagged(tagged_ptr_t* target, tagged_ptr_t expected, tagged_ptr_t new_val) {
    // 将两个32位值打包成64位,用CMPXCHG8B操作
    // 具体实现略...
}

总结一下:CAS是x86给我们的原子操作利器,但用的时候要小心。记住三点:

  • 多核系统必须加 LOCK 前缀
  • 64位/128位CAS需要内存对齐
  • 注意ABA问题,必要时用带标签的指针

好了,这一章的内容就到这儿。下一章咱们聊聊ARM架构上的原子操作,你会发现思路完全不同——ARM用的是LL/SC(Load-Linked/Store-Conditional)机制。嗯,到时候再细说。