引言

这个系列,从最开始的 Mutux,WaitGroup 等,到最近的 Map、Pool,我们已经了解了很多并发原语,绝大部分并发场景的问题都可以通过这些原语解决。
但是,使用这些并发原语并不是一件性价比很高的事情。比如,data race 场景中,锁常常导致系统性能下降。
原子操作能帮助我们进行更底层的优化,在实现相同效果的同时大大减少资源的消耗。

什么是原子操作

原子操作的意思是说,这个操作在执行的过程中,其它协程不会看到执行一半的操作结果。在其它协程看来,原子操作要么执行完成了,要么还没开始,就像一个原子一样,不可分割。
单处理器单核系统中,即使一个操作翻译成汇编不止一个指令,也有可能保持一致性。比如经常用来演示的并发场景下的 count++ 操作 (count++ 对应的汇编指令就有三条),如果像下面这样写:
func main() { runtime.GOMAXPROCS(1) var w sync.WaitGroup count := int32(0) w.Add(100) for i := 0; i < 100; i++ { go func() { for j := 0; j < 20; j++ { count++ } w.Done() }() } w.Wait() fmt.Println(count) }
无论执行多少次,输出结果都是 2000。
而在多核系统中,情况就变得复杂了许多。A核修改 count 的时候,由于 CPU 缓存的存在,B核读到的 count 值可能不是最新的值。如果我们将上面的例子中的第二行改成:
runtime.GOMAXPROCS(2)
之后,程序每执行一次,结果都有可能不一样。
解决思路除了使用前面介绍过的 Mutex,也可以使用今天要介绍的 atomic,具体使用方法是将 count++ 替换成:
atomic.AddInt32(&count, 1)
这样就能保证即使在多核系统下 count++ 也是一个原子操作。
针对一些基本的原子操作,不同的 CPU 架构中有不同的机制来保证原子性,atomic 包将底层不同架构的实现进行了封装,对外提供通用的 API。

atomic 的基础方法

原子操作主要是两类:修改和加载存储。修改很好理解,就是在原来值的基础上改动;加载存储就是读写。
atomic 提供了 AddXXX、CompareAndSwapXXX、SwapXXX、LoadXXX、StoreXXX 等方法。
由于 Go 暂时还不支持泛型,所以很多方法的实现都很啰嗦,比如 AddXXX 方法,针对 int32、int64、uint32 基础类型,每个类型都有相应的实现。等 Go 支持泛型之后,相信 atomic 的 API 就会清爽很多。
需要注意的是,atomic 的操作对象是地址,所以传参的时候,需要传变量的地址,不能传变量的值。

Add 方法

Add 方法很好理解,对 addr 指向的值加上 delta。如果将 delta 设置成负值,加法就变成了减法。
Add 方法的签名有如下5个:
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)
这里有个细节,像 AddUint32 针对无符号整型的操作,它实现减法的操作略微复杂一些,可以利用计算机补码的规则,把减法变成加法。
以 AddUint32 为例:
AddUint32(&x, ^uint32(c-1))
这样就实现了 x-c 的效果。当然,如果觉得这样麻烦,用下面的 CAS 方法也可以。

CAS 方法

CAS 的全称是 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)
这个方法会比较当前 addr 地址对应值是不是等于 old,等于的话就更新成 new,并返回 true,不等于的话返回 false。
CAS 本身并未实现失败的后的处理机制,只不过我们最常用的处理方式是重试而已

Swap 方法

如果不需要比较,直接交换的话,也可以用 Swap 方法。它支持的数据类型和方法如下:
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)
和下文的 Store 不一样的是,Swap 会返回旧值,因此被叫做置换操作。

Load 方法

Load 方法会取出 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)
即使在多处理器、多核、有 CPU cache 的情况下,Load 方法能保证数据的读一致性。

Store 方法

Store 方法会将一个值存到指定的 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)
其它协程通过 Load 读取数据,不会看到存取了一半的值。

Value 类型

上面的几种方法只支持基本的几种类型,因此,atomic 还提供了一个 Value 类型,它可以实现对任意类型(结构体)原子的存取操作。
Value 类型的定义以及支持的方法如下:
// A Value provides an atomic load and store of a consistently typed value. // The zero value for a Value returns nil from Load. // Once Store has been called, a Value must not be copied. // // A Value must not be copied after first use. type Value struct { v interface{} } func (v *Value) Load() (x interface{}) func (v *Value) Store(x interface{})
相比于上面的 StoreXXX 和 LoadXXX,value的操作效率会低一些,不过胜在简单易用。

对一个地址(更正:内存)的赋值操作是原子的吗?

不一定。如果赋值操作是原子的,那还需要 atomic 包吗?
在进一步解释之前,我们先看下下面这个例子:
type T struct { x int16 y int64 } func main() { var t = &T{} a := T{x: 1, y: 2} b := T{x: 0, y: 0} go func() { for { go func() { *t = a }() go func() { *t = b }() } }() for { fmt.Println(t) } }
先不执行这段程序,你能猜到 t 有几种输出吗?
答案是4种,分别是:{1,2},{0,0},{1,0},{0,2}。
如果我们使用上面提到的 atomic.Value 来进行 t 的存储和加载:
type T struct { x int16 y int64 } func main() { var v atomic.Value var t = T{} v.Store(t) a := T{x: 1, y: 2} b := T{x: 0, y: 0} go func() { for { go func() { t = a v.Store(t) }() go func() { t = b v.Store(t) }() } }() for { fmt.Println(v.Load().(T)) } }
数据的展现就恢复正常了。
在现在的操作系统中,写的地址基本是对齐的。32位系统中,变量的起始地址都是4的倍速,64位系统中,变量的起始地址都是8的倍数。如果在32位的系统上进行64位的写操作,系统可能需要两个指令才能完成(你还记得 Go 并发任务编排利器之 WaitGroup 中 state 字段针对不同系统的不同处理吗?)。对齐地址的读写,不会导致其它协程只看到写了一半的数据。
64位系统中,如果变量类型是结构体,这里的对齐一般指成员变量中第一个(64位)字是8字节对齐(具体可以看参考文章中的第一篇)。
对于现代的多处理多核的系统来说,一个核对地址的值的更改,在更新到主内存中之前,存放在在多级缓存中。这时候,其它的核看的的可能是还没有更新的数据。
多核处理器为了解决这种问题,使用了一种内存屏障的方式。使用这种方式后,数据的读写机制有点类似读写锁,并且写操作还会让 CPU 缓存失效,以便其它核能从主内存中拉取最新的值。
atomic 包提供的方法会提供内存屏障的功能,所以,atomic 不仅仅可以保证赋值的数据完整性,还能保证数据的可见性,一旦一个核更新了该地址的值,其它处理器总是能读取到它的最新值。

总结

今天这一篇主要介绍了原子操作的一些基本概念以及 Go 内置包 atomic 的几种原子操作方法。
另外,还对“对一块内存的赋值是原子操作吗?”这个问题进行了探究,现代多核操作系统中,由于多级缓存、指令重排,可见性等问题,我们对原子操作的意义有了更多的追求。
假如你还有一些疑问,或者觉得意犹未尽,想对内存对齐想做一个更深入的了解,我在文末列了两个参考文章,写的很好,强烈推荐阅读!