18-atomic

并发编程里经常用到的技术,除了Context、计时器、互斥锁、通道外还有一种技术--原子操作在一些同步算法中会被用到。

对于一个Go程序来说,Go语言运行时系统中的调度器会恰当地安排其中所以的goroutine运行。

不过,在同一时刻,只可能有少数的goroutine真正地处于运行状态,并且这个数量只会与M的数量一致,而不会随着G的增多而增长。

为了公平期间,调度器总是会频繁地换上或者换下这些goroutine。

  • 换上:让一个goroutine由非运行状态转为运行状态,并促使其中的代码在某一个CPU核心上执行
  • 换下:是一个goroutine中的代码中断执行,并让它由运行状态转为非运行状态

这个中断的时机有很多,任何两条语句执行的间隙,甚至在某条语句执行的过程中都可以,即使这条语句在临界区之内

互斥锁虽然可以保证临界区中代码的串行执行,但却不能保证这些代码执行的原子性(atomicity)。

在众多同步工具中,真正能够保证原子性的只有原子操作。

原子操作在进行的过程中不允许中断。在底层,这会由CPU提供芯片级别的支持(一个原子操作只会由一个独立的CPU指令代表和完成),所以绝对有效。

即使在拥有多CPU核心或者多个CPU的计算机系统中,原子操作的保证也是不可撼动的。

原子操作可以完全地消除竞态条件,并能够绝对地保证并发安全性

原子操作是无锁的,直接通过CPU指令直接实现,在执行速度上比其他的同步工具快很多,通常会高出好几个数量级。

事实上,其它同步技术的实现常常依赖于原子操作。

正是因为原子操作不能被中断,所以需要足够简单,并且要求快速

如果原子操作迟迟不能完成,而它又不会被中断,那么将会给计算机执行指令的效率带来多么大的影响。因此操作系统层面只针对二进制整数的原子操作提供支持。

0.1. Go对原子操作的支持

Go语言的原子操作也是基于操作系统和CPU的,所以只能对少数数据类型的值提供原子操作函数。这些函数在sync/atomic包中,用于同步访问整数和指针。

Go语言提供的原子操作都是非入侵式的,原子操作可以确保gorotuine之间不存在数据竞争。

竞争条件是由于异步的访问共享资源,并试图同时读写该资源而导致的,使用互斥锁和通道的思路都是在线程获得到访问权后阻塞其他线程对共享内存的访问,而使用原子操作解决数据竞争问题则是利用了其不可被打断的特性。

支持的5种原子操作:

  1. 加法(add)
  2. 比较并交换(compare and swap,简称CAS)
  3. 加载(load)
  4. 存储(store)
  5. 交换(swap)

这些函数针对的数据类型并不多,但是对这些类型中的每一个都有一套函数给予支持。

原子操作支持的6种数据类型:

  1. int32
  2. int64
  3. uint32
  4. uint64
  5. uintptr
  6. unsafe包中的Pointer:对unsate.Pointer类型没有提供原子加法操作的函数

sync/atomic包中还提供了名为Value的类型,用于存储任意类型的值。

0.1.1. 注意点1

传入原子操作函数的第一个参数值对应的都应该是那个被操作的值,如:

func AddInt32(addr *int32, delta int32) (new int32)
// 以原子方式将增量添加到 *addr 并返回新的值

上面函数的一个参数应该是那个需要被增大的整数。这个参数类型为什么是*int32?因为原子操作函数需要的是被操作值的指针,而不是这个值本身,被传入函数的参数值都会被复制,像这种基本类型的值一旦被传入函数,就已经与函数外的那个值毫无关系了。传入值本身没有任何意义

unsafe.Pointer类型虽然是指针类型,但是那些原子操作函数要操作的是这个指针值,而不是它指向的那个值,所以需要的仍然是指向这个指针值的指针。

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

0.1.2. 注意点2

用于原子加法操作的函数可以做原子减法吗?

func AddInt32(addr *int32, delta int32) (new int32)
// 以原子方式将增量添加到 *addr 并返回新的值

上面函数的第二个参数代表增量,它的类型是int32,是有符号的,将增量值设置为负数就能实现原子减法操作。

但是下面两个函数就不能直接将增量值赋予为负数进行减法,因为第二个表示增量的参数是无符号的:

func AddUint32(addr *uint32, delta uint32) (new uint32)
func AddUint32(addr *uint32, delta uint32) (new uint32)

假设要将上面的增量赋予-3,可以进行类型转换:uint32(int32(-3)),这样转换后Go编译器会报错“常量-3不在unit32类型可表示范围内”,即表达式的结果值溢出。

换个操作方式,先将int32(-3)赋予增量,在将增量的类型转换为unit32,这样可以绕过编译器的检查并得到正确的结果。将这个结果作为第二个参数值传递给原子加法操作来实现原子减法目的。

官方文旦展示了另一种操作方式,^uint32(-N-1),其中N表示增量的负整数,即:

  1. 先把增量的绝对值减去1
  2. 再把得到的无类型的整数常量转换我uint32类型的值
  3. 在这个值上做按位异或操作得到最终的参数值

0.1.3. 注意点3

CAS与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操作:总是假设共享资源状态的改变不是很频繁,或者,它的状态总会变成期望的那样,这是一种乐观假设

0.1.4. 注意点4

对一个变量的写操作都是原子操作(如,加减、存储、交换等),那么对它的读操作还需要是原子操作吗?

有必要。就像读写锁中写操作与读操作是互斥的。这是为了防止读操作读到未被修改完的值

所以要对共享资源进行保护,那就要完全的保护,不完全的保护基本上和不保护没有什么区别。

原子操作支持的数据类型很有限,所以在很多场景下,互斥锁更加适用

一旦确定某个场景下可以适用原子操作函数,比如只涉及并发地读写单一的整数类型值,或者多个互不相关的整数类型值,就不要考虑互斥锁,因为原子操作函数的执行速度要比互斥锁快很多。使用起来也更简单,不会涉及临界区的选择,死锁等问题。使用CAS操作要注意可能引起“阻塞”流程

0.1.5. sync/atomic.Value

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

atomic.Value类型是开箱即用的,声明一个该类型的变量(简称原子变量)之后就直接使用,该类型只有两个指针方法:StoreLoad

0.1.5.1. 注意点

atomic.Value类型的值(原子值)被正真使用(用原子变量存储了值,就相当于真正使用),就不应该再被复制了。

atomic.Value类型属于结构体类型,而结构体类型属于值类型。所以复制这个值会产生一个完全分离的新值,两者怎么改变都不会相互影响。

用原子值来存储值,有两个强制性的使用规则:

  1. 不能用原子值存储nil:即不能把nil作为参数值传入原子值的Store方法,否则会引发一个panic

    注意,如果有一个接口类型的变量,它的动态值的nil,但动态类型却不是nil,那么它的值就不等于nil,所以这样一个变量的值可以被存入原子值中。

  2. 向原子值存储的第一个值,决定了它今后能且只能存储哪一个类型的值

    例如,对于一个第一次存储了string类型值的原子值,在调用Store发放存储其他类型时会引发一个panic,提示这次存储的值的类型与之前的不一致。

    在原子值内部依据被存储值的实际类型进行判断,所以即使实现了同一个接口的不同类型,它们的值也不能被先后存储在同一个原子值中

遗憾的是:

  1. 无法通过某个方法知道一个原子值是否已经被真正使用
  2. 无法通过常规图解得到一个原子值存储的实际类型

这使得误用原子值的可能性大大增加,尤其在多个地方使用同一个原子值。

0.1.5.2. 建议

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

尽量不要在原子值中存储引用类型的值,因为容易造成安全漏洞。如下代码所示:

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

// 上述操作修改了切片中的值,也就修改了box6中存储的值
// 这样绕过了原子值而进行了非并发安全的操作

// 修改为这样

store := func(v []int) {
    replica := make([]int, len(v)) // 为切片值创建一个副本,副本涉及的数据与原值毫不相关
    copy(replica, v)
    box6.Store(replica)    // 把副本存储到box6中
}
store(v6)
v6[2] = 5 // 此处的操作是安全的。

// 修改切片中的值不会修改box6中的值

0.2. 原子操作与互斥所的区别

  • 互斥锁是一种数据结构,用来让一个线程执行程序的关键部分,完成互斥的多个操作。
  • 原子操作是针对某个值的单个互斥操作,这意味着没有其他线程可以打断它。

原子操作的优势,更轻量:比如CAS可以在不形成临界区和创建互斥量的情况下完成并发安全的值替换操作。这可以大大的减少同步对程序性能的损耗。

原子操作的劣势:比如CAS操作的做法趋于乐观,总是假设被操作值未曾被改变(即与旧值相等),并一旦确认这个假设的真实性就立即进行值替换,那么在被操作值被频繁变更的情况下,CAS操作并不那么容易成功。

把互斥锁理解为悲观锁,共享资源每次只给一个线程使用,其它线程阻塞,用完后再把资源转让给其它线程。

atomic包提供了底层的原子性内存原语,这对于同步算法的实现很有用。这些函数一定要非常小心地使用,使用不当反而会增加系统资源的开销。

对于应用层来说,最好使用channelsync包中提供的功能来完成同步操作。

上次修改: 25 November 2019