Go中并发控制常用方法

简介

如果想程序少 panicgoroutine 并发读写同一个变量就需要加锁,这应该是深入到我们的习惯中。

但是总有人以为,不加锁导致的问题最多就是读取的数据是修改前的数据(数据不一致性),不能保证原子性而已。 是这样的吗? 其实这些都是典型的误解。

在 Go(甚至是大部分语言)中,一条普通的赋值语句其实并不是一个原子操作(语言规范同样没有定义 i++ 是原子操作, 任何变量的赋值都不是原子操作)。例如,在 32 位机器上写 int64 类型的变量是有中间状态的,它会被拆成两次写操作 MOV —— 写低 32 位和写高 32 位。

在 Go 的内存模型中,有 race 的 Go 程序的行为是未定义行为,理论上出现什么情况都是正常的。

undefined behavior: 未定义行为是指执行某种计算机代码所产生的结果,这种代码在当前程序状态下的行为在其所使用的语言标准中没有规定。在 Go 的内存模型中,有 race 的 Go 程序的行为是未定义行为

Go语言标准库中的 sync/atomic 包提供了偏底层的原子内存原语(atomic memory primitives),用于实现同步算法,其本质是将底层CPU提供的原子操作指令封装成了Go函数。

Go 语言在 1.4 版本后向sync/atomic包中添加了一个新的类型Value。此类型的值相当于一个容器,可以被用来原子的存储(Store)和加载(Load)任意类型的值。

5种类型的原子操作

下面为大家介绍5种常见类型的原子操作。

Add 操作

当需要添加的 delta 值为负数的时候,做减法,正数做加法

func AddInt32(addr *int32, delta int32) (new int32)
func AddInt64(addr *int64, delta int64) (new int64)
func AddUint32(addr *uint32, delta uint32) (new uint32)
func AddUint64(addr *uint64, delta uint64) (new uint64)
func AddUintptr(addr *uintptr, delta uintptr) (new uintptr)

CompareAndSwap 操作

操作仅支持int32, int64, uint32, uint64, uintptr, unsafe.Pointer这6种基本数据类型,对应有6个compare-and-swap操作函数。

func CompareAndSwapInt32(addr *int32, old, new int32) (swapped bool)
func CompareAndSwapInt64(addr *int64, old, new int64) (swapped bool)
func CompareAndSwapPointer(addr *unsafe.Pointer, old, new unsafe.Pointer) (swapped bool)
func CompareAndSwapUint32(addr *uint32, old, new uint32) (swapped bool)
func CompareAndSwapUint64(addr *uint64, old, new uint64) (swapped bool)
func CompareAndSwapUintptr(addr *uintptr, old, new uintptr) (swapped bool)

函数会对 会先比较传入的地址的值是否是 old,如果是的话就尝试赋新值,如果不是的话就直接返回 false,返回 true 时表示赋值成功。

该操作通过将内存中的值与指定数据进行比较,当数值一样时将内存中的数据替换为新的值。

Go中的CAS操作是借用了CPU提供的原子性指令来实现。CAS操作修改共享变量时候不需要对共享变量加锁,而是通过类似乐观锁的方式进行检查,本质还是不断的占用CPU 资源换取加锁带来的开销(比如上下文切换开销)。

bool Cas(int *val, int old, int new)
 Atomically:
    if(*val == old){
        *val = new;
        return 1;
    } else {
        return 0;
    }

使用Demo:

func main() {
    var destination int32 = 1
    oldValue := atomic.LoadInt32(&destination)
    var source int32 = 2
    // 先比较  &destination 值和 oldValue 值,如果相等,就把dst的值替换为newValue
    swapped := atomic.CompareAndSwapInt32(&destination, 1, source)
    // 打印结果, old value: 1, destination value: 2, swapped success: true
    fmt.Printf("old value: %d, destination value: %d, swapped success: %v\n", oldValue, destination, swapped)
}

Load 操作

load操作实现的功能是返回addr指针指向的内存里的值。

func LoadInt32(addr *int32) (val int32)
func LoadInt64(addr *int64) (val int64)
func LoadPointer(addr *unsafe.Pointer) (val unsafe.Pointer)
func LoadUint32(addr *uint32) (val uint32)
func LoadUint64(addr *uint64) (val uint64)
func LoadUintptr(addr *uintptr) (val uintptr)

Store 操作

给某个指针地址赋值

func StoreInt32(addr *int32, val int32)
func StoreInt64(addr *int64, val int64)
func StorePointer(addr *unsafe.Pointer, val unsafe.Pointer)
func StoreUint32(addr *uint32, val uint32)
func StoreUint64(addr *uint64, val uint64)
func StoreUintptr(addr *uintptr, val uintptr)

Swap 操作

swap操作实现的功能是把 addr 指针指向的内存里的值替换为新值new,然后返回旧值old.

func SwapInt32(addr *int32, new int32) (old int32)
func SwapInt64(addr *int64, new int64) (old int64)
func SwapPointer(addr *unsafe.Pointer, new unsafe.Pointer) (old unsafe.Pointer)
func SwapUint32(addr *uint32, new uint32) (old uint32)
func SwapUint64(addr *uint64, new uint64) (old uint64)
func SwapUintptr(addr *uintptr, new uintptr) (old uintptr)

小结

这几种类型的原子操作只支持几个基本的数据类型。

Add操作只支持 int32, int64, uint32, uint64, uintptr 这5种基本数据类型。(原因是GO中不支持指针运算)

其它类型的操作函数只支持int32, int64, uint32, uint64, uintptr, unsafe.Pointer这6种基本数据类型。

Value类型

Go标准库里的sync/atomic包提供了Value类型,可以用来并发读取和修改任何类型的值。

Value类型有4个方法:CompareAndSwap, Load, Store, Swap,定义如下:

func (v *Value) CompareAndSwap(old, new any) (swapped bool)
func (v *Value) Load() (val any)
func (v *Value) Store(val any)
func (v *Value) Swap(new any) (old any)

CAS 详解

其作用是让 CPU 比较内存中某个值是否和预期的值相同,如果相同则将这个值更新为新值,不相同则不做更新,也就是CAS是原子性的操作(读和写两者同时具有原子性),调用CPU指令完成的,所以效率很高。

CAS虽然高效的实现了原子性操作,但是也存在一些缺点,主要表现在以下三个方面。

ABA问题(从银行交易来讲)

例如:

  • A从自动取款机,取50元,因为提款机问题,有两个线程,同时把余额从100变为50
  • 线程1(提款机):获取当前值100,期望更新为50,
  • 线程2(提款机):获取当前值100,期望更新为50,

事件触发:线程1成功执行,线程2某种原因block了,这时,某人给小明汇款50

  • 线程3(默认):获取当前值50,期望更新为100,
    这时候线程3成功执行,余额变为100,

  • 线程2 从Block中恢复,获取到的也是100,compare之后,继续更新余额为50!!!

此时可以看到,实际余额应该为100(100-50+50),但是实际上变为了50(100-50+50-50)这就是ABA问题带来的问题。

通常解决方法:在变量前面加上版本号,每次变量更新的时候变量的版本号都+1,即A->B->A就变成了1A->2B->3A。

循环时间长开销大

如果CAS操作失败,就需要循环进行CAS操作(循环同时将期望值更新为最新的),如果长时间都不成功的话,那么会造成 CPU 极大的开销。 (这种循环也称为自旋循环)

解决方法: 限制自旋次数,防止进入死循环。

只能保证一个共享变量的原子操作

CAS的原子操作只能针对一个共享变量。

解决方法: 如果需要对多个共享变量进行操作,可以使用加锁方式(悲观锁)保证原子性,或者可以把多个共享变量合并成一个共享变量进行CAS操作。

总结

解决 race 的问题时,无非就是上锁。

可能很多人都听说过一种「无锁队列」(甚至面试时还经常遇到写无锁队列)。 听到加锁就觉得很 low,那无锁又是怎么实现的?

其实就是利用 atomic 特性,那 atomic 会比 mutex 有什么好处呢?go race detector 的作者总结了这两者的一个区别:

Mutexes do no scale. Atomic loads do.

mutex 由操作系统实现,atomic 包中的原子操作则由底层硬件直接提供支持。 在 CPU 实现的指令集里,有一些指令被封装进了 atomic 包,这些指令在执行的过程中是不允许中断(interrupt),因此原子操作可以在 lock-free 的情况下保证并发安全。 并且它的性能也能做到随 CPU 个数的增多而线性扩展。 锁则由操作系统的调度器实现。

  • mutexatomic 区别是什么?

锁应当用来保护一段逻辑,对于一个变量更新的保护,原子操作通常会更有效率,并且更能利用计算机多核的优势,如果要更新的是一个复合对象,则应当使用atomic.Value封装好的实现。

  1. 实现方式对比: atomic 是对 CPU 底层进行原子操作,不能通过程序干预。而 mutex 则是在语言层面的,操作自由度较高。 原子操作由底层硬件支持,而锁则由操作系统的调度器实现。

  2. 程序执行对比:atomic 因为其在底层就已封装好的特性,所以它在 goroutine 下的运行表现是连续不间断的;而 mutex 则在 goroutine 运行间由于锁的等待或持有等情况,断断续续地执行。

  3. 数据保护的性能对比:atomic总体运行较快,但是如果存储数据的非常巨大,它的性能会大打折扣。因为每次更新atomic的数据,都会进行一次数据复制,数据越大效率下降越大。

  4. 使用场景对比:锁应当用来保护一段逻辑,对于一个变量更新的保护,原子操作通常会更有效率,并且更能利用计算机多核的优势,如果要更新的是一个复合对象,则应当使用atomic.Value封装好的实现。

  5. 互斥锁是一种数据结构,用来让一个线程执行程序的关键部分,完成互斥的多个操作。原子操作是针对某个值的单个互斥操作。 可以把互斥锁理解为悲观锁,共享资源每次只给一个线程使用,其它线程阻塞,用完后再把资源转让给其它线程。

  • 互斥锁 Mutex 与 读写锁(RWMutex)有什么区别? 在什么情况下应该使用Mutex,而在什么情况下应该使用RWMutex

Mutex只允许一个goroutine同时获得锁,适用于需要频繁修改共享资源的场景;
RWMutex允许多个goroutine同时获得读锁,但只允许一个goroutine获得写锁,适用于需要频繁读取共享资源的场景。

  • Go语言中有哪些同步原语和并发安全的数据结构?

除了Mutex互斥锁,Go语言中还有其他的同步原语和并发安全的数据结构,如读写锁(RWMutex)、条件变量(Cond)、原子操作(atomic包)、通道(Channel)等。 可以根据具体的需求和场景,可以选择合适的并发原语来实现并发控制和数据同步。

  • sync/atomic 提供的原子操作可以确保在任意时刻只有一个goroutine对变量进行操作,避免并发冲突。

  • sync/atomic 官方建议只有在一些偏底层的应用场景里才去使用sync/atomic,其它场景建议使用channel或者sync包里的锁。

参考文章

关于我
loading