4、x86的答案:Compare-And-Swap (CAS) 指令详解与汇编实战
好,咱们接着聊。上一章我讲了原子操作的基本概念,也提到了x86平台上的关键指令。今天咱们就深入看看x86给出的核心答案——Compare-And-Swap (CAS)。说白了,CAS就是无锁编程的基石,你想想看,几乎所有的高性能并发组件,底层都离不开它。
我个人习惯把CAS叫做「硬件级的乐观锁」。为什么?因为它不直接阻塞线程,而是先尝试操作,失败了再重试。这种思路,在RTOS内核里特别实用。
4.1 什么是CAS?
CAS的全称是 Compare And Swap,翻译过来就是「比较并交换」。它做的事情很简单:
- 读取内存中的某个值,记为
old - 把这个
old和一个期望值expected做比较 - 如果相等,就把一个新值
new写入这个内存地址 - 如果不相等,什么都不做
整个过程是原子完成的,中间不会被任何中断或其它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读取指针值为
0x1000(节点A) - 线程2把节点A释放了,分配了新节点B(地址也是
0x1000) - 线程2又把节点B释放了,分配了新节点C(地址还是
0x1000) - 线程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)机制。嗯,到时候再细说。