一文搞定 Go 原子操作计算机科学中的原子操作,包括其定义、特点、应用、与锁的区别,以及在 Go 语言中的实现原理和相关
什么是原子操作
在计算机科学中,原子操作指的是一个或一系列不可被中断的操作。也就是说,这些操作在执行过程中不会被分割成更小的部分,也不会被其他操作干扰或中断。
原子操作的特点
原子操作具有以下几个重要特点:
-
完整性:原子操作要么完全执行,要么完全不执行,不会出现执行到一半的情况。
-
不可分割性:不能被其他操作打断,整个操作作为一个独立的、不可分割的单元执行。
-
原子性:保证操作的原子性是为了确保数据的一致性和正确性。例如,在对一个共享变量进行修改时,如果这个修改操作不是原子的,可能会导致数据的不一致性。
原子操作的应用
原子操作在多线程编程和并发环境中非常重要。在多个线程同时访问和修改共享资源时,使用原子操作可以避免出现竞争条件和数据不一致的问题。常见的原子操作包括原子的变量赋值、原子的计数器递增或递减等。
例如,在一个多线程的计数器程序中,如果没有使用原子操作来增加计数器的值,可能会出现多个线程同时修改计数器导致结果错误的情况。而使用原子操作可以确保计数器的值在任何时候都是正确的。
package main
import (
"fmt"
"sync"
"sync/atomic"
)
func main() {
var sum1 int32 = 0
var sum2 int32 = 0
var wg sync.WaitGroup
wg.Add(1000)
for i := 0; i < 1000; i++ {
go func() {
defer wg.Done()
sum1++
atomic.AddInt32(&sum2, 1)
}()
}
wg.Wait()
fmt.Println("非原子操作:", sum1)
fmt.Println("原子操作:", sum2)
}
$ go run main.go
非原子操作:989
原子操作:1000
原子操作与锁的区别
原子操作和互斥锁均可用于在并发环境中保护共享资源,不过它们在应用场景、实现机制、性能等方面存在一定的差异:
差异点 | 原子操作 | 互斥锁 |
---|---|---|
应用场景 | 适用于对单个变量或简单数据结构的操作,尤其是在高并发场景下需要频繁进行的简单操作。 | 适用于对复杂数据结构或一段代码块的同步,当需要确保一组操作的原子性和一致性时,互斥锁更为合适。例如,对一个链表的插入和删除操作,或者对多个变量的同时修改。 |
实现机制 | 通过底层硬件指令实现,不需要复杂的同步逻辑。 | 通常基于信号量、原子操作、线程调度等一系列复杂操作实现的。 |
性能 | 通常性能较高,因为它们直接在硬件层面实现,避免了上下文切换和线程阻塞的开销。 | 相对原子操作性能较低。当多个线程竞争锁时,会导致线程阻塞和唤醒,这会带来一定的开销。 |
Go 原子操作
Go 原子操作是通过 sync/atomic 包实现的,主要包括了 Add、CompareAndSwap、Swap、Load、Store 等原子操作,go 1.23 还引入 And、Or 操作。
AddT操作
AddT是一系列函数的集合,可以操作int32、int64、uint32、uint64、uintptr类型的变量,其中 int32 和 int64 可以是负数,如果是负数就能实现相减的效果。
func AddInt32(addr *int32, delta int32) (new int32)
func AddInt64(addr *int64, delta int64) (new int64)
func AddUint32(addr *uint32, delta uint32) (new uint32)
func AddUint64(addr *uint64, delta uint64) (new uint64)
func AddUintptr(addr *uintptr, delta uintptr) (new uintptr)
AddT 操作与下面的伪代码是等效,但是需要把下面代码当成一个原子的来看:
func AddT(addr *T, delta T) (new T) {
*addr += delta
return *addr
}
CompareAndSwapT 操作
CompareAndSwapT 字面含义是比较并交换,在Go的 sync 包中有广泛的应用,常用来做有条件的更新操作,有点类似数据库操作中的乐观锁。
CompareAndSwapT 同样也是 CompareAndSwap 的函数集:
func CompareAndSwapInt32(addr *int32, old, new int32) (swapped bool)
func CompareAndSwapInt64(addr *int64, old, new int64) (swapped bool)
func CompareAndSwapPointer(addr *unsafe.Pointer, old, new unsafe.Pointer) (swapped bool)
func CompareAndSwapUint32(addr *uint32, old, new uint32) (swapped bool)
func CompareAndSwapUint64(addr *uint64, old, new uint64) (swapped bool)
func CompareAndSwapUintptr(addr *uintptr, old, new uintptr) (swapped bool)
CompareAndSwapT 的伪代码如下:
-
如果 *addr 与 old 相等,则把 new 赋值给 *addr, 并返回 true,代表交换成功;
-
如果不相等则直接返回 false,代表没有进行交换。
func CompareAndSwapT(addr *T, old, new T) (swapped bool)
if *addr == old {
*addr = new
return true
}
return false
}
LoadT 操作
原子性的读取 addr 指向的数据,能保证读取 addr 指向的数据时,没有其他程序读取 addr 指向的数据。
func LoadInt32(addr *int32) (val int32)
func LoadInt64(addr *int64) (val int64)
func LoadPointer(addr *unsafe.Pointer) (val unsafe.Pointer)
func LoadUint32(addr *uint32) (val uint32)
func LoadUint64(addr *uint64) (val uint64)
func LoadUintptr(addr *uintptr) (val uintptr)
LoadT的等效伪代码如下:
func LoadT(addr *T) (val T) {
return *addr
}
StoreT 操作
Store 可以将 val 值原子性的保存到 *addr 中。
func StoreInt32(addr *int32, val int32)
func StoreInt64(addr *int64, val int64)
func StorePointer(addr *unsafe.Pointer, val unsafe.Pointer)
func StoreUint32(addr *uint32, val uint32)
func StoreUint64(addr *uint64, val uint64)
func StoreUintptr(addr *uintptr, val uintptr
StoreT 的伪代码:
func StoreT(addr *T, val T) {
*addr = val
}
SwapT 操作
SwapT 以原子方式将新值存储到 addr 中并返回先前的 addr 值,同样 SwapT 也是一系列函数的合集:
func SwapInt32(addr *int32, new int32) (old int32)
func SwapInt64(addr *int64, new int64) (old int64)
func SwapPointer(addr *unsafe.Pointer, new unsafe.Pointer) (old unsafe.Pointer)
func SwapUint32(addr *uint32, new uint32) (old uint32)
func SwapUint64(addr *uint64, new uint64) (old uint64)
func SwapUintptr(addr *uintptr, new uintptr) (old uintptr)
SwapT 的伪代码:
func SwapT(addr *T, new T) (old T)
old = *addr
*addr = new
return old
}
AndT 操作
AndT 是 go1.23 新添加的特性,用来实现原子的按位与运算:
func AndInt32(addr *int32, mask int32) (old int32)
func AndInt64(addr *int64, mask int64) (old int64)
func AndUint32(addr *uint32, mask uint32) (old uint32)
func AndUint64(addr *uint64, mask uint64) (old uint64)
func AndUintptr(addr *uintptr, mask uintptr) (old uintptr)
AndT 的伪代码:
func AndT(addr *T, mask T) (old T) {
old = *addr
*addr = *addr & mask
return old
}
OrT 操作
OrT 同样是 go1.23 新添加的特性,用来实现原子的按位或运算:
func OrInt32(addr *int32, mask int32) (old int32)
func OrInt64(addr *int64, mask int64) (old int64)
func OrUint32(addr *uint32, mask uint32) (old uint32)
func OrUint64(addr *uint64, mask uint64) (old uint64)
func OrUintptr(addr *uintptr, mask uintptr) (old uintptr)
OrT 的伪代码:
func OrT(addr *T, mask T) (old T) {
old = *addr
*addr = *addr | mask
return old
}
Go 原子操作实现原理
下面的内容会涉及汇编的相关知识,不了解汇编的小伙伴可以阅读《Go 语言进阶秘籍——Plan9 汇编》
上面提到的原子操作是基于硬件指令实现的,我们在 src/internal/runtime/atomic 目录中可以找到具体的汇编代码,Go 是跨平台的编程语言,不同的平台有不同的实现:
其中 atomic_386.go 、 atomic_386.s 是 386 平台原子操作函数的定义与实现,这些原子操作的核心是 Cas 、Xadd、Xchg、Store 、Load 、 And、 Or 等几个基本函数。
Cas 函数
Cas 函数是比较并交换的汇编实现,其中最核心的 LCOK 指令:
TEXT ·Cas(SB), NOSPLIT, $0-13
MOVL ptr+0(FP), BX
MOVL old+4(FP), AX
MOVL new+8(FP), CX
LOCK
CMPXCHGL CX, 0(BX)
SETEQ ret+12(FP)
RET
-
MOVL ptr+0(FP), BX:将栈帧指针 FP 加上偏移量 0 处的值(ptr指向的值)移动到寄存器 BX 中。
-
MOVL old+4(FP), AX:将 FP 加上偏移量 4 处的值(old的值)移动到寄存器 AX 中,可能是一个旧值。
-
MOVL new+8(FP), CX:将 FP 加上偏移量 8 处的值(new的值)移动到寄存器 CX 中,可能是一个新值。
-
LOCK:是一个指令前缀,其后必须跟一条“读-改-写”性质的指令,它们可以是ADD, ADC, AND, BTC, BTR, BTS, CMPXCHG, CMPXCH8B, CMPXCHG16B, DEC, INC, NEG, NOT, OR, SBB, SUB, XOR, XADD, XCHG。该指令是一种锁定协议,用于封锁总线,禁止其他 CPU 对内存的操作来保证原子性。
-
CMPXCHGL CX, 0(BX):CMPXCHGL,L代表4个字节。该指令会把AX(累加器寄存器)中的内容(old)和第二个操作数(0(BX))中的内容(ptr所指向的数据)比较。如果相等,则把第一个操作数(CX)中的内容(new)赋值给第二个操作数,同时将 ZF(零标志位)置为 1。
-
SETEQ ret+12(FP):如果零标志位为 1(即比较并交换成功),将栈帧指针 FP 加上偏移量 12 处的值设置为 1(可能是一个返回值的存储位置),否则为 0。
-
RET:从函数返回。
Xadd 函数
Xadd 函数实现了一个原子性的加法操作并返回结果。
TEXT ·Xadd(SB), NOSPLIT, $0-12
MOVL ptr+0(FP), BX
MOVL delta+4(FP), AX
MOVL AX, CX
LOCK
XADDL AX, 0(BX)
ADDL CX, AX
MOVL AX, ret+8(FP)
RET
-
MOVL ptr+0(FP), BX:将栈帧指针 FP 加上偏移量 0 处的值(即参数 ptr)移动到寄存器 BX 中。这里假设 BX 是一个 32 位寄存器,用于存储指针。
-
MOVL delta+4(FP), AX:将栈帧指针 FP 加上偏移量 4 处的值(即参数 delta)移动到寄存器 AX 中。这里假设 AX 是一个 32 位寄存器,用于存储增量值。
-
MOVL AX, CX:将寄存器 AX 的值复制到寄存器 CX 中。
-
LOCK:指令前缀,用于确保后续的指令执行是原子性的。
-
XADDL AX, 0(BX):对寄存器 AX 和内存地址为 BX 指向的地址处的值进行原子性的加法操作,并将结果存储回内存地址中。这里 BX 是指针,指向要进行加法操作的内存位置。
-
ADDL CX, AX:将寄存器 CX(保存着原始的增量值)和寄存器 AX(保存着加法操作后的结果)的值相加。这一步可能是为了计算最终的返回值,但具体目的可能取决于上下文。
-
MOVL AX, ret+8(FP):将寄存器 AX 的值移动到栈帧指针 FP 加上偏移量 8 处,可能是作为返回值的存储位置。
-
RET:从函数返回。
Xchg 函数
Xchg 函数实现了一个原子性的交换操作。它接受一个指向特定数据类型的指针 ptr 和一个新值 new,将 ptr 所指向的内存位置的值与 new 进行原子性交换,并返回原始内存位置的值。
TEXT ·Xchg(SB), NOSPLIT, $0-12
MOVL ptr+0(FP), BX
MOVL new+4(FP), AX
XCHGL AX, 0(BX)
MOVL AX, ret+8(FP)
RET
-
MOVL ptr+0(FP), BX:将栈帧指针 FP 加上偏移量 0 处的值(即参数 ptr)移动到寄存器 BX 中。这里 BX 将用于存储指向要进行交换操作的内存地址的指针。
-
MOVL new+4(FP), AX:将栈帧指针 FP 加上偏移量 4 处的值(即参数 new)移动到寄存器 AX 中。这里 AX 存储着要与内存位置进行交换的新值。
-
XCHGL AX, 0(BX):执行原子性的交换操作。将寄存器 AX 中的值与内存地址为 BX 指向的地址处的值进行交换。这里 BX 是指针,指向目标内存位置。
-
MOVL AX, ret+8(FP):将寄存器 AX 的值(即交换操作后原始内存位置的值)移动到栈帧指针 FP 加上偏移量 8 处,可能是作为返回值的存储位置。
-
RET:从函数返回。
Store 函数
TEXT ·Store(SB), NOSPLIT, $0-8
MOVL ptr+0(FP), BX
MOVL val+4(FP), AX
XCHGL AX, 0(BX)
RET
-
MOVL ptr+0(FP), BX:将栈帧指针 FP 加上偏移量 0 处的值(ptr 指向的值)移动到寄存器 BX 中。
-
MOVL val+4(FP), AX:将 FP 加上偏移量 4 处的值(val的值)移动到寄存器 AX 中。
-
XCHGL AX, 0(BX):交换指令,将寄存器 AX 中的值与内存地址为 BX + 0 处的值进行交换。这样就实现了将 AX 中的值存储到由 BX 指向的内存位置,同时原先内存中的值被加载到 AX 中。
-
RET:从函数返回。
Load 函数
func Load(ptr *uint32) uint32 {
return *ptr
}
Load 函数不是汇编实现的,但是真正执行时还会被编译成汇编后执行,底层是 MOV 指令:
func main() {
var a int32 = 1
b := atomic.LoadInt32(&a)
fmt.Println(b)
}
& go build -gcflags="-N -l -S" main.go
// ...
MOVD main.&a-8(SP), R1 // 将 a 的地址保存到 R1 寄存器中
LDARW (R1), R1 // 将 a 的值保存到 R1 寄存器中
MOVW R1, main.b-68(SP) // R1 寄存器中的值保存到 b 中
And 函数
Add 函数实现了一个按位与(And)操作。它接受一个指向 uint32 类型的指针 addr 和一个 uint32 类型的值 v,将 addr 所指向的内存位置的值与 v 进行按位与操作,并将结果存储回 addr 所指向的内存位置。
// func And(addr *uint32, v uint32)
TEXT ·And(SB), NOSPLIT, $0-8
MOVL ptr+0(FP), AX
MOVL val+4(FP), BX
LOCK
ANDL BX, (AX)
RET
-
MOVL ptr+0(FP), AX:将栈帧指针 FP 加上偏移量 0 处的值(即参数 addr)移动到寄存器 AX 中。这里假设 AX 是一个 32 位寄存器。
-
MOVL val+4(FP), BX:将栈帧指针 FP 加上偏移量 4 处的值(即参数 v)移动到寄存器 BX 中。
-
LOCK:这是一个指令前缀,用于确保后续的指令执行是原子性的,防止在多处理器环境下出现并发访问问题。
-
ANDL BX, (AX):对寄存器 BX 和 AX 所指向的内存位置的值进行按位与操作,并将结果存储回 AX 所指向的内存位置。这里,AX 中存储着参数 addr,即指向目标内存位置的指针,BX 中存储着参数 v。
-
RET:从函数返回。
Or 函数
这个函数实现了一个按位或(Or)操作。它接受一个指向 uint32 类型的指针 addr 和一个 uint32 类型的值 v,将 addr 所指向的内存位置的值与 v 进行按位或操作,并将结果存储回 addr 所指向的内存位置。
// func Or(addr *uint32, v uint32)
TEXT ·Or(SB), NOSPLIT, $0-8
MOVL ptr+0(FP), AX
MOVL val+4(FP), BX
LOCK
ORL BX, (AX)
RET
-
MOVL ptr+0(FP), AX:将栈帧指针 FP 加上偏移量 0 处的值(即参数 addr)移动到寄存器 AX 中。这里假设 AX 是一个 32 位寄存器。
-
MOVL val+4(FP), BX:将栈帧指针 FP 加上偏移量 4 处的值(即参数 v)移动到寄存器 BX 中。
-
LOCK:这是一个指令前缀,用于确保后续的指令执行是原子性的,防止在多处理器环境下出现并发访问问题。
-
ORL BX, (AX):对寄存器 BX 和 AX 所指向的内存位置的值进行按位或操作,并将结果存储回 AX 所指向的内存位置。这里,AX 中存储着参数 addr,即指向目标内存位置的指针,BX 中存储着参数 v。
-
RET:从函数返回。
原子类型
Atomic 除了提供了原子操作函数外,还提供了原子操作类型,但是这些原子类型底层还是基于原子操作函数实现的,这里不详细展开。
type Bool struct {
_ noCopy
v uint32
}
// Load atomically loads and returns the value stored in x.
func (x *Bool) Load() bool { return LoadUint32(&x.v) != 0 }
// Store atomically stores val into x.
func (x *Bool) Store(val bool) { StoreUint32(&x.v, b32(val)) }
// Swap atomically stores new into x and returns the previous value.
func (x *Bool) Swap(new bool) (old bool) { return SwapUint32(&x.v, b32(new)) != 0 }
// CompareAndSwap executes the compare-and-swap operation for the boolean value x.
func (x *Bool) CompareAndSwap(old, new bool) (swapped bool) {
return CompareAndSwapUint32(&x.v, b32(old), b32(new))
}
小结
-
Cas 函数:LOCK + CMPXCHGL 指令
-
Xadd 函数:LOCK + ADD 指令
-
Xchg 函数:LOCK + XCHGL 指令
-
Store 函数:XCHGL 指令
-
Load 函数: MOV 指令
-
Add 函数:LOCK + AND 指令
-
Or 函数:LOCK + OR 指令
源码中的原子操作
sync.Once
sync.Once 中使用 Load() 原子操作,判断是否执行过,但是需要注意的 Load() 是原子的,但是 if 判断语句并不是原子的,所以在 doSlow 里面还需要加锁。外面使用 Load() 是为了提高性能。
func (o *Once) Do(f func()) {
if o.done.Load() == 0 {
o.doSlow(f)
}
}
func (o *Once) doSlow(f func()) {
o.m.Lock()
defer o.m.Unlock()
if o.done.Load() == 0 {
defer o.done.Store(1)
f()
}
}
sync.Mutex
sync.Mutex 使用 CompareAndSwapInt32 来判断有没有加锁, CompareAndSwapInt32 比 Load 功能更强大,不大能判断状态还能原子性的更变状态。
func (m *Mutex) Lock() {
if atomic.CompareAndSwapInt32(&m.state, 0, mutexLocked) {
if race.Enabled {
race.Acquire(unsafe.Pointer(m))
}
return
}
m.lockSlow()
}
sync.RWMutex
func (rw *RWMutex) RLock() {
if race.Enabled {
_ = rw.w.state
race.Disable()
}
if rw.readerCount.Add(1) < 0 {
// A writer is pending, wait for it.
runtime_SemacquireRWMutexR(&rw.readerSem, false, 0)
}
if race.Enabled {
race.Enable()
race.Acquire(unsafe.Pointer(&rw.readerSem))
}
}
在 Go 的同步原语的源码中有很多都是基于原子操作的,可以说原子操作是同步原语的基石。
任意类型的原子操作
前面所介绍的原子操作仅能对int32、int64、uint32、uint64、uintptr、unsafe.Pointer这些类型的值进行操作。然而在实际的开发过程中,我们所涉及的类型众多,例如string、struct等等,那么对于这些类型的值,应如何进行原子操作呢?答案是运用atomic.Value。
atomic.Value 支持以下操作:
-
Load:原子性的读取 Value 中的值。
-
Store:原子性的存储一个值到 Value 中。
-
Swap:原子性的交换 Value 中的值,返回旧值。
-
CompareAndSwap:原子性的比较并交换 Value 中的值,如果旧值和 old 相等,则将 new 存入 Value 中,返回 true,否则返回 false。
atomic.Value 是一个结构体,它的内部有一个 any 类型的字段,存储了我们要原子操作的值,也就是一个任意类型的值。
type Value struct {
v any
}
在对 atomic.Value 进行原子操作时,会将 v any 转换为 efaceWords 类型。efaceWords 具有 typ 和 data 两个字段,它们都是 unsafe.Pointer 类型, unsafe.Pointer 类型是支持原子操作的,atomic.Value 的原理就是对 typ 和 data 两个字段分别作原子操作。
为什么可以转换参考 《深入 go interface 底层原理》 、《深入理解 go unsafe》
type efaceWords struct {
typ unsafe.Pointer
data unsafe.Pointer
}
func (v *Value) Load() (val any) {
vp := (*efaceWords)(unsafe.Pointer(v))
// ...
}
转载自:https://juejin.cn/post/7410222585211502643