go源码中 CAS 是如何实现的?

67 阅读2分钟

点进 atomic包 看到CompareAndSwapInt64 只定义了一个空方法 那么具体是怎么实现的呢?

package atomic
// 源码路径 /sync/atomic/doc.go
import (
    "unsafe"
)

// CompareAndSwapInt64 executes the compare-and-swap operation for an int64 value.
func CompareAndSwapInt64(addr *int64, old, new int64) (swapped bool)

// 省略 其他方法 ...

仔细观察 会发现 同级目录下 定义着一个名为asm.s的汇编文件


// Copyright 2011 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.

//go:build !race

#include "textflag.h"


TEXT ·CompareAndSwapInt64(SB),NOSPLIT,$0
        JMP        runtime/internal/atomic·Cas64(SB)

在汇编代码中,又调用了 runtime/internal/atomic·Cas64(SB)

紧接着 我们来到 runtime/internal 包 找寻对应的实现

发现在 runtime/internal/atomic文件夹下 有很多组 atomic_xxx.go 和 atomic_xxx.s 文件 例如

  • atomic_amd64.go 和 atomic_amd64.s 为 amd64架构下的实现

  • atomic_arm.go 和 atomic_arm.s 为 arm架构下的实现

  • atomic_arm64.go 和 atomic_arm64.s 为 arm64架构的实现

  • 其他架构下的实现 ...

我们重点看 amd架构下的具体实现 atomic_amd64.s 中的 Cas64方法

以下的汇编代码 使用的Plan9汇编

// Atomically:
//        if(*val == old){
//                *val = new;
//                return 1;
//        } else {
//                return 0;
//        }
TEXT ·Cas64(SB), NOSPLIT, $0-25
        MOVQ        ptr+0(FP), BX  // 将第一个参数(addr *int64) 移动到虚拟寄存器 BX
        MOVQ        old+8(FP), AX  // 将第二个参数(old int64)   移动到虚拟寄存器 AX
        MOVQ        new+16(FP), CX // 将第三个参数(new int64)   移动到虚拟寄存器 CX
        // 在CPU的LOCK信号被声明之后,随后执行的指令会转换成原子指令。
        // 在多处理器环境中,LOCK信号确保,在此信号被声明之后,处理器独占使用任何共享内存
        LOCK
        // 将AX寄存器中的 64 位值与 BX 中的值进行比较
        // 如果这两个值相等,那么就将CX寄存器中的 64 位值存入BX内存操作数所指向的内存位置
        // 如果不相等,那么就将BX内存操作数中的 64 位原始值读取到AX寄存器中,
        // 并且会根据比较结果设置处理器的状态标志位,如零标志位ZF(ZF = 1表示比较相等,ZF = 0表示比较不等)
        // 其中的Q表示操作数的大小为四字(Quad - word),即 64 位         
        CMPXCHGQ     CX, 0(BX)
        // 执行SETEQ指令时,它会检查零标志位ZF的值。
        // 如果ZF = 1,说明 old = *addr
        // 如果ZF = 0,说明 old != *addr
        SETEQ        ret+24(FP) // +24正好就是返回值对应的地址
        RET

第一行 包含函数名,栈帧大小,参数返回值大小等信息


                           参数及返回值大小
                                  | 
 TEXT pkgname·add(SB),NOSPLIT,$0-25
       |        |              |
      包名    函数名          栈帧大小

NOSPLIT是一个标志,用于告知编译器这个函数不需要进行栈帧分割操作。在 Go 语言的函数调用过程中,栈帧分割是一种用于管理函数调用栈空间的机制。正常情况下,当一个函数被调用时,可能会对栈帧进行调整,以适应函数内部局部变量的存储、参数传递等操作。

使用NOSPLIT标志的函数通常是比较简单的函数,例如一些短小的内联函数或者性能敏感的函数。这些函数通过避免栈帧分割操作,可以节省一些函数调用的开销。例如,一些简单的数学运算函数或者只是简单地从寄存器获取数据并返回的函数,使用NOSPLIT可以使函数执行更加高效。

  • 包名: 可以省略

  • 函数名: 对应的xx.go文件中的函数名

  • 栈帧大小: (局部变量+可能需要的额外调用函数的参数空间的总大小,不包括调用其它函数时的 ret address 的大小) 该方法没有调用其他函数,也没有引入额外的局部变量 所以是0

  • 参数及返回值大小: 25具体组成为 8(addr *int64) + 8(old int64) + 8(new int64) + 1(swapped bool)

plan9 伪寄存器介绍

Go 汇编中引入了 4 个伪寄存器,官方文档的描述为:

  • FP: Frame pointer: arguments and locals.
  • PC: Program counter: jumps and branches.
  • SB: Static base pointer: global symbols.
  • SP: Stack pointer: the highest address within the local stack frame.
  • FP: 使用方式为symbol+offset(FP),用于引用函数的输入参数。例如 addr+0(FP)old+8(FP)
  • PC: 即 pc 寄存器,amd64平台对应于rip寄存器, x86平台对应于 ip寄存器
  • SB: 全局静态基指针,常声明函数或全局变量
  • SP: plan9 中的SP 寄存器指向当前栈帧的局部变量的开始位置,通过 symbol+offset(SP) 的方式,引用函数的局部变量。offset 的合法取值是 [-framesize, 0),为左闭右开区间

自己编写go代码如何调用自己写的汇编呢?

比如你想原子的load 128位的变量,你发现使用汇编指令 CMPXCHG16B可以实现该功能

首先编写对应的go文件 声明方法

//go:build amd64

package test

type Int128 [2]int64

func LoadInt128(addr *Int128) (val Int128)

对应的汇编实现为

// +build amd64

#include "textflag.h"

TEXT ·LoadInt128(SB),NOSPLIT,$0
        MOVQ addr+0(FP), R8
        XORQ AX, AX
        XORQ DX, DX
        XORQ BX, BX
        XORQ CX, CX
        LOCK
        CMPXCHG16B (R8)
        MOVQ AX, val_0+8(FP)
        MOVQ DX, val_1+16(FP)
        RET

这样你可以正常使用 方法 LoadInt128啦~