Go 并发系列是根据我对晁岳攀老师的《Go 并发编程实战课》的吸收和理解整理而成,如有偏差,欢迎指正~
引言
这个系列,从最开始的 Mutux,WaitGroup 等,到最近的 Map、Pool,我们已经了解了很多并发原语,绝大部分并发场景的问题都可以通过这些原语解决。
但是,使用这些并发原语并不是一件性价比很高的事情。比如,data race 场景中,锁常常导致系统性能下降。
原子操作能帮助我们进行更底层的优化,在实现相同效果的同时大大减少资源的消耗。
什么是原子操作
原子操作的意思是说,这个操作在执行的过程中,其它协程不会看到执行一半的操作结果。在其它协程看来,原子操作要么执行完成了,要么还没开始,就像一个原子一样,不可分割。
在单处理器单核系统中,即使一个操作翻译成汇编不止一个指令,也有可能保持一致性。比如经常用来演示的并发场景下的 count++ 操作 (count++ 对应的汇编指令就有三条),如果像下面这样写:
无论执行多少次,输出结果都是 2000。
而在多核系统中,情况就变得复杂了许多。A核修改 count 的时候,由于 CPU 缓存的存在,B核读到的 count 值可能不是最新的值。如果我们将上面的例子中的第二行改成:
之后,程序每执行一次,结果都有可能不一样。
解决思路除了使用前面介绍过的 Mutex,也可以使用今天要介绍的 atomic,具体使用方法是将 count++ 替换成:
这样就能保证即使在多核系统下 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个:
这里有个细节,像 AddUint32 针对无符号整型的操作,它实现减法的操作略微复杂一些,可以利用计算机补码的规则,把减法变成加法。
以 AddUint32 为例:
x-c
CAS 方法
CAS 的全称是 CompareAndSwap,它支持的数据类型和方法如下:
这个方法会比较当前 addr 地址对应值是不是等于 old,等于的话就更新成 new,并返回 true,不等于的话返回 false。
CAS 本身并未实现失败的后的处理机制,只不过我们最常用的处理方式是重试而已。
Swap 方法
如果不需要比较,直接交换的话,也可以用 Swap 方法。它支持的数据类型和方法如下:
和下文的 Store 不一样的是,Swap 会返回旧值,因此被叫做置换操作。
Load 方法
Load 方法会取出 addr 地址中的值,它支持的数据类型和方法如下:
即使在多处理器、多核、有 CPU cache 的情况下,Load 方法能保证数据的读一致性。
Store 方法
Store 方法会将一个值存到指定的 addr 地址中去它支持的数据类型和方法如下:
其它协程通过 Load 读取数据,不会看到存取了一半的值。
Value 类型
上面的几种方法只支持基本的几种类型,因此,atomic 还提供了一个 Value 类型,它可以实现对任意类型(结构体)原子的存取操作。
Value 类型的定义以及支持的方法如下:
相比于上面的 StoreXXX 和 LoadXXX,value的操作效率会低一些,不过胜在简单易用。
对一个地址(更正:内存)的赋值操作是原子的吗?
不一定。如果赋值操作是原子的,那还需要 atomic 包吗?
在进一步解释之前,我们先看下下面这个例子:
先不执行这段程序,你能猜到 t 有几种输出吗?
答案是4种,分别是:{1,2},{0,0},{1,0},{0,2}。
如果我们使用上面提到的 atomic.Value 来进行 t 的存储和加载:
数据的展现就恢复正常了。
在现在的操作系统中,写的地址基本是对齐的。32位系统中,变量的起始地址都是4的倍速,64位系统中,变量的起始地址都是8的倍数。如果在32位的系统上进行64位的写操作,系统可能需要两个指令才能完成(你还记得 Go 并发任务编排利器之 WaitGroup 中 state 字段针对不同系统的不同处理吗?)。对齐地址的读写,不会导致其它协程只看到写了一半的数据。
64位系统中,如果变量类型是结构体,这里的对齐一般指成员变量中第一个(64位)字是8字节对齐(具体可以看参考文章中的第一篇)。
对于现代的多处理多核的系统来说,一个核对地址的值的更改,在更新到主内存中之前,存放在在多级缓存中。这时候,其它的核看的的可能是还没有更新的数据。
多核处理器为了解决这种问题,使用了一种内存屏障的方式。使用这种方式后,数据的读写机制有点类似读写锁,并且写操作还会让 CPU 缓存失效,以便其它核能从主内存中拉取最新的值。
atomic 包提供的方法会提供内存屏障的功能,所以,atomic 不仅仅可以保证赋值的数据完整性,还能保证数据的可见性,一旦一个核更新了该地址的值,其它处理器总是能读取到它的最新值。
总结
今天这一篇主要介绍了原子操作的一些基本概念以及 Go 内置包 atomic 的几种原子操作方法。
另外,还对“对一块内存的赋值是原子操作吗?”这个问题进行了探究,现代多核操作系统中,由于多级缓存、指令重排,可见性等问题,我们对原子操作的意义有了更多的追求。
假如你还有一些疑问,或者觉得意犹未尽,想对内存对齐想做一个更深入的了解,我在文末列了两个参考文章,写的很好,强烈推荐阅读!
参考
- 内存布局:
- Go 语言标准库中 atomic.Value 的前世今生:
欢迎关注我的公众号【码农的自由之路】,左手代码,右手理财,自由的路上,希望有我也有你!
都看到这里了,不如点个 赞/喜欢,加个关注呗~~