golang并发教程3

204 阅读5分钟
原文链接: xueyuanjun.com

Go 语言并发编程系列教程(十二)—— sync 包(三):原子操作

中断与原子操作

我们在前两篇教程中讨论了互斥锁、读写锁以及基于它们的条件变量。互斥锁是一个同步工具,它可以保证每一时刻进入临界区的协程只有一个;读写锁对共享资源的写操作和读操作区别看待,并消除了读操作之间的互斥;条件变量主要用于协调想要访问共享资源的那些线程,当共享资源的状态发生变化时,它可以被用来通知被互斥锁阻塞的线程,它既可以基于互斥锁,也可以基于读写锁(当然了,读写锁也是互斥锁,是对后者的一种扩展)。通过对互斥锁的合理使用,我们可以使一个 Go 协程在执行临界区中的代码时,不被其他的协程打扰,实现串行执行,不过,虽然不会被打扰,但是它仍然可能会被中断(interruption)。

所谓中断其实是 CPU 和操作系统级别的术语,并发执行的协程并不是真的并行执行,而是通过 CPU 的调度不断从运行状态切换到非运行状态,或者从非运行状态切换到运行状态,在用户开来,好像是「同时」在执行。我们把代码从运行状态切换到非运行状态称之为中断。中断的时机很多,比如任何两条语句执行的间隙,甚至在某条语句执行的过程中都是可以的,即使这些语句在临界区内也是如此。所以我们说互斥锁只能保证临界区代码的串行执行,不能保证这些代码执行的原子性,因为原子操作不能被中断。

原子操作通常是 CPU 和操作系统提供支持的,由于执行过程中不会中断,所以可以完全消除竞态条件,从而绝对保证并发安全性,此外,由于不会中断,所以原子操作本身要求也很高,既要简单,又要快速。Go 语言的原子操作也是基于 CPU 和操作系统的,由于简单和快速的要求,只针对少数数据类型的值提供了原子操作函数,这些函数都位于标准库代码包 sync/atomic 中。这些原子操作包括加法(Add)、比较并交换(Compare And Swap,简称 CAS)、加载(Load)、存储(Store)和交换(Swap)。

下面我们简单介绍下这些原子操作。

Go 语言中的原子操作

加减法

我们可以通过 atomic 包提供的下列函数实现加减法的原子操作,第一个参数是操作数对应的指针,第二个参数是加/减值:

虽然这些函数都是以 Add 前缀开头,但是对于减法可以通过传递负数实现,不过对于后三个函数,由于操作数类型是无符号的,所以无法显式传递负数来实现减法。比如我们测试下 AddInt32 函数:

var i int32 = 1
atomic.AddInt32(&i, 1)
fmt.Println("i = i + 1 =", i)
atomic.AddInt32(&i, -1)
fmt.Println("i = i - 1 =", i)

上述代码打印结果如下:

i = i + 1 = 2
i = i - 1 = 1

比较并交换

比较并交换相关的原子函数如下,第一个参数是操作数对应的指针,第二、三个参数是待比较和交换的旧值和新值:

这些函数会在交换之前先判断 oldnew 对应的值是否相等,如果不相等才会交换:

var a int32 = 1
var b int32 = 2
var c int32 = 2
atomic.CompareAndSwapInt32(&a, a, b)
atomic.CompareAndSwapInt32(&b, b, c)
fmt.Println("a, b, c:", a, b, c)

上述代码的打印结果是:

a, b, c: 2 2 2

加载

加载相关的原子操作函数如下,这些操作函数仅传递一个参数,即待操作数对应的指针,并且有一个返回值,返回传入指针指向的值:

这里的「原子性」指的是当读取该指针指向的值时,CPU 不会执行任何其它针对此值的读写操作。例如,我们可以这样调用 LoadInt32 函数:

var x int32 = 100
y := atomic.LoadInt32(&x)
fmt.Println("x, y:", x, y)

存储

存储相关的原子函数如下所示,第一个参数表示待操作变量对应的指针,第二个参数表示要存储到待操作变量的数值:

该操作可以看作是加载操作的逆向操作,一个用于读取,一个用于写入,通过上述原子函数存储数值的时候,不会出现存储流程进行到一半被中断的情况,比如我们可以通过 StoreInt32 函数改写上述设置 y 变量的操作代码:

var x int32 = 100
var y int32
atomic.StoreInt32(&y, atomic.LoadInt32(&x))
fmt.Println("x, y:", x, y)

打印结果和之前完全一致。

交换

交换和比较并交换看起来有点类似,但是交换不关心待操作数的旧值,不管旧值和新值是否相等,都会通过新值替换旧值,不过,交换函数有一个返回值,会返回旧值:

示例代码如下:

var j int32 = 1
var k int32 = 2
j_old := atomic.SwapInt32(&j, k)
fmt.Println("old,new:", j_old, j)

打印结果为:

old,new: 1 2

原子类型

为了扩大原子操作的适用范围,Go 语言在 1.4 版本发布的时候向 sync/atomic 包中添加了一个新的类型 Value,此类型的值相当于一个容器,可以被用来「原子地」存储和加载任意的值:

type Value struct {
    v interface{}
}

atomic.Value 类型是开箱即用的,我们声明一个该类型的变量(以下简称原子变量)之后就可以直接使用了。这个类型使用起来很简单,它只有 StoreLoad 两个指针方法,这两个方法都是原子操作:

var v atomic.Value
v.Store(100)
fmt.Println("v:", v.Load())

不过,虽然简单,但还是有一些需要注意的地方。首先,存储值不能是 nil;其次,我们向原子类型存储的第一个值,决定了它今后能且只能存储该类型的值。如果违背这两条,编译时会抛出 panic。