go原子操作,原子性增加值,简易自旋锁实现,无符号整数减法操作,CAS,ABA问题,互斥锁,内存屏障

go原子操作

go原子性用法

用法示例1:原子性增加值

package main

import (
  "fmt"
  "sync"
  "sync/atomic"
)

func main() {
  var count int32
  var wg sync.WaitGroup

  for i := 0; i < 10; i++ {
    wg.Add(1)
    go func() {
      atomic.AddInt32(&count, 1) // 原子性增加值
      wg.Done()
    }()
    go func() {
      fmt.Println(atomic.LoadInt32(&count)) // 原子性加载
    }()
  }
  wg.Wait()
  fmt.Println("count: ", count)
}

用法示例2:简易自旋锁实现

package main

import (
  "sync/atomic"
)

type spin int64

func (l *spin) lock() bool {
  for {
    if atomic.CompareAndSwapInt64((*int64)(l), 0, 1) {
      return true
    }
    continue
  }
}

func (l *spin) unlock() bool {
  for {
    if atomic.CompareAndSwapInt64((*int64)(l), 1, 0) {
      return true
    }
    continue
  }
}

func main() {
  s := new(spin)

  for i := 0; i < 5; i++ {
    s.lock()
    go func(i int) {
      println(i)
      s.unlock()
    }(i)
  }
  for {

  }
}

用法示例3: 无符号整数减法操作

对于Uint32和Uint64类型Add方法第二个参数只能接受相应的无符号整数,atomic包没有提供减法SubstractT操作:

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

对于无符号整数V,我们可以传递-V给AddT方法第二个参数就可以实现减法操作。

package main

import (
  "sync/atomic"
)

func main() {
  var i uint64 = 100
  var j uint64 = 10
  var k = 5
  atomic.AddUint64(&i, -j)
  println(i)
  atomic.AddUint64(&i, -uint64(k))
  println(i)
  // 下面这种操作是不可以的,会发生恐慌:constant -5 overflows uint64
  // atomic.AddUint64(&i, -uint64(5))
}

原子操作介绍

原子操作是指一次操作是不可被打断分割的(非原子操作,比如我们自己写的一个函数执行可能是会被在某个语句中断一会儿后接着继续执行的),这里说的原子操作专门指需要依赖CPU硬件指令提供的方式

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

CAS

CAS(Compare And Swap),这个其实是一个CPU指令其作用是让CPU比较内存中某个值是否和预期的值相同,如果相同则将这个值更新为新值,不相同则不做更新,由CPU保证这个过程的原子性。这是一个非常底层的函数,用在并发场景中非常有用。一般用来在并发场景中尝试修改值,也是自旋锁的底层。

CAS的缺点

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

ABA问题

在多线程场景下CAS会出现ABA问题,关于ABA问题这里简单科普下,例如有2个线程同时对同一个值(初始值为A)进行CAS操作,这三个线程如下

  1. 线程1,期望值为A,欲更新的值为B
  2. 线程2,期望值为A,欲更新的值为B

线程1抢先获得CPU时间片,而线程2因为其他原因阻塞了,线程1取值与期望的A值比较,发现相等然后将值更新为B,然后这个时候出现了线程3,期望值为B,欲更新的值为A,线程3取值与期望的值B比较,发现相等则将值更新为A,此时线程2从阻塞中恢复,并且获得了CPU时间片,这时候线程2取值与期望的值A比较,发现相等则将值更新为B,虽然线程2也完成了操作,但是线程2并不知道值已经经过了A->B->A的变化过程。

ABA****问题带来的危害:(两个线程减,一个线程加)小明在提款机,提取了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操作。

互斥锁

互斥锁:一个变量n两个线程持续不断的将n的值增加1,为避免并发问题,用一把锁来保护变量n,锁的本质也是一个变量,初始值置为0,若能够原子性的将锁的值由0置为1,就可以获得锁,然后操作变量n,操作结束后原子性将锁的值由1置为0,就可以释放锁,但现在有两个线程都会尝试获得锁,而同一时刻只有一个能够成功,另一个没获取锁的循环获取锁,直到能获取锁而已。

另一个线程循环获取锁会增加CPU负担,增加pause来解决,循环30次获取不到就不获取了,程序运行的时间会明显提高。

锁是一个逻辑上的概念,本质上也是一个变量,锁的底层是互斥量和CAS;CAS我们前面已经介绍过了,他的底层是原子操作。

互斥:是指某一资源同时只允许一个访问者对其进行访问,具有唯一性排它性。但互斥无法限制访问者对资源的访问顺序,即访问是无序的。互斥是在操作系统级别提供的多线程对共享资源的访问机制,没有竞争到访问权的线程会被挂起,等资源被释放后线程又被恢复,整个过程是操作系统的调度机制实现的,线程挂起恢复虽然比进程要快但在高并发场景来讲还是太慢。

一般来讲我们说锁都是指操作系统级别通过互斥来进行调度的方式,自旋锁是特指依赖CAS进行资源抢占的方式(也有的地方把CAS自旋这种叫做无锁设计,概念比较混乱)。而Java语言中直接使用互斥锁比较重,在某些场景下可以在JVM层面做一些轻量级的调度,所以它创造了很多概念。所以重量级锁就是synchronized关键字,底层是互斥锁。偏向锁、轻量级锁、自旋锁底层都是CAS。

原子操作与互斥锁的区别

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

原子操作也有劣势。还是以CAS操作为例,使用CAS操作的做法趋于乐观,总是假设被操作值未曾被改变(即与旧值相等),并一旦确认这个假设的真实性就立即进行值替换,那么在被操作值被频繁变更的情况下,CAS操作并不那么容易成功。而使用互斥锁的做法则趋于悲观,我们总假设会有并发的操作要修改被操作的值,并使用锁将相关操作放入临界区中加以保护。

下面是几点区别:

  • 互斥锁是一种数据结构,用来让一个线程执行程序的关键部分,完成互斥的多个操作
  • 可以把互斥锁理解为悲观锁,共享资源每次只给一个线程使用,其它线程阻塞,用完后再把资源转让给其它线程
  • 原子操作是无锁的,常常直接通过CPU指令直接实现
  • 原子操作中的cas趋于乐观锁,CAS操作并不那么容易成功,需要判断,然后尝试处理

atomic包提供了底层的原子性内存原语,这对于同步算法的实现很有用。这些函数一定要非常小心地使用,使用不当反而会增加系统资源的开销,对于应用层来说,最好使用通道或sync包中提供的功能来完成同步操作。

内存屏障

在编译器层面也会对我们写的代码做优化导致CPU看到的指令顺序跟我们写的代码顺序并不完全是一致的,这就也会导致多核执行情况下,数据不一致问题。而内存屏障也是解决这些问题的一种手段,各个语言封装底层指令,强制CPU指令按照代码写的顺序执行

在上文中可以看到为提供缓冲命中和减少与内存通信频率,CPU做了各种优化策略,有的会给我们带来一些问题,比如某个核心更新了数据之后,如果没有进行原子操作会导致各个核心在L1中的数据不一致问题。

内存屏障另一个作用是强制更新CPU的缓存,比如一个写屏障指令会把这个屏障前写入的数据更新到缓存中,这样任何后面试图读取该数据的线程都将得到最新值。

程序执行

代码编写,编译器编译成可执行文件,作为进程运行在物理内存,物理内存映射到虚拟内存(连续完整的地址空间),cpu运算,操作系统线程调度、程序结束等。

参考

https://www.cnblogs.com/dojo-lzz/p/16183006.html

https://www.cyub.vip/2021/04/05/Golang源码分析系列之atomic底层实现/

https://juejin.cn/post/6844903796129136647

https://www.cnblogs.com/ricklz/p/13648859.html

posted @ 2022-05-27 17:46  凌易说-lingyisay  阅读(469)  评论(0编辑  收藏  举报