CAS 在 Go 语言中的实现
什么是 CAS ?
CAS (Compare And Swap) 是原子操作的一种,通常用于多线程(协程)环境下对某个值进行原子更改。它的功能是判断内存中某个变量当前的值是否是我们预期的值,如果是我们预期的值则将其更新成我们指定的新值,否则就保持当前值。以 CAS(v, expected, newV)
为例:
Go 语言中许多并发原语都是依靠信号量和原子操作实现的。下面是互斥锁*Mutex.Lock
方法中使用 CAS atomic.CompareAndSwapInt32
的示例:
go
func (m *Mutex) Lock() {
// 如果此时m.state == 0,则说明锁未被占用,获取锁(将锁的状态改为mutexLocked)
if atomic.CompareAndSwapInt32(&m.state, 0, mutexLocked) {
// ...
return
}
// ...
}
从上面的流程图中可以看出 CAS 实际由两个阶段组成:1. Compare , 比较变量当前值和我们预期的值是否相等,若相等进入步骤2;2. Swap, 将变量赋值为新值。
接触过多线程(协程)的同学应该知道,步骤1和步骤2本身就不是并发安全的,更何况 CAS 由两个步骤组成。我们通常使用锁机制来保证并发安全,但是互斥锁的实现又使用了 CAS ,这岂不是死循环了?实际上 CAS 是一种非常底层的并发原语,需要使用特定的 CPU 指令实现。不同的 CPU 架构(具体来说是指令集)提供了不同的指令,如 amd64 主要使用 LOCK
和 COMXCHG
指令来实现 CAP。
Go 语言通过 sync/atomic
包向用户暴露了各种原子操作的方法,但具体的实现在 runtime
包中。如图 1 所示,Go语言在 runtime
中实现了基于 amd64
, arm
, loong64
, mips64
等架构的原子操作, CAS 也包括其中(本文基于 Go 1.16 版本):
图 1. Go 语言基于不同 CPU 架构的原子操作实现,runtime/internal/atomic
以 atomic.CompareAndSwapInt32 为例
以 amd64
架构下的 atomic.CompareAndSwapInt32
方法为例介绍 CAS 的实现。下面是此方法在 sync/atomic/asm.s
中的汇编代码:
assembly
TEXT ·CompareAndSwapInt32(SB),NOSPLIT,$0
JMP runtime/internal/atomic·Cas(SB)
Go汇编中函数定义的语法如下:
TEXT symbol(SB), [flags,] $framesize[-argsize]
函数的定义由5个部分组成:
TEXT
:Go汇编语言中的指令,用于定义一个函数;symbol(SB)
:symbol
是包路径·函数名,包路径可以省略。SB
寄存器的值是代码段的起始位置,symbol(SB)
用于获取此函数在代码段中的起始位置,即函数的入口;flags
:可选,指示函数的一些特殊行为,NOSPLIT 表示 "Don't insert stack check preamble.";$framesize
:函数的栈帧大小;-argsize
:可选,此函数参数和返回值的大小。
根据Go汇编中函数定义的语法,上面的汇编代码定义了 atomic.CompareAndSwapInt32
方法,方法体内只有一条 JMP 指令,跳转到 runtime/internal/atomic·Cas
方法处继续执行。
下面是 Cas 方法的伪代码和汇编代码,代码位于 runtime/internal/atomic/atomic_amd64.s
中:
assembly
// bool Cas(int32 *ptr, int32 old, int32 new)
// Atomically:
// if(*ptr == old){
// *ptr = new;
// return 1;
// } else
// return 0;
TEXT ·Cas(SB),NOSPLIT,$0-17
MOVQ ptr+0(FP), BX
MOVL old+8(FP), AX
MOVL new+12(FP), CX
LOCK
CMPXCHGL CX, 0(BX)
SETEQ ret+16(FP)
RET
代码的上半部分注释是 Cas 的伪代码,针对 int32 类型进行比较和替换。下半部分是真正的汇编代码,首先看一下函数定义:
assembly
TEXT ·Cas(SB),NOSPLIT,$0-17
Cas 函数接收3个参数:8字节的 *int32
类型 ptr,4字节的 int32
类型 old,4字节的 int32
类型new;返回值为1字节的 bool
类型。因此 Cas 方法参数和返回值的大小为 8 + 4 + 4 + 1 = 17 字节,于是在函数定义中 argsize
的大小为17。
Go汇编使用的是 caller-save 模式,被调用函数的入参参数、返回值都由调用者准备、维护。因此当需要调用一个函数时,调用方 caller 需要先将这些工作准备好,才调用下一个函数。假设由函数 func1 调用 Cas,下图是程序运行过程中的栈帧布局:
图 2. func1 调用 Cas 方法的栈帧布局
为了简化汇编代码的编写,Go汇编引入了 FP
伪寄存器,用于在汇编中更加简便地寻址参数和返回值。FP
的值表示第一个参数的地址,在图2中就表示参数 ptr 的地址。Go汇编语法要求,通过 FP
伪寄存器访问变量时必须有一个标识符前缀,一般使用参数对应的变量名作为前缀(伪寄存器编译后都会转成真实的物理寄存器,不会在最终生成的汇编代码中有任何伪寄存器)。因此汇编代码中的前3行的作用是寻址参数将其复制到特定寄存器中:
assembly
MOVQ ptr+0(FP), BX // 将ptr的值复制到BX寄存器,MOVQ中的Q表示操作数宽度为8字节
MOVL old+8(FP), AX // 将old的值复制到AX寄存器,MOVL中的L表示操作数宽度为4字节
MOVL new+12(FP), CX // 将new的值复制到CX寄存器,MOVL中的L表示操作数宽度为4字节
Go汇编的风格和常见的Intel风格、AT&T风格不太一样:
- 操作数的宽度:Intel汇编中使用AX、EAX、RAX不同的寄存器名称表示操作数的宽度;Go汇编使用指令的后缀表示操作数的宽度,W代表16位,L代表32位,Q表示64位;
- 操作数顺序:Intel汇编中源操作数在后,目的操作数在前;Go汇编中源操作数在前,目的操作数在后;
- 地址表示:Intel汇编中的[ESP + EBX * 2 + 16],在Go汇编中表示为16(SP)(BX * 2)
接下来是 LOCK
指令:
assembly
LOCK
参考 AMD64 Architecture Programmer's Manual Volume 3:General-Purpose and System Instructions(亦可参考Intel的技术手册),LOCK
指令的介绍如下:
LOCK prefix:
The LOCK prefix causes certain kinds of memory read-modify-write instructions to occur atomically.
译:LOCK前缀使得某些类型的内存读-修改-写指令以原子方式发生。
The prefix is intended to give the processor exclusive use of shared memory in a multiprocessor system.
译:LOCK前缀旨在使某个处理器在多处理器系统中独占共享内存。
The LOCK prefix can only be used with forms of the following instructions that write a memory operand: ADC, ADD, AND, BTC, BTR, BTS, CMPXCHG, CMPXCHG8B, CMPXCHG16B, DEC, INC, NEG, NOT, OR, SBB, SUB, XADD, XCHG, and XOR.
译:LOCK前缀只能与以下写入内存操作数的指令形式一起使用:ADC, ADD, AND, BTC, BTR, BTS, CMPXCHG, CMPXCHG8B, CMPXCCHG16B, DEC, INC, NEG, NOT, OR, SBB, SUB, XADD, XCHG和XOR。
LOCK
指令使得接下来的 COMXCHGL
指令独占共享内存,以原子的形式完成。COMXCHGL
指令的作用就是比较(COM)并交换(XCHG):
CMPXCHG (Compare and Exchange):
Compares the value in the AL, AX, EAX, or RAX register with the value in a register or a memory location (first operand). If the two values are equal, the instruction copies the value in the second operand to the first operand and sets the ZF flag in the rFLAGS register to 1. Otherwise, it copies the value in the first operand to the AL, AX, EAX, or RAX register and clears the ZF flag to 0.
译:将AL、AX、EAX或RAX寄存器中的值与寄存器或内存位置(第一个操作数)中的值进行比较。如果两个值相等,则指令将第二个操作数中的值复制到第一个操作数,并将rFLAGS寄存器中的ZF标志设置为1。否则,将第一个操作数中的值拷贝到AL、AX、EAX或RAX寄存器,并将ZF标志清除为0。
assembly
LOCK // 使得接下来的COMXCHGL指令独占共享内存,以原子的形式完成
CMPXCHGL CX, 0(BX)
/*
if AX == [BX] { // 比较 *ptr和old
ZF = 1 // 记录比较结果
[BX] = CX // 如果相等则令*ptr = new
} else { // 如果不相等保持*ptr不变
ZF = 0 // 记录比较结果
AX = [BX] // side effect
}
*/
接下来的 SETEQ
指令会读取 ZF
寄存器的值,如果 ZF
寄存器的值为1,则将返回值 swapped
置为1 (true),反之置为0 (false)。最后是 RET
指令用于函数返回,至此 func1 调用 atomic.CompareAndSwapInt32
的流程结束(实际上调用的是 runtime 下的 Cas 方法):
assembly
SETEQ ret+16(FP)
RET
总结
Go 语言针对不同的 CPU 架构,使用 Plan9 汇编调用相应的 CPU 指令,实现了对 CAS 操作的支持。其实除了 CAS,Go 语言对一些如 Store, Add 等其他原子操作的支持都是使用类似的方式实现的。
ABI
- 函数调用栈的布局属于 abi 范畴的知识,Go 在 1.17 版本之后提供了 abi 文件,位置位于源码
src/cmd/compile/internal-abi.md
。也是在 1.17 版本 Go 开始使用寄存器传参,之前是栈传参; - Plan9 汇编可以参考 Rob Pike 编写的 A Manual for the Plan 9 assembler 以及官方文档中 A Quick Guide to Go's Assembler.
最后
I do and I understand.