详解并发编程基础之原子操作(atomic包)

Go语言中文网 今天

以下文章来源于Golang梦工厂 ,作者AsongGo

Golang梦工厂Asong是一名Golang开发工程师,专注于Golang相关技术:Golang面试、Beego、Gin、Mysql、Linux、网络、操作系统等,致力于Golang开发。

前言

最近想写一个并发编程系列的文章,使用Go也有一段时间了,但是对并发的理解不是很透彻,借着这次总结,希望能更进一步。我们以"原子操作"开篇,对于并发操作而言,原子操作是个非常现实的问题,比较典型的应用的就是i++操作,并发情况下,同时对内存中的i进行读取,就会产生与预期不符的结果,所以Go语言中的sync/atomic就是解决这个问题的,接下来我们一起来看一看Go的原子操作。

什么是原子性、原子操作

原子(atomic)本意是"不能被进一步分割的最小粒子",而原子操作(atomic operation)意为"不可中断的一个或一系列操作"。其实用大白话说出来就是让多个线程对同一块内存的操作是串行的,不会因为并发操作把内存写的不符合预期。我们来看这样一个例子:假设现在是一个银行账户系统,用户A想要自己从自己的账户中转1万元到用户B的账户上,直到转帐成功完成一个事务,主要做这两件事:

  • 从A的账户中减去1万元,如果A的账户原来就有2万元,现在就变成了1万元
  • 给B的账户添加1万元,如果B的账户原来有2万元,那么现在就变成了3万元

假设在操作一的时候,系统发生了故障,导致给B账户添加款项失败了,那么就要进行回滚。回滚就是回到事务之前的状态,我们把这种要么一起成功的操作叫做原子操作,而原子性就是要么完整的被执行、要么完全不执行。

如何保证原子性

  • 锁机制

在处理器层面,可以采用总线加锁或者对缓存加锁的方式来实现多处理器之间的原子操作。通过加锁保证从系统内存中读取或写入一个字节是原子的,也就是当一个处理器读取一个字节时,其他处理器不能访问这个字节的内存地址。

总线锁:处理器提供一个Lock#信号,当一个处理器上在总线上输出此信号时,其他处理器的请求将被阻塞住,那么该处理器可以独占共享内存。总线锁会把CPU和内存之间的通信锁住了,在锁定期间,其他处理就不能操作其他内存地址的数据,所以总线锁定的开销比较大,所以处理会在某些场合使用缓存锁进行优化。缓存锁:内存区域如果被缓存在处理器上的缓存行中,并且在Lock#操作期间,那么当它执行操作回写到内存时,处理不在总线上声言Lock#信号,而是修改内部的内存地址,并允许它的缓存一致机制来保证操作的原子性,因为缓存一致性机制会阻止同时修改由两个以上处理器缓存的内存区域的数据,其他处理器回写已被锁定的缓存行的数据时,就会使缓存无效。

锁机制虽然可以保证原子性,但是锁机制会存在以下问题:

  • 多线程竞争的情况下,频繁的加锁、释放锁会导致较多的上下文切换和调度延时,性能会很差
  • 当一个线程占用时间比较长时,就导致其他需要此锁的线程挂起.

上面我们说的都是悲观锁,要解决这种低效的问题,我们可以采用乐观锁,每次不加锁,而是假设没有冲突去完成某项操作,如果因为冲突失败就重试,直到成功为止。也就是我们接下来要说的CAS(compare and swap).

  • CAS(compare and swap)

CAS的全称为Compare And Swap,直译就是比较交换。是一条CPU的原子指令,其作用是让CPU先进行比较两个值是否相等,然后原子地更新某个位置的值,其实现方式是给予硬件平台的汇编指令,在intelCPU中,使用的cmpxchg指令,就是说CAS是靠硬件实现的,从而在硬件层面提升效率。简述过程是这样:

假设包含3个参数内存位置(V)、预期原值(A)和新值(B)。V表示要更新变量的值,E表示预期值,N表示新值。仅当V值等于E值时,才会将V的值设为N,如果V值和E值不同,则说明已经有其他线程在做更新,则当前线程什么都不做,最后CAS返回当前V的真实值。CAS操作时抱着乐观的态度进行的,它总是认为自己可以成功完成操作。基于这样的原理,CAS操作即使没有锁,也可以发现其他线程对于当前线程的干扰。

伪代码可以这样写:

func CompareAndSwap(int *addr,int oldValue,int newValue) bool{    if *addr == nil{        return false    }    if *addr == oldValue {        *addr = newValue        return true    }    return false}

不过上面的代码可能会发生一个问题,也就是ABA问题,因为CAS需要在操作值的时候检查下值有没有发生变化,如果没有发生变化则更新,但是如果一个值原来是A,变成了B,又变成了A,那么使用CAS进行检查时会发现它的值没有发生变化,但是实际上却变化了。ABA问题的解决思路就是使用版本号。在变量前面追加上版本号,每次变量更新的时候把版本号加一,那么A-B-A 就会变成1A-2B-3A。

go语言中如何进行原子操作

Go语言标准库中,sync/atomic包将底层硬件提供的原子操作封装成了Go的函数,主要分为5个系列的函数,分别是:

  • func SwapXXXX(addr *int32, new int32) (old int32)系列:其实就是原子性的将new值保存到*addr并返回旧值。代码表示:
old = *addr*addr = newreturn old
  • func CompareAndSwapXXXX((addr *int64, old, new int64) (swapped bool)系列:其就是原子性的比较*addr和old的值,如果相同则将new赋值给*addr并返回真,代码表示:
if *addr == old{    *addr = new    return ture}return false
  • func AddXXXX(addr *int64, delta int64) (new int64)系列:原子性的将val的值添加到*addr并返回新值。代码表示:
*addr += deltareturn *addr
  • func LoadXXXX(addr *uint32) (val uint32)系列:原子性的获取*addr的值
  • func StoreXXXX(addr *int32, val int32)原子性的将val值保存到*addr

Go语言在1.4版本时添加一个新的类型Value,此类型的值就相当于一个容器,可以被用来"原子地"存储(store)和加载(Load)任意类型的值。这些使用起来都还比较简单,就不写例子了,接下来我们一起看一看这些方法是如何实现的。

源码解析

由于系列比较多。底层实现的方法也大同小异,这里就主要分析一下Value的实现方法吧。为什么不分析其他系列的呢?因为原子操作由底层硬件支持,所以看其他系列实现都要看汇编,Go的汇编是基于Plan9的,这个汇编语言真的资料甚少,我也是真的不懂,水平不够,也不自讨苦吃了,等后面真的能看懂这些汇编了,再来分析吧。这个网站有一些关于plan9汇编的知识,有兴趣可以看一看:http://doc.cat-v.org/plan_9/4th_edition/papers/asm。

Value结构

我们先来看一下Value的结构:

type Value struct { v interface{}}

Value结构里就只有一个字段,是interface类型,虽然这里是interface类型,但是这里要注意,第一次Store写入的类型就确定了之后写入的类型,否则会发生panic。因为这里是interface类型,所以为了之后写入与读取操作方便,又在这个包里定义了一个ifaceWords结构,其实他就是一个空interface,他的作用就是将interface分解成类型和数值。结构如下:

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

Value的写入操作

我们一起来看一看他是如何实现写入操作的:

// 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(x interface{}) { if x == nil {  panic("sync/atomic: store of nil value into Value") } vp := (*ifaceWords)(unsafe.Pointer(v)) xp := (*ifaceWords)(unsafe.Pointer(&x)) 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; and so that   // GC does not see the fake type accidentally.   runtime_procPin()   if !CompareAndSwapPointer(&vp.typ, nil, unsafe.Pointer(^uintptr(0))) {    runtime_procUnpin()    continue   }   // Complete first store.   StorePointer(&vp.data, xp.data)   StorePointer(&vp.typ, xp.typ)   runtime_procUnpin()   return  }  if uintptr(typ) == ^uintptr(0) {   // 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 != xp.typ {   panic("sync/atomic: store of inconsistently typed value into Value")  }  StorePointer(&vp.data, xp.data)  return }}

// Disable/enable preemption, implemented in runtime.func runtime_procPin()func runtime_procUnpin()

这段代码中的注释集已经告诉了我们,调用Store方法写入的类型必须与原类型相同,不一致便会发生panic。接下来分析代码实现:

  1. 首先判断条件写入参数不能为nil,否则触发panic
  2. 通过使用unsafe.PointeroldValuenewValue转换成ifaceWords类型。方便我们获取他的原始类型(typ)和值(data).
  3. 为了保证原子性,所以这里使用一个for换来处理,当已经有Store正在进行写入时,会进行等待.
  4. 如果还没写入过数据,那么获取不到原始类型,就会开始第一次写入操作,这里会把先调用runtime_procPin()方法禁止调度器对当前 goroutine 的抢占(preemption),这样也可以防止GC线程看到假类型。
  5. 调用CAS方法来判断当前地址是否有被抢占,这里大家可能对unsafe.Pointer(^uintptr(0))这一句话有点不明白,因为是第一个写入数据,之前是没有数据的,所以通过这样一个中间值来做判断,如果失败就会解除抢占锁,解除禁止调度器,继续循环等待.
  6. 设置中间值成功后,我们接下来就可以安全的把v设为传入的新值了,这里会先写入值,在写入类型(typ),因为我们会根据ty来做完成判断。
  7. 第一次写入没完成,我们还会通过uintptr(typ) == ^uintptr(0)来进行判断,因为还是第一次放入的中间类型,他依然会继续等待第一次完成。
  8. 如果第一次写入完成,会检查上一次写入的类型与这次写入的类型是否一致,不一致则会抛出panic.

这里代码量没有多少,相信大家一定看懂了吧~。

Value的读操作

先看一下代码:

// 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() (x interface{}) { vp := (*ifaceWords)(unsafe.Pointer(v)) typ := LoadPointer(&vp.typ) if typ == nil || uintptr(typ) == ^uintptr(0) {  // First store not yet completed.  return nil } data := LoadPointer(&vp.data) xp := (*ifaceWords)(unsafe.Pointer(&x)) xp.typ = typ xp.data = data return}

读取操作的代码就很简单了:1.第一步使用unsafe.PointeroldValue转换成ifaceWords类型,然后获取他的类型,如果没有类型或者类型出去中间值,那么说明现在还没数据或者第一次写入还没有完成。2. 通过检查后,调用LoadPointer方法可以获取他的值,然后构造一个新interfacetypdata返回。

小彩蛋

前面我们在说CAS时,说到了ABA问题,所以我就写了demo试一试Go标准库atomic.CompareAndSwapXXX方法是否有解决这个问题,看运行结果是没有,所以这里大家使用的时候要注意一下(虽然我也没想到什么现在什么业务场景会出现这个问题,但是还是要注意一下,需要自己评估)。

func main()  { var share uint64 = 1 wg := sync.WaitGroup{} wg.Add(3) // 协程1,期望值是1,欲更新的值是2 go func() {  defer wg.Done()  swapped := atomic.CompareAndSwapUint64(&share,1,2)  fmt.Println("goroutine 1",swapped) }() // 协程2,期望值是1,欲更新的值是2 go func() {  defer wg.Done()  time.Sleep(5 * time.Millisecond)  swapped := atomic.CompareAndSwapUint64(&share,1,2)  fmt.Println("goroutine 2",swapped) }() // 协程3,期望值是2,欲更新的值是1 go func() {  defer wg.Done()  time.Sleep(1 * time.Millisecond)  swapped := atomic.CompareAndSwapUint64(&share,2,1)  fmt.Println("goroutine 3",swapped) }() wg.Wait() fmt.Println("main exit")}

总结

原子操作是并发编程的一个基础,也是为我学习sync.once打基础,好啦,现在你们应该知道下篇文章的内容是什么啦,敬请期待~。

(0)

相关推荐

  • 浅谈 Java 并发下的乐观锁

    引子 各位少侠大家好!今天我们来聊聊 Java 并发下的乐观锁. 在聊乐观锁之前,先给大家复习一个概念:原子操作: 什么是原子操作呢? 我们知道,原子(atom)指化学反应不可再分的基本微粒.在 Ja ...

  • recover.panic.defer.2021.03.03

    Defer, Panic, and Recover 在 Go 语言中,recover 和 panic 的关系是什么? 我们先看一个基础的例子,在 main 方法体中启动一个协程,在协程内部主动调用 p ...

  • 多图详解Go中的Channel源码

    chan介绍 package mainimport "fmt" func main() { c := make(chan int) go func() { c <- 1 // ...

  • 微服务过载保护原理与实战

    在微服务中由于服务间相互依赖很容易出现连锁故障,连锁故障可能是由于整个服务链路中的某一个服务出现故障,进而导致系统的其他部分也出现故障.例如某个服务的某个实例由于过载出现故障,导致其他实例负载升高,从 ...

  • Go操作Redis实战

    目录 安装Redis客户端 连接redis 基本指令 Keys():根据正则获取keys Type():获取key对应值得类型 Del():删除缓存项 Exists():检测缓存项是否存在 Expir ...

  • 两万字长文带你深入Go语言GC源码(下)

    作者:luozhiyun,腾讯IEG后台开发工程师 博客: https://www.luozhiyun.com/archives/475 本文使用的 Go 的源码1.15.7 创建后台标记 Worke ...

  • 空结构体引发的大型打脸现场

    背景 哈喽,大家好,我是正在学习PS技术的asong,上周读者问了我一道题,觉得挺有意义的,就在这里分享一下,我们先来看一下这个题: type User struct { } func FPrint( ...

  • go编程:说说channel哪些事

    channel是什么 channel中文翻译为通道,它是Go语言内置的数据类型,使用channel不需要导入任何包,像int/float一样直接使用.它主要用于goroutine之间的消息传递和事件通 ...

  • 汇算清缴,详解填报《基础信息表》

    具体文章链接:3月结束了,是时候开始写企业所得税汇算清缴了 然后呢具体以表格为基础,看看2020年的所得税申报表的一些变化. 具体文章链接:求求大家别催了!汇算清缴内容分享终终终于来了(第二弹) 接下 ...

  • 步骤详解,零基础钢笔画入门,把握好空间结构关系,是成功的一半

    这幅作品画了一座小桥,连接了两岸,在左边和右边都有许多房子和植物,在小桥上还坐了一个老人,由于田园风的钢笔画很少涉及到人物,所以在画的时候可以先用铅笔进行打稿,这幅作品在画的时候要注意房子之间的交错关 ...

  • 详解机顶盒维修基础及检修方法(4)

    (四)机顶盒检修注意事项    1)部分机型接通电源后,底板对地有较高的电压,维修时必须使用隔离变压器:或者将修理工作台铺设钢 板与大地相连,所有镊子用导线与钢板良好连接,防止静电感应损坏器件.    ...

  • 详解机顶盒维修基础及检修方法(3)

      (二)机顶盒维修应具备的条件(1)维修人员应具备的条件     维修人员应具备电子技术基础知识,对机顶盒有清晰完整的概念,并有一定的机顶盒和其他电器维修实践经验.     1)熟悉电路工作原理,其 ...

  • 详解机顶盒维修基础及检修方法(2)

         二.机顶盒的故障检修技能  (一)机顶盒检修的基本原则 (1)先调查后熟悉     当用户送来一台故障机,首先要询问使用情况.故障的发生及故障现象.例如,故障是逐渐变化还是突然发生,故障现象 ...

  • 详解机顶盒维修基础及检修方法(1)

          一.机顶盒理论基础   (一)机顶盒的内部组成    数字有线电视机顶盒内部主要由主板.电源板.按键板.IC卡板.高频头等组成,图1所示.卫星电视机顶盒内部主要由主板.电源板.按键板等组成 ...

  • Creo 7.0混合功能详解视频教程,基础功能不基础

    冰大IceFai制作讲解的Creo基础功能深入理解系列视频教程,这是混合功能的讲解,基础功能不基础,无基础不高级!

  • 钢筋图纸看不懂?159页钢筋理论及识图详解,零基础3天就掌握

    工程人都知道钢筋识图是开展工程项目的第一步,也是最关键的一步,只有看懂图纸才能高效准确的进行项目施工,但是钢筋图纸比较复杂,工程人难以进行系统的学习. 今天整理了159页的钢筋基础知识及识图详解,包含 ...

  • 80页ppt详解数控编程中的常用指令!快来温习一下!

    (文章底部可以评论,欢迎对文章进行点评和知识补充) 数控编程教学 订单 | 技术 | 干货 | 编程 关注可加入机械行业群 点击免费领取10G数控编程教程 点击免费领取10G数控编程教程 点击免费领取 ...