30 | 原子操作(下)

你好,我是郝林,今天我们继续分享原子操作的内容。

我们接着上一篇文章的内容继续聊,上一篇我们提到了,sync/atomic包中的函数可以做的原子操作有:加法(add)、比较并交换(compare and swap,简称CAS)、加载(load)、存储(store)和交换(swap)。并且以此衍生出了两个问题。

今天我们继续来看第三个衍生问题: 比较并交换操作与交换操作相比有什么不同?优势在哪里?

回答是:比较并交换操作即CAS操作,是有条件的交换操作,只有在条件满足的情况下才会进行值的交换。

所谓的交换指的是,把新值赋给变量,并返回变量的旧值。

在进行CAS操作的时候,函数会先判断被操作变量的当前值,是否与我们预期的旧值相等。如果相等,它就把新值赋给该变量,并返回true以表明交换操作已进行;否则就忽略交换操作,并返回false

可以看到,CAS操作并不是单一的操作,而是一种操作组合。这与其他的原子操作都不同。正因为如此,它的用途要更广泛一些。例如,我们将它与for语句联用就可以实现一种简易的自旋锁(spinlock)。

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/atomic.Value

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

atomic.Value类型是开箱即用的,我们声明一个该类型的变量(以下简称原子变量)之后就可以直接使用了。这个类型使用起来很简单,它只有两个指针方法:StoreLoad。不过,虽然简单,但还是有一些值得注意的地方的。

首先一点,一旦atomic.Value类型的值(以下简称原子值)被真正使用,它就不应该再被复制了。什么叫做“真正使用”呢?

我们只要用它来存储值了,就相当于开始真正使用了。atomic.Value类型属于结构体类型,而结构体类型属于值类型。

所以,复制该类型的值会产生一个完全分离的新值。这个新值相当于被复制的那个值的一个快照。之后,不论后者存储的值怎样改变,都不会影响到前者,反之亦然。

另外,关于用原子值来存储值,有两条强制性的使用规则。第一条规则,不能用原子值存储nil

也就是说,我们不能把nil作为参数值传入原子值的Store方法,否则就会引发一个panic。

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

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

例如,我第一次向一个原子值存储了一个string类型的值,那我在后面就只能用该原子值来存储字符串了。如果我又想用它存储结构体,那么在调用它的Store方法的时候就会引发一个panic。这个panic会告诉我,这次存储的值的类型与之前的不一致。

你可能会想:我先存储一个接口类型的值,然后再存储这个接口的某个实现类型的值,这样是不是可以呢?

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

遗憾的是,我们无法通过某个方法获知一个原子值是否已经被真正使用,并且,也没有办法通过常规的途径得到一个原子值可以存储值的实际类型。这使得我们误用原子值的可能性大大增加,尤其是在多个地方使用同一个原子值的时候。

下面,我给你几条具体的使用建议。

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

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

var box6 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提供的安全保护。

以上,就是我要告诉你的关于atomic.Value的注意事项和使用建议。你可以在demo64.go文件中看到相应的示例。

总结

我们把这两篇文章一起总结一下。相对于原子操作函数,原子值类型的优势很明显,但它的使用规则也更多一些。首先,在首次真正使用后,原子值就不应该再被复制了。

其次,原子值的Store方法对其参数值(也就是被存储值)有两个强制的约束。一个约束是,参数值不能为nil。另一个约束是,参数值的类型不能与首个被存储值的类型不同。也就是说,一旦一个原子值存储了某个类型的值,那它以后就只能存储这个类型的值了。

基于上面这几个注意事项,我提出了几条使用建议,包括:不要对外暴露原子变量、不要传递原子值及其指针值、尽量不要在原子值中存储引用类型的值,等等。与之相关的一些解决方案我也一并提出了。希望你能够受用。

原子操作明显比互斥锁要更加轻便,但是限制也同样明显。所以,我们在进行二选一的时候通常不会太困难。但是原子值与互斥锁之间的选择有时候就需要仔细的考量了。不过,如果你能牢记我今天讲的这些内容的话,应该会有很大的助力。

思考题

今天的思考题只有一个,那就是:如果要对原子值和互斥锁进行二选一,你认为最重要的三个决策条件应该是什么?

戳此查看Go语言专栏文章配套详细代码。

精选留言

  • hunknownz

    2018-11-12 11:21:51

    回答问题:
    1. 是否一定要操作引用类型的值;
    2. 是否一定要操作nil;
    3. 是否需要处理一个接口的不同类型。
  • Geek_f1933b

    2020-01-18 11:45:53

    郝老师,什么时候使用atomic.value呢,有没有具体的应用中的简单例子呢
    作者回复

    其实需要保护非引用类型的值的时候都挺适用的。如果是引用类型的值的话,可能会起不到保护作用。因为我们修改的往往是这个值引用的那个底层值,而 atomic.Value 只会保护这个值本身。

    例子的话...比如保护全局配置、同时保护一坨全局计数、保护 bit array,等等。

    2020-01-18 15:38:14

  • rename

    2018-10-27 14:28:31

    我认为最重要的三点是 要操作的变量类型,操作频率和整体操作耗时。请郝大指教~
    作者回复

    其实主要还是变量类型,原子操作在这块是严格的。能用原子就用原子。锁这个原语还是相对较重。

    2018-11-06 13:24:43

  • 蜉蝣

    2020-11-06 09:55:23

    老师好,衍生问题 4 的回答不能明白。既然原子操作是不会被中断的,那么为什么还会出现 “如果写操作还没有进行完,读操作就来读了,那么就只能读到仅修改了一部分的值。” 这种写操作还没完进行完就有其他操作进来? 如果是因为多核多 CPU 的话,那是不是说,读的原子操作与写的原子操作是互斥?
    作者回复

    原文中的“如果写操作还没有进行完,读操作就来读了,那么就只能读到仅修改了一部分的值。”这句话是对读写锁来说的。如果只锁写操作、不锁读操作就会发生这样的问题。

    这种原子操作一般都是通过原生的CPU指令实现的,所以从底层保障了并发操作的安全性。即使CPU是多核的,甚至有多个CPU,只要主板等硬件方面没有问题,都是可以保障的。

    最后,原子操作和互斥锁不是一个东西。原子操作用防中断的方式来保并发,而互斥锁用串行化多个操作的方式来保并发(它不防中断)。

    你如果说的只是“互斥”这个词,那你也可以理解为针对同一个变量的原子操作是“互斥”的(但它是通过“防中断”来达到“互斥”的目的)。

    2020-11-06 17:04:43

  • sky

    2018-10-25 10:55:32

    郝大 关于这两节的原子操作提供的一些方法能具体列下相应常用的业务场景就更好了 这样才能更好的学以致用啊
  • 翅膀

    2018-11-15 08:11:58

    请教下关于读写的原子操作底层的问题,对于一个32位的整数,什么情况下会读写一半。假如这个值定义时做了字节对齐(存储地址是4的整数倍),还会有这种情况吗?如果再加限制,仅仅针对intel的现代cpu,比如i7,情况又是怎样的?
  • Askerlve

    2018-10-19 10:23:28

    老师,git代码没更新哦~😯
  • mkii

    2021-02-25 09:17:32

    一旦atomic.Value类型的值(以下简称原子值)被真正使用,它就不应该再被复制了。
    老师,如果Value存储的是引用类型,被复制有可能绕过原子值进行非并发操作这个我可以理解。但如果Value存储的是值类型,如果复制了再对这个副本操作应该不会对原值有影响。这里是怕别人误操作产生歧义吗?(即,我明明通过copyValue.store改了值,但为什么没生效?)
    作者回复

    Value里面存储的是被操作值的指针啊,所以一样的。

    2021-02-25 12:14:09

  • poettian

    2021-01-10 19:34:13

    看到衍生问题4我也有个疑惑,那如果不用原子操作,是不是说假如在写的同时有读的操作,我们读取变量实际读到的是个不完整的值?
    作者回复

    是有这个可能的。

    2021-01-11 13:18:45

  • 冰激凌的眼泪

    2018-11-01 08:34:41

    value的封装使用,可以参照后面的并发map
  • Askerlve

    2018-10-19 10:18:17

    思考题:1.使用原子类型有ABA问题,若业务对ABA敏感,使用锁。
    只想到了一点,求老师补充~😀
  • noisyes

    2022-06-23 10:24:33

    通过一个或多个公开的函数,让外界间接地使用到它。这种情况下不要把原子值传递到外界,不论是传递原子值本身还是它的指针值。 但是即使编写了一个函数,但是不传递原子值的话,怎么才可以让外界使用它呢。
    作者回复

    靠复制啊,如果是值类型的值的话,从函数返回时就自动复制了,如果是引用类型的值的话,比如切片或字典,就需要手动复制。使用atomic.Value的时候一定要注意这类问题。

    2022-06-23 11:33:50

  • 风翱

    2022-06-18 19:18:17

    老师,关于热更有什么方案呢? 是否有使用过,并在实际的项目中运用过? 有没有相应的事例?
    作者回复

    热更新的话,现在肯定是首选 context 啊,因为标准库中的其他包已经与 context 融合得非常好了。虽然现在市面上还有一些相关的第三方库,但是如果没有特殊需求的话,还还是推荐 context。

    2022-06-21 19:51:05

  • jxs1211

    2021-10-24 23:31:41

    go的内置函数的源码是在哪里可以找到,比如说append()使用时,都做了什么
    作者回复

    要看切片相关的话,先在源码 src 目录里找runtime/slice.go 。这个文件里有个 growslice 函数,append 会调用它。后边你就顺藤摸瓜吧。

    2021-10-26 00:06:49

  • lesserror

    2021-08-17 15:51:33

    郝林老师,demo64 中的 示例3 的第二条 打印:

    fmt.Printf("Store %d to box2.\n", v3) 是不是 应该 改为:

    fmt.Printf("Store %d to box3.\n", v3)

    作者回复

    好像应该是 box3 ,回头我改一下吧,谢谢

    2021-08-18 23:49:04

  • Da Vinci

    2019-11-11 16:23:09

    在首次真正使用后,原子值就不应该再被复制了,这句话不是特别理解,想请老师再解释一下
    作者回复

    Value 类型的值里面一旦存了值就不应该再拷贝了,因为这很可能会让基于内存地址的互斥机制失效,并产生混乱。这也是 Value 的文档里特别说明的。

    2019-11-13 11:23:53

  • 一颗大白菜

    2019-05-08 08:55:30

    有个疑问:语言层可以保证原子操作自身读、写时的正确性,但如何保证读出后用于业务判断的正确性呢?我理解业务判断已经在atomic方法之外了,还是无法保证数据使用的一致性
    作者回复

    数据一致性是一个比较大的话题了。没有上下文的话几乎是无从谈起的。

    2019-05-08 10:58:24

  • 顾骨

    2018-12-12 09:18:19

    int32这种类型是4个字节的,64位操作系统下,a=b这种赋值操作不是原子的吗?为什么还要LoadInt32这个原子操作?