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 的几种原子操作方法。

另外,还对“对一块内存的赋值是原子操作吗?”这个问题进行了探究,现代多核操作系统中,由于多级缓存、指令重排,可见性等问题,我们对原子操作的意义有了更多的追求。

假如你还有一些疑问,或者觉得意犹未尽,想对内存对齐想做一个更深入的了解,我在文末列了两个参考文章,写的很好,强烈推荐阅读!

参考

  1. 内存布局:
  1. Go 语言标准库中 atomic.Value 的前世今生:

欢迎关注我的公众号【码农的自由之路】,左手代码,右手理财,自由的路上,希望有我也有你!

都看到这里了,不如点个 赞/喜欢,加个关注呗~~