Go 中的原子操作 sync/atomic

用原子操作来替换 mutex 锁其主要原因是:原子操作由底层硬件支持,而锁则由操作系统提供的 API 实现。若实现相同的功能,前者通常会更有效率。

并发安全中的原子操作

对于并发操作而言,原子操作是个非常现实的问题。典型的就是 i++ 的问题。当两个 CPU 同时对内存中的i进行读取,然后把加一之后的值放入内存中,可能两次 i++ 的结果,这个 i 只增加了一次。

为了保证并发安全,除了使用临界区之外,还可以使用原子操作。顾名思义这类操作满足原子性,其执行过程不能被中断,这也就保证了同一时刻一个线程的执行不会被其他线程中断,也保证了多线程下数据操作的一致性。

原子操作即是进行过程中不能被中断的操作。也就是说,针对某个值的原子操作在被进行的过程当中,CPU 绝不会再去进行其它的针对该值的操作。无论这些其它的操作是否为原子操作都会是这样。为了实现这样的严谨性,原子操作仅会由一个独立的 CPU 指令代表和完成。只有这样才能够在并发环境下保证原子操作的绝对安全。

如果我们善用原子操作,它会比锁更为高效。

Go 中 sync/atomic 包的学习与使用

  • Go 语言提供的原子操作都是非入侵式的,由标准库 sync/atomic 中的众多函数代表
  • atomic 包提供了底层的原子级内存操作,类型共有六种:int32, int64, uint32, uint64, uintptr, unsafe.Pinter
  • 对于每一种类型,提供了五类原子操作分别是:
    • Add, 增加和减少
    • CompareAndSwap, 比较并交换
    • Swap, 交换
    • Load, 读取
    • Store, 存储

增加或减少 Add

  • 被操作的类型只能是数值类型 int32,int64,uint32,uint64,uintptr
  • 第一个参数值必须是一个指针类型的值,以便施加特殊的 CPU 指令。
  • 第二个参数值的类型和第一个被操作值的类型总是相同的,传递一个正整数增加值,负整数减少值。

函数会直接在传递的地址上进行修改操作,此外函数会返回修改之后的新值。需要注意的是当你处理 unint32 和 unint64 时,由于 delta 参数类型被限定,不能直接传输负数,所以需要利用二进制补码机制,其中 N 为需要减少的正整数值。

var b uint32
b += 20
// atomic.Adduint32(&b, ^uint32(N-1))
atomic.AddUint32(&b, ^uint32(10-1)) // 等价于 b -= 10
fmt.Println(b == 10) // true

比较并交换 CAS

原子操作中最经典的 CAS 问题。

CAS 的意思是判断内存中的某个值是否等于 old 值,如果是的话,则赋 new 值给这块内存。CAS 是一个方法,并不局限在 CPU 原子操作中。 CAS 比互斥锁乐观,但是也就代表 CAS 是有赋值不成功的时候,调用 CAS 的那一方就需要处理赋值不成功的后续行为了,比如 用 for 循环不断进行尝试,直到成功为止。

CAS 类似乐观锁,总是假设被操作值未曾被改变(即与旧值相等),并一旦确认这个假设的真实性就立即进行值替换。 而互斥锁是悲观锁总假设会有并发的操作要修改被操作的值,并使用锁将相关操作放入临界区中加以保护。

  • 调用函数后,会先判断参数 addr 指向的被操作值与参数 old 的值是否相等
  • 仅当此判断得到肯定的结果之后,才会用参数 new 代表的新值替换掉原先的旧值,否则操作就会被忽略。
var value int32

func main()  {
    fmt.Println("======old value=======")
    fmt.Println(value)
    fmt.Println("======CAS value=======")
    addValue(3)
    fmt.Println(value)

}

//不断地尝试原子地更新value的值,直到操作成功为止
func addValue(delta int32){
    //在被操作值被频繁变更的情况下,CAS操作并不那么容易成功
    //so 不得不利用for循环以进行多次尝试
    for {
        v := value
        if atomic.CompareAndSwapInt32(&value, v, (v + delta)){
            //在函数的结果值为true时,退出循环
            break
        }
        //操作失败的缘由总会是value的旧值已不与v的值相等了.
        //CAS操作虽然不会让某个Goroutine阻塞在某条语句上,但是仍可能会使流产的执行暂时停一下,不过时间大都极其短暂.
    }
}

读取和写入 Load and Store

许多变量的读写无法在一个时钟周期内完成,而此时执行可能会被调度到其他线程,无法保证并发安全。

当我们要读取一个变量的时候,很有可能这个变量正在被写入,这时我们就很有可能读取到写到一半的数据,所以读取操作是需要一个原子行为的。如果有多个 CPU 往内存中一个数据块写入数据的时候,可能导致这个写入的数据不完整。

  • 在原子地存储某个值的过程中,任何 CPU 都不会进行针对同一个值的读或写操作。
  • 原子的值存储操作总会成功,因为它并不会关心被操作值的旧值是什么。
  • 和 CAS 操作有着明显的区别。

交换 Swap

  • 与 CAS 操作不同,原子交换操作不会关心被操作的旧值。
  • 它会直接设置新值,并返回被操作值的旧值。
  • 此类操作比 CAS 操作的约束更少,同时又比原子载入操作的功能更强。

atomic.Value

适用于读多写少并且变量占用内存不是特别大的情况,如果用内存存储大量数据,这个并不适合,技术上主要是常见的写时复制 copy-on-write。

详见这里golang value并发安全的另一种玩法

相关文章

如果觉得我的文章对您有用,请在支付宝公益平台找个项目捐点钱。 @Victor Apr 17, 2019

奉献爱心