Go核心36讲四-原子操作

原子操作

互斥锁是一个很有用的同步工具,它可以保证每一时刻进入临界区的goroutine只有一个,读写锁对共享资源的写操作和读操作则区别看待,并消除了读操作之间的互斥。

条件变量主要是用于协调想要访问共享资源的那些线程。当共享资源的状态发生变化时,它可以被
用来通知被互斥锁阻塞的线程,它既可以基于互斥锁,也可以基于读写锁。当然了,读写锁也是一种互斥锁,前者是对后者的扩展。
通过对互斥锁的合理使用,我们可以使一个goroutine在执行临界区中的代码时,不被其他的 goroutine打扰。不过,虽然不会被打扰,但是它仍然可能会被中断(interruption)。

我们已经知道,对于一个Go程序来说,Go语言运行时系统中的调度器,会恰当地安排其中所有
的goroutine的运行。不过,在同一时刻,只可能有少数的goroutine真正地处于运行状态,并且这个数量是固定的。

所以,为了公平起见,调度器总是会频繁地换上或换下这些aoroutine。换上的意思是,让一个
qoroutine由非运行状态转为运行状态,并促使其中的代码在某个CPU核心上执行。

换下的意思正好相反,即:使一个goroutine中的代码中断执行,并让它由运行状态转为非运行状态。
这个中断的时机有很多,任何两条语句执行的间隙,甚至在某条语句执行的过程中都是可以的。即使这些语句在临界区之内也是如此。所以,我们说,互斥锁虽然可以保证临界区中代码的串行执
行,但却不能保证这些代码执行的原子性(atomicity)。
在众多的同步工具中,真正能够保证原子性执行的只有原子操作(atomicoperation)。原子操作
在进行的过程中是不允许中断的。在底层,这会由CPU提供芯片级别的支持,所以绝对有效。即
使在拥有多CPU核心,或者多CPU的计算机系统中,原子操作的保证也是不可撼动的。

这使得原子操作可以完全地消除竞态条件,并能够绝对地保证并发安全性。并目,它的执行速度要
比其他的同步工具快得多,通常会高出好几个数量级。不过,它的缺点也很明显。
更具体地说,正是因为原子操作不能被中断,所以它需要足够简单,并且要求快速。你可以想象一下,如果原子操作迟迟不能完成,而它又不会被中断,那么将会给计算机执行指令的效率带来多大的影响。因此操作系统层面只对针对二进制位或整数的原子操作提供了支持。
Go语言的原子操作基于CPU和操作系统的,所以只针对少数的数据类型值提供了原子操作函数,函数存在于标准库代码包sync/atomic中

  • sync/atomic 包中的函数可以做原子操作的有
  • 函数:add、compare and swap (CAS)、load、store、swap
  • 数据类型: int32、int64、unint32、unit64、uintptr、以及unsafe包中的Pointer。不过针对unsafe.Pointer类型,该包并未提供进行原子加法操作的函数。
  • 问题1:atomic.AddInt32函数的第一个参数类型为什么不是int32 而是*int32?
  • 因为原子操作函数需要的是被操作值的指针,而不是这个值本身;被传入函数的参数值都会被复制,像这种基本类型的值一旦被传入函数,就已经与函数外的那个值毫无关系了。
    所以,传入值本身没有任何意义。unsafePointer类型虽然是指针类型,但是那些原子操作函数
    要操作的是这个指针值,而不是它指向的那个值,所以需要的仍然是指向这个指针值的指针。

只要原子操作函数拿到了被操作值的指针,就可以定位到存储该值的内存地址。只有这样,它们才能够通过底层的指令,准确地操作这个内存地址上的数据。

  • 问题2:用于原子加法操作的函数可以做原子减法吗?比如,atomic.AddInt32函数可以用于减小那个被操作的整数值吗?
  • 当然是可以的。atomicAddInt32函数的第二个参数代表差量,它的类型是int32,是
    有符号的。如果我们想做原子减法,那么把这个差量设置为负整数就可以了。

对于atomic.AddInt64函数来说也是类似的。不过,要想用atomicAddUint32和
atomic.Adduint64函数做原子减法,就不能这么直接了,因为它们的第二个参数的类型分别是 uint32和uint64,都是无符号的,不过,这也是可以做到的,就是稍微麻烦一些。

列如,如果想对uint32类型的被操作值18做原子减法,比如说差量是-3,那么我们可以先把这个
差量转换为有符号的int32类型的值,然后再把该值的类型转换为uint32,用表达式来描述就是
uint32(int32(-3))。

不过要注意,直接这样写会使Go语言的编译器报错,它会告诉你:“常量-3不在uint32类型可表
示的范围内”换句话说,这样做会让表达式的结果值溢出。
不过,如果我们先把int32(-3)的结果值赋给变量delta,再把delta的值转换为uint32类型的值,就可以绕过编译器的检查并得到正确的结果了。
最后,我们把这个结果作为atomicAddUint32函数的第二个参数值,就可以达到对uint32类型的值做原子减法的目的了。
还有一种更加直接的方式。我们可以依据下面这个表达式来给定atomicAddUint32函数的第二个参数值:

^uint32(-N-1)

N代表由负整数表示的差量,先把差量的绝对值减去1,然后再把得到的这个无类型的整数常量,转换为uint32类型的值,最后在这个值上做按位异或操作,就可以获得最终的参数值了。

  • 问题3 compare and swap 简称CAS和swap的区别于优势
  • CAS操作是有条件的交换操作,只有在条件满足的情况下才会进行值的交换。
  • 交换指的是把新值赋给变量,并返回变量的旧值。
  • 在进行CAS操作的时候,函数会先判断被操作变量的当前值,是否与我们预期的旧值相等。如果相等它就把新值赋给该变量,并返回true以表明交换操作已经进行,否则就忽略交换操作,并返回false
  • CAS操作不是单一的操作,而是一种操作组合。它的用途要更广泛一些
  • for语句联用实现自旋锁

    for {
    if atomic.CompareAndSwapInt32(&num2, 10, 0) {
        fmt.Println("The second number has gone to zero")
        break
    }
    
    time.Sleep(time.Millisecond * 500)
    }

    在for语句中的CAS操作可以不停地检查某个需要满足的条件,一旦条件满足就退出for循环。这就相当于,只要条件未被满足,当前的流程就会被一直“阻塞”在这里。
    这在效果上与互斥锁有些类似。不过,它们的适用场景是不同的。我们在使用互斥锁的时候,总是假设共享资源的状态会被其他的goroutine频繁地改变。

而for语句加CAS操作的假设往往是:共享资源状态的改变并不频繁,或者,它的状态总会变成期
望的那样。这是一种更加乐观,或者说更加宽松的做法。
第四个衍生问题:假设我已经保证了对一个变量的写操作都是原子操作,比如:加或减、存储、交换等等,那我对它进行读操作的时候,还有必要使用原子操作吗?

回答:很有必要。其中的道理你可以对照一下读写锁。为什么在读写锁保护下的写操作和读操作之
间是互斥的?这是为了防止读操作读到没有被修改完的值,对吗?

如果写操作还没有进行完,读操作就来读了,那么就只能读到仅修改了一部分的值。这显然破坏了
值的完整性,读出来的值也是完全错误的。
所以,一旦你决定了要对一个共享资源进行保护,那就要做到完全的保护。不完全的保护基本上与不保护没有什么区别。

好了,上面的主问题以及相关的衍生问题涉及了原子操作函数的用法、原理、对比和一些最佳实践,希望你已经理解了。
由于这里的原子操作函数只支持非常有限的数据类型,所以在很多应用场景下,互斥锁往往是更加
适合的。

不过,一日我们确定了在某个场景下可以使用原子操作函数,比如:只涉及并发地读写单一的整数
类型值,或者多个互不相关的整数类型值,那就不要再考虑互斥锁了。
这主要是因为原子操作函数的执行速度要比互斥锁快得多。而且,它们使用起来更加简单,不会涉及临界区的选择,以及死锁等问题。当然了,在使用CAS操作的时候,我们还是要多加注意的因为它可以被用来模仿锁,并有可能“阻塞”流程。

问题:怎样用好sync/atomicValue?

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

atomic.value类型是开箱即用的,我们声明一个该类型的变量(以下简称原子变量)之后就可以
直接使用了。这个类型使用起来很简单,它只有两个指针方法--Store和Load。不过,虽然简单,但还是有一些值得注意的地方的。
首先一点,一旦atomicValue类型的值(以下简称原子值)被真正使用,它就不应该再被复制了。什么叫做“真正使用"呢?

我们只要用它来存储值了,就相当于开始真正使用了。atomicValue类型属于结构体类型,而结构体类型属于值类型。
所以,复制该类型的值会产生一个完全分离的新值。这个新值相当于被复制的那个值的一个快照。之后,不论后者存储的值怎样改变,都不会影响到前者,反之亦然。

另外,关于用原子值来存储值,有两条强制性的使用规则。第一条规则,不能用原子值存储nil。
也就是说,我们不能把nil作为参数值传入原子值的store方法,否则就会引发一个panic。

这里要注意,如果有一个接口类型的变量,它的动态值是nil,但动态类型却不是nil,那么它的
值就不等于nil。我在前面讲接口的时候和你说明过这个问题。正因为如此,这样一个变量的值是可以被存入原子值的。

第二条规则,我们向原子值存储的第一个值,决定了它今后能且只能存储哪一个类型的值。

例如,我第一次向一个原子值存储了一个string类型的值,那我在后面就只能用该原子值来存储字符串了。如果我又想用它存储结构体,那么在调用它的store方法的时候就会引发一个panic。这个panic会告诉我,这次存储的值的类型与之前的不一致。
你可能会想:我先存储一个接口类型的值,然后再存储这个接口的某个实现类型的值,这样是不是

可以呢?
很可惜,这样是不可以的,同样会引发一个panic。因为原子值内部是依据被存储值的实际类型来
做判断的。所以,即使是实现了同一个接口的不同类型,它们的值也不能被先后存储到同一个原子
值中。

遗憾的是,我们无法通过某个方法获知一个原子值是否已经被真正使用,并且,也没有办法通过常
规的途径得到一个原子值可以存储值的实际类型。这使得我们误用原子值的可能性大大增加,尤其是在多个地方使用同一个原子值的时候。
下面,我给你几条具体的使用建议。
1不要把内部使用的原子值暴露给外界。比如,声明一个全局的原子变量并不是一个正确的做法。这个变量的访问权限最起码也应该是包级私有的。
2如果不得不让包外,或模块外的代码使用你的原子值,那么可以声明一个包级私有的原子变
量,然后再通过一个或多个公开的函数,让外界间接地使用到它。注意,这种情况下不要把原子值传递到外界,不论是传递原子值本身还是它的指针值。
3如果通过某个函数可以向内部的原子值存储值的话,那么就应该在这个函数中先判断被存储值
类型的合法性。若不合法,则应该直接返回对应的错误值,从而避免panic的发生。
4.如果可能的话,我们可以把原子值封装到一个数据类型中,比如一个结构体类型。这样,我们
既可以通过该类型的方法更加安全地存储值,又可以在该类型中包含可存储值的合法类型信
息。

除了上述使用建议之外,我还要再特别强调一点:尽量不要向原子值中存储引用类型的值。因为这
很容易造成安全漏洞。请看下面的代码:

varbox6 atomic.Value
v6 := []int{1,2,3} box6.Store(v6)
v6[1]=4//注意此处的操作不是并发安全的!

我把一个int类型的切片值v6存入了原子值box6。注意,切片类型属于引用类型。所以,我在外面改动这个切片值,就等于修改了box6中存储的那个值。这相当于绕过了原子值而进行了非并发安全的操作。那么,应该怎样修补这个漏洞呢?可以这样做:

store:=func(v[]int){
replica :=make([]int,len(v)) copy(replica,v)
box6.Store(replica)
}
store(v6)
v6[2]=5//此处的操作是安全的。

我先为切片值v6创建了一个完全的副本。这个副本涉及的数据已经与原值毫不相干了。然后,我再把这个副本存入box6。如此一来,无论我再对v6的值做怎样的修改,都不会破坏box6提供的安全
保护。

发表评论

您的电子邮箱地址不会被公开。 必填项已用*标注