Golang 原子操作

开篇

并发是业务开发中经常要面对的问题,很多时候我们会直接用一把 sync.Mutex 互斥锁来线性化处理,保证每一时刻进入临界区的 goroutine 只有一个。这样避免了并发,但性能也随着降低。

所以,我们进而又有了 RWMutex 读写锁,保障了多个读请求的并发处理,对共享资源的写操作和读操作则区别看待,并消除了读操作之间的互斥。

回忆下我们此前对 Mutex 和 RWMutex 原理的解析,锁的实现本身还是基于 atomic 包提供的原子操作,辅之以自旋等处理。很多时候其实我们不太需要【锁住资源】这个语意,而是一个【原子操作】就 ok。这篇文章我们来看一下 atomic 包提供的能力。

原子操作

所谓原子操作,关键在于是否能被中断。Golang 的调度器同时可能需要处理成百上千个 goroutine 的运行,即便 CPU 是多核的,同时运行的协程数量,也大体上和 GMP 调度模型中的 M(系统线程)保持一致。

所以,还是那句老话,并发是一种假象,并不是大家在每个时刻都一起执行,而是分时控制。比如单核的场景,CPU 在执行一个goroutine 一段时间后,切换到另一个goroutine,过段时间再切换回来。

程序代码最终会被翻译为 CPU 指令,这一点很关键。一个稀松平常的 a := 1 赋值语句,底层也会被拆为多条 CPU 指令。注意,一旦是多条 CPU 指令,意味着什么呢?

它意味着,可能执行一半就会被调度器切走,pause 在这个地方。当前 goroutine 就进入非运行态,调度其他的 goroutine 去了。如果此时写的变量可以被其他 goroutine 读到,就会读到不完整的数据。

此时数据可能处于任何状态,这也是为什么,Golang 的内存模型建议大家,好好用锁,用 atomic,"Don't be clever",因为并发的bug通常都是很难复现的,一旦出现对生产环境可能产生很严重的事故。

原子操作本质上是由 CPU 提供的芯片级别的支持,原子操作在进行的过程中是不允许中断的。即使在拥有多 CPU 核心,或者多 CPU 的计算机系统中,原子操作的保证也是可以信任的。这也是为什么 Mutex 和 RWMutex 都基于原子操作来实现。

操作系统层面只对针对二进制位或整数的原子操作提供了支持。通常其实不同位的计算机,或者操作系统,提供的原子能力是有区别的。但 Golang 运行时帮助我们屏蔽了这些困扰,只需要使用 atomic 包中的函数即可实现原子操作。

对于单处理器单核系统来说,如果一个操作是由一个 CPU 指令来实现的,那么它就是原子操作,比如它的 XCHG 和 INC 等指令。如果操作是基于多条指令来实现的,那么,执行的过程中可能会被中断,并执行上下文切换,这样的话,原子性的保证就被打破了,因为这个时候,操作可能只执行了一半。

在多处理器多核系统中,原子操作的实现就比较复杂了。由于 cache 的存在,单个核上的单个指令进行原子操作的时候,你要确保其它处理器或者核不访问此原子操作的地址,或者是确保其它处理器或者核总是访问原子操作之后的最新的值。x86 架构中提供了指令前缀 LOCK,LOCK 保证了指令(比如 LOCK CMPXCHG op1、op2)不会受其它处理器或 CPU 核的影响,有些指令(比如 XCHG)本身就提供 Lock 的机制。不同的 CPU 架构提供的原子操作指令的方式也是不同的,比如对于多核的 MIPS 和 ARM,提供了 LL/SC(Load Link/Store Conditional)指令,可以帮助实现原子操作(ARMLL/SC 指令 LDREX 和 STREX)。

事实上如果你看 atomic 包的官方代码注释也会发现,目前在一些架构上 atomic 是存在bug的,需要注意:

// BUG(rsc): On 386, the 64-bit functions use instructions unavailable before the Pentium MMX.
//
// On non-Linux ARM, the 64-bit functions use instructions unavailable before the ARMv6k core.
//
// On ARM, 386, and 32-bit MIPS, it is the caller's responsibility
// to arrange for 64-bit alignment of 64-bit words accessed atomically.
// The first word in a variable or in an allocated struct, array, or slice can
// be relied upon to be 64-bit aligned.
复制代码

atomic 包

sync/atomic包中的函数可以做的原子操作有:加法(add)、比较并交换(compare and swap,简称 CAS)、加载(load)、存储(store)和交换(swap)。

这些函数针对的数据类型并不多。但是,对这些类型中的每一个,sync/atomic包都会有一套函数给予支持。这些数据类型有:int32、int64、uint32、uint64、uintptr,以及unsafe包中的Pointer。不过,针对unsafe.Pointer类型,该包并未提供进行原子加法操作的函数。

此外,sync/atomic包还提供了一个名为Value的类型,它可以被用来存储任意类型的值。

能力其实直接参考源码即可,这里的实现在 runtime,直接看 src 下的 atomic 包只作为文档使用:

抓住一点即可:

第一个参数 addr 就是你要修改的指针。

(传值是没有意义的,因为 Golang 是传值而不是引用,意味着你如果传了个 int32 类型的变量作为入参到一个函数,本质是 copy,你是无法修改原来的值的)

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

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

package atomic

import (
	"unsafe"
)

// BUG(rsc): On 386, the 64-bit functions use instructions unavailable before the Pentium MMX.
//
// On non-Linux ARM, the 64-bit functions use instructions unavailable before the ARMv6k core.
//
// On ARM, 386, and 32-bit MIPS, it is the caller's responsibility
// to arrange for 64-bit alignment of 64-bit words accessed atomically.
// The first word in a variable or in an allocated struct, array, or slice can
// be relied upon to be 64-bit aligned.

// SwapInt32 atomically stores new into *addr and returns the previous *addr value.
func SwapInt32(addr *int32, new int32) (old int32)

// SwapInt64 atomically stores new into *addr and returns the previous *addr value.
func SwapInt64(addr *int64, new int64) (old int64)

// SwapUint32 atomically stores new into *addr and returns the previous *addr value.
func SwapUint32(addr *uint32, new uint32) (old uint32)

// SwapUint64 atomically stores new into *addr and returns the previous *addr value.
func SwapUint64(addr *uint64, new uint64) (old uint64)

// SwapUintptr atomically stores new into *addr and returns the previous *addr value.
func SwapUintptr(addr *uintptr, new uintptr) (old uintptr)

// SwapPointer atomically stores new into *addr and returns the previous *addr value.
func SwapPointer(addr *unsafe.Pointer, new unsafe.Pointer) (old unsafe.Pointer)

// CompareAndSwapInt32 executes the compare-and-swap operation for an int32 value.
func CompareAndSwapInt32(addr *int32, old, new int32) (swapped bool)

// CompareAndSwapInt64 executes the compare-and-swap operation for an int64 value.
func CompareAndSwapInt64(addr *int64, old, new int64) (swapped bool)

// CompareAndSwapUint32 executes the compare-and-swap operation for a uint32 value.
func CompareAndSwapUint32(addr *uint32, old, new uint32) (swapped bool)

// CompareAndSwapUint64 executes the compare-and-swap operation for a uint64 value.
func CompareAndSwapUint64(addr *uint64, old, new uint64) (swapped bool)

// CompareAndSwapUintptr executes the compare-and-swap operation for a uintptr value.
func CompareAndSwapUintptr(addr *uintptr, old, new uintptr) (swapped bool)

// CompareAndSwapPointer executes the compare-and-swap operation for a unsafe.Pointer value.
func CompareAndSwapPointer(addr *unsafe.Pointer, old, new unsafe.Pointer) (swapped bool)

// AddInt32 atomically adds delta to *addr and returns the new value.
func AddInt32(addr *int32, delta int32) (new int32)

// AddUint32 atomically adds delta to *addr and returns the new value.
// To subtract a signed positive constant value c from x, do AddUint32(&x, ^uint32(c-1)).
// In particular, to decrement x, do AddUint32(&x, ^uint32(0)).
func AddUint32(addr *uint32, delta uint32) (new uint32)

// AddInt64 atomically adds delta to *addr and returns the new value.
func AddInt64(addr *int64, delta int64) (new int64)

// AddUint64 atomically adds delta to *addr and returns the new value.
// To subtract a signed positive constant value c from x, do AddUint64(&x, ^uint64(c-1)).
// In particular, to decrement x, do AddUint64(&x, ^uint64(0)).
func AddUint64(addr *uint64, delta uint64) (new uint64)

// AddUintptr atomically adds delta to *addr and returns the new value.
func AddUintptr(addr *uintptr, delta uintptr) (new uintptr)

// LoadInt32 atomically loads *addr.
func LoadInt32(addr *int32) (val int32)

// LoadInt64 atomically loads *addr.
func LoadInt64(addr *int64) (val int64)

// LoadUint32 atomically loads *addr.
func LoadUint32(addr *uint32) (val uint32)

// LoadUint64 atomically loads *addr.
func LoadUint64(addr *uint64) (val uint64)

// LoadUintptr atomically loads *addr.
func LoadUintptr(addr *uintptr) (val uintptr)

// LoadPointer atomically loads *addr.
func LoadPointer(addr *unsafe.Pointer) (val unsafe.Pointer)

// StoreInt32 atomically stores val into *addr.
func StoreInt32(addr *int32, val int32)

// StoreInt64 atomically stores val into *addr.
func StoreInt64(addr *int64, val int64)

// StoreUint32 atomically stores val into *addr.
func StoreUint32(addr *uint32, val uint32)

// StoreUint64 atomically stores val into *addr.
func StoreUint64(addr *uint64, val uint64)

// StoreUintptr atomically stores val into *addr.
func StoreUintptr(addr *uintptr, val uintptr)

// StorePointer atomically stores val into *addr.
func StorePointer(addr *unsafe.Pointer, val unsafe.Pointer)

复制代码

最简易的自旋锁

CAS 是经典的模式了,Compare And Swap,只有在【当前的值等于 old 时,赋值为 new】,如果你看过 Mutex 和 RWMutex 源码的话就会发现,其实锁的实现也是高度依赖 atomic 的 CAS 函数的。

这里我们可以用 CAS 做一个简易的自旋锁(乐观锁)。实现的效果:

  1. 只有第一个来获取锁的能成功(即 Swap),其他的由于 old 对不上,所以 CAS 的返回值 swapped 为 false;
  2. 无法获取锁的 goroutine 进入自旋(也就是先休眠,过一段时间继续探测能否获取锁)。

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

atomic.Value

Golang 在 1.4 的时候就添加了对于 atomic.Value 的支持,看过源码你会发现它就是一个空的 interface{},所以 Value 的本质是个容器,可以被用来“原子地”存储和加载任意的值,并且是开箱即用的。

// A Value provides an atomic load and store of a consistently typed value.
// The zero value for a Value returns nil from Load.
// Once Store has been called, a Value must not be copied.
//
// A Value must not be copied after first use.
type Value struct {
  v any
}

// ifaceWords is interface{} internal representation.
type ifaceWords struct {
  typ  unsafe.Pointer
  data unsafe.Pointer
}

// Load returns the value set by the most recent Store.
// It returns nil if there has been no call to Store for this Value.
func (v *Value) Load() (val any) {
  vp := (*ifaceWords)(unsafe.Pointer(v))
  typ := LoadPointer(&vp.typ)
  if typ == nil || typ == unsafe.Pointer(&firstStoreInProgress) {
    // First store not yet completed.
    return nil
  }
  data := LoadPointer(&vp.data)
  vlp := (*ifaceWords)(unsafe.Pointer(&val))
  vlp.typ = typ
  vlp.data = data
  return
}

var firstStoreInProgress byte

// Store sets the value of the Value to x.
// All calls to Store for a given Value must use values of the same concrete type.
// Store of an inconsistent type panics, as does Store(nil).
func (v *Value) Store(val any) {
  if val == nil {
    panic("sync/atomic: store of nil value into Value")
  }
  vp := (*ifaceWords)(unsafe.Pointer(v))
  vlp := (*ifaceWords)(unsafe.Pointer(&val))
  for {
    typ := LoadPointer(&vp.typ)
    if typ == nil {
      // Attempt to start first store.
      // Disable preemption so that other goroutines can use
      // active spin wait to wait for completion.
      runtime_procPin()
      if !CompareAndSwapPointer(&vp.typ, nil, unsafe.Pointer(&firstStoreInProgress)) {
        runtime_procUnpin()
        continue
      }
      // Complete first store.
      StorePointer(&vp.data, vlp.data)
      StorePointer(&vp.typ, vlp.typ)
      runtime_procUnpin()
      return
    }
    if typ == unsafe.Pointer(&firstStoreInProgress) {
      // First store in progress. Wait.
      // Since we disable preemption around the first store,
      // we can wait with active spinning.
      continue
    }
    // First store completed. Check type and overwrite data.
    if typ != vlp.typ {
      panic("sync/atomic: store of inconsistently typed value into Value")
    }
    StorePointer(&vp.data, vlp.data)
    return
  }
}

复制代码

根据笔者的个人实践,atomic.Value 对于内存维护,定期更新的静态数据读取是绝佳的场景,建议看看 官方文档提供的 Copy On Write 实现。

这里维护了一个 Map,每次插入 key 时直接创建一个新的 Map,通过 atomic.Value.Store 赋值回来。读的时候用 atomic.Value.Load 转换成 Map 即可。

package main

import (
  "sync"
  "sync/atomic"
)

func main() {
  type Map map[string]string
  var m atomic.Value
  m.Store(make(Map))
  var mu sync.Mutex // used only by writers
  // read function can be used to read the data without further synchronization
  read := func(key string) (val string) {
    m1 := m.Load().(Map)
    return m1[key]
  }
  // insert function can be used to update the data without further synchronization
  insert := func(key, val string) {
    mu.Lock() // synchronize with other potential writers
    defer mu.Unlock()
    m1 := m.Load().(Map) // load current value of the data structure
    m2 := make(Map)      // create a new value
    for k, v := range m1 {
      m2[k] = v // copy all data from the current object to the new one
    }
    m2[key] = val // do the update that we need
    m.Store(m2)   // atomically replace the current object with the new one
    // At this point all new readers start working with the new version.
    // The old version will be garbage collected once the existing readers
    // (if any) are done with it.
  }
  _, _ = read, insert
}
复制代码

参照 COW

指针赋值是原子的么?

这个话题很有意思,感兴趣的同学可以看看在 wuyanyi 大佬在 go-nuts 的帖子,因为要不要加锁,和 weedfs 的作者以及B站的毛大都进行过讨论:

直接说结论,引用自鸟窝大神:

在现在的系统中,write 的地址基本上都是对齐的(aligned)。 比如,32 位的操作系统、CPU 以及编译器,write 的地址总是 4 的倍数,64 位的系统总是 8 的倍数(还记得 WaitGroup 针对 64 位系统和 32 位系统对 state1 的字段不同的处理吗)。对齐地址的写,不会导致其他人看到只写了一半的数据,因为它通过一个指令就可以实现对地址的操作。如果地址不是对齐的话,那么,处理器就需要分成两个指令去处理,如果执行了一个指令,其它人就会看到更新了一半的错误的数据,这被称做撕裂写(torn write) 。所以,你可以认为赋值操作是一个原子操作,这个“原子操作”可以认为是保证数据的完整性。

但是,这并不代表 atomic 包没有意义,因为写归写,你写了不一定能被看到。

由于 cache、指令重排,可见性等问题,我们对原子操作的意义有了更多的追求。

在多核系统中,一个核对地址的值的更改,在更新到主内存中之前,是在多级缓存中存放的。这时,多个核看到的数据可能是不一样的,其它的核可能还没有看到更新的数据,还在使用旧的数据。

多处理器多核心系统为了处理这类问题,使用了一种叫做内存屏障(memory fence 或 memory barrier)的方式。一个写内存屏障会告诉处理器,必须要等到它管道中的未完成的操作(特别是写操作)都被刷新到内存中,再进行操作。此操作还会让相关的处理器的 CPU 缓存失效,以便让它们从主存中拉取最新的值。

atomic 包提供的方法会提供内存屏障的功能,所以,atomic 不仅仅可以保证赋值的数据完整性,还能保证数据的可见性,一旦一个核更新了该地址的值,其它处理器总是能读取到它的最新值。但是,需要注意的是,因为需要处理器之间保证数据的一致性,atomic 的操作也是会降低性能的。

本文正在参加技术专题18期-聊聊Go语言框架

Guess you like

Origin juejin.im/post/7119437493547565063