关于golang:一文读懂原子操作内存屏障锁偏向锁轻量级锁重量级锁自旋锁

43次阅读

共计 10375 个字符,预计需要花费 26 分钟才能阅读完成。

[]() 背景

[]() 在做了 9 年前端之后,自我感在此畛域曾经没有晋升空间,同时市场行情绝对较差,不如趁着这个工夫补充下后端系列技术,被裁之后也好接个私活不至于饿死。学两周 Go,如盲人摸象般不知重点。那么重点谁晓得呢?必定是应用 Go 的后端工程师,那便利用业余时间找了几个老哥对练一下。其中一位问道在利用多个 goroutine 发送申请拿到后果之后如果进行销毁。

[]() 有句话叫做老成持重天下无敌,再练三年举步维艰。本着不服输精力回来钻研了一下这个问题,很简略须要应用 Go 提供的 Context,api 应用起来也很简略,然而我一贯喜爱刨根问底,于是乎钻研 Context 源码发现互斥锁(Mutex)、原子操作(atomic),钻研 atomic 发现 CAS,钻研 CAS 发现了 java 的自旋锁、偏差锁、轻量级锁、重量级锁,钻研锁发现 Disruptor,钻研 Disruptor 发现 CPU 伪共享、MESI 协定、内存屏障。

[]() 所以这篇文章会自下向上的解说,先讲 CPU 硬件设计带来的劣势以及带来的问题(多核并发抵触、伪共享),从底层上了解并发问题存在的根因,而后解说原子操作与 CAS,之后解说操作系统为解决并发问题的锁机制、信号量,而后介绍下高并发框架 Disruptor 利用这些机制的高性能实现,最初回到 Go 中的 atomic.Value 以及建设在 aotmic.Value 和 Mutex 之上的 Context,最初的最初答复下那位老哥问题,怎么应用 Context 来做 goroutine 的调度。

[]() 并发与并行

[]() 并行是并发的一个子集;并发(ConcurrentMode)强调的是从任务调度角度来看,同时安顿多个是工作;工作能够穿插着执行,并不一定是同一时刻在同时进行;并行(Parallel)是从理论执行角度来看真的有多个工作在同一时刻同时执行。

[]() 古代 CPU 多半是多核设计,所以我了解会存在以多核并行的形式进行高并发运行。当然单核单线程仍然存在并行,它下面也存在真正的“并行”。只不过,这个并行并不是 CPU 外部的、线程之间的并行;而是 CPU 执行程序的同时,DMA 控制器也在执行着网络报文收发、磁盘读写、音视频播放 / 录制等等工作。

[]() 不像 js 这种单线程异步的语言,向 java、Go 语法上看起来都是以多线程同步阻塞的模式运行,个别多个申请来时,都是开启一个线程池利用多线程的形式进行解决。得益于 CPU 多核的劣势,能够疾速并行运行申请工作,然而同样因为这个起因会人造的引起多线程拜访数据抵触。

[]() 

[]() 硬件底层起因

[]() 并发抵触

[]() 并发资源抵触的底层起因与 CPU 设计无关,先看 CPU 的缓存构造

[]() 能够看到 i7-8700k 是 6 核,右下角 L1、L2 都 6x 多少 kb,能够看到 L1、L2 缓存是各个外围独有的,L3 是共用的,读取数据时会先从内存中把数据和指令读到本人的 L1 缓存中,这就能够晓得当两个线程拜访同一个数据时,CPU 角度他们其实在各自外围中都是独有的。

[]() 同样在写的时候,CPU 为了节俭跟内存通信带来的性能开销,也并不一定是程序更新后立刻放到内存中。而是有两种策略:写中转和写回。写中转比较简单,都是每次写入到内存中,性能开销大。而写回是当 L1 数据缓存中的变量 A 被更改后不立刻写回内存,只是做一个脏标记,这样屡次写同一个变量 A 能够始终在 L1 中解决,直到这个缓存地位的 A 不在解决了,须要 A 腾出中央给另一个变量 B 时,才把 A 的数据写到内存中,这样晋升缓存命中率,缩小与内存的通信晋升性能。

[]() 能够设想的是,这种高性能在多核并发运行时,两个外围都读到了变量 A 值为 0,外围 1 上运行的线程 T1 把 A 改成了 10,但没有写到内存中,也没有告诉外围 2,它的 L1 缓存中 A 还是 0,这时候外围 2 的线程 T2 把 A 改成了 20;当他们都往内存写的时候,必然呈现抵触。这就是在单机上多线程并发引起的资源抵触的底层起因,也是后续各种原子操作、锁、信号量等机制要解决的问题。推广到宏观业务场景是一样的,两个服务为了性能优化,先把数,0 读到本人的内存中,一个改成了 1,一个改成了 2,而数据库看到的还是 0,这时候两个服务都往数据库写,就会产生抵触。

[]() 

[]() 原子操作

[]() 为了解决这个问题,晚期科学家走了很多弯路,花了好多年才找到解决软件和硬件的解决方案。首先在 CPU 硬件层面,有两个伎俩:写流传和 MESI 协定。

[]() 写流传的计划就是当某个外围更新了 Cache 数据后,要把事件播送告诉到其余外围,然而并不能保障时序问题。比方外围 1 更改了 A 为 100,外围 2 更改了 A 为 200,这两个是在同一时间产生的,更改之后他们别离告诉对方,那么在外围 1 看来 A 是先变成了 100 而后又被改成了 200,外围 2 看来 A 是先被改成了 200,而后有收到告诉要改成 100;当他们往内存写的时候还是存在抵触。


[]() 解决这个问题就是要在 CPU 硬件上做到事务的串行化,MESI 就是解决这个问题。

[]()MESI 协定其实是 4 个状态单词的结尾字母缩写,别离是:

  • []()Modified,已批改
  • []()Exclusive,独占
  • []()Shared,共享
  • []()Invalidated,已生效

[]() 这四个状态来标记 Cache Line 四个不同的状态。

[]()「已批改」状态就是咱们后面提到的脏标记,代表该 Cache Block 上的数据曾经被更新过,然而还没有写到内存里。而「已生效」状态,示意的是这个 Cache Block 里的数据曾经生效了,不能够读取该状态的数据。

[]()「独占」和「共享」状态都代表 Cache Block 里的数据是洁净的,也就是说,这个时候 Cache Block 里的数据和内存外面的数据是一致性的。

[]()「独占」和「共享」的差异在于,独占状态的时候,数据只存储在一个 CPU 外围的 Cache 里,而其余 CPU 外围的 Cache 没有该数据。这个时候,如果要向独占的 Cache 写数据,就能够间接自在地写入,而不须要告诉其余 CPU 外围,因为只有你这有这个数据,就不存在缓存一致性的问题了,于是就能够轻易操作该数据。

[]() 另外,在「独占」状态下的数据,如果有其余外围从内存读取了雷同的数据到各自的 Cache,那么这个时候,独占状态下的数据就会变成共享状态。

[]() 那么,「共享」状态代表着雷同的数据在多个 CPU 外围的 Cache 里都有,所以当咱们要更新 Cache 外面的数据的时候,不能间接批改,而是要先向所有的其余 CPU 外围播送一个申请,要求先把其余外围的 Cache 中对应的 Cache Line 标记为「有效」状态,而后再更新以后 Cache 外面的数据。

[]() 这个 MESI 协定在 CPU 硬件层面保障了并发拜访变量的串行性,当然这须要做设置,你不开开关是不行的,毕竟高并发场景也不是那么多很多语言在设计层面上就杜绝了多线程安全性问题,所以没必要都用这个协定有性能损耗的。而这个开关其实就是各语言和操作系统或者虚拟机封装进去的原子操作,这就是原子操作和内存屏障的底层原理。(原子操作和内存屏障是硬件层面提供的机制,而锁是操作系统提供的机制,所以原子操作和内存屏障比锁的性能更高)(MESI 局部的图文取材自小林 coding 的图解零碎,感激)

[]() 

[]() 原子操作与内存屏障

[]() 在理解 CPU 层面的硬件常识之后咱们再来介绍在高并发场景中常常会遇到的原子操作和内存屏障是怎么回事。

[]() 原子操作

[]() 原子是是指化学反应中不可再分的根本微粒,所以引入到计算机场景中,原子操作是指一次操作是不可被打断宰割的(非原子操作,比方咱们本人写的一个函数执行可能是会被在某个语句中断一会儿后接着继续执行的);然而说到原子操作这个名词其实是存在歧义的,不同场景下含意不同 (有的把事务也等同是原子操作),这里说的原子操作专门指须要依赖 CPU 硬件指令提供的形式。

[]() 原子操作宏观粒度层面是通过硬件指令来实现的,比方读取一个 L1 缓存中的数据是原子(这是最小粒度的原子性),当初多核 CPU 基本上都是在 cache lock 层面即利用上文的 MESI 协定来保障原子性。

[]() 然而原子操作个别只能保障一个小变量操作的原子性,当是简单类型时,个别应用 COW(copy on wirte)计划,首先有一个指向这个对象的指针,在须要原子性批改这个大对象数据时,就把这个对象的数据拷贝一份(这里的拷贝指的是十分底层的拷贝可能是 L1 或者 L2 缓存),在对象正本上批改,而后在原子性的指向这个对象的指针(这里外围计划是利用指令来实现指针的原子替换,指针占用内存还是很小的)。这也是 Go 语言在 atomic.Value 中应用到的计划(LoadPointer、和 StorePointer)。

[]() 

[]() 而咱们在并发中肯定常常听到 CAS(Compare And Swap),这个其实是一个 CPU 指令,各种语言或者工具依赖这个指令提供了各种 CAS(VEN) 函数,如 compareAndSwapInt、compareAndSwapPointer 等,他做的事件很简略,V 和 E 进行比拟,如果 V 和 E 相等就把 V 设为 N,不相等则失败,由 CPU 保障这个过程的原子性。这是一个十分底层的函数,用在并发场景中十分有用。个别用来在并发场景中尝试批改值,也是自旋锁的底层。


[]()![]() 

[]() 有了这个根底之后咱们来看下 Go 中的 atomic.Value 的实现原理。它是在 Go 中用来设计为存储存储任意类型的数据,所以外部字段是一个 interface{} 类型。

type Value struct {v interface{}

}

[]() 除此之外还有一个 ifaceWords 类型,这其实是对应于空 interface 的外部示意模式,次要为了失去其中的 typ 和 data 两个字段:

type ifaceWords struct {

  typ  unsafe.Pointer

  data unsafe.Pointer

}

[]() 这里用的是 unsafe.Pointer 它是能够间接操作内存,因为如果两种类型具备雷同的内存构造,其实能够利用 unsafe.Pointer 来让两种类型的指针互相转换,来实现同一份内存的的不必解读。这里能够外部 JavaScript 中的 ArrayBuffer 能够被转化成 DataView 或者不同的 TypedArray 进行不同的解读。上面举了一个 []byte 和 string 的例子,因为 Go 语言类型零碎禁止他俩互转,然而能够利用 unsafe.Pointer 来绕过类型零碎查看,间接转换。

bytes := []byte{104, 101, 108, 108, 111}

 

p := unsafe.Pointer(&bytes) // 强制转换成 unsafe.Pointer,编译器不会报错

str := *(*string)(p) // 而后强制转换成 string 类型的指针,再将这个指针的值当做 string 类型取出来

fmt.Println(str) // 输入 "hello"

[]() 上面来看下 Store 函数的代码:

func (v *Value) Store(x interface{}) {

  if x == nil {panic("sync/atomic: store of nil value into Value")

  }

  // 通过 unsafe.Pointer 将现有的和要写入的值别离转成 ifaceWords 类型,// 这样咱们下一步就能够失去这两个 interface{} 的原始类型(typ)和真正的值(data)vp := (*ifaceWords)(unsafe.Pointer(v))  // Old value

  xp := (*ifaceWords)(unsafe.Pointer(&x)) // New value

  // 这里开始利用 CAS 来自旋了

  for {

    // 通过 LoadPointer 这个原子操作拿到以后 Value 中存储的类型

    typ := LoadPointer(&vp.typ)

    if typ == nil {

      // typ 为 nil 代表 Value 实例被初始化,还没有被写入数据,则进行初始写入;// 初始写入须要确定 typ 和 data 两个值,非初始写入只须要更改 data

      

      // 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 总以后 P(调度器)并设置禁止抢占,使得 goroutine 执行以后逻辑不被打断以便尽快实现,同时这时候也不会产生 GC

      // pin 函数会将以后 goroutine 绑定的 P, 禁止抢占 (preemption) 并从 poolLocal 池中返回 P 对应的 poolLocal

      runtime_procPin()

      // 应用 CAS 操作,先尝试将 typ 设置为 ^uintptr(0) 这个中间状态。// 如果失败,则证实曾经有别的线程领先实现了赋值操作,那它就解除抢占锁,而后从新回到 for 循环第一步进行自旋

      // 回到第一步后,则进入到 if uintptr(typ) == ^uintptr(0) 这个逻辑判断和前面的设置 StorePointer(&vp.data, xp.data)

      if !CompareAndSwapPointer(&vp.typ, nil, unsafe.Pointer(^uintptr(0))) {

        // 设置胜利则将 P 复原原样

        runtime_procUnpin()

        continue

      }

      // Complete first store.

      // 这里先写 data 字段在写 typ 字段,因为这个两个独自都是原子的

      // 然而两个原子放在一起未必是原子操作,所以先写 data 字段,typ 用来做判断

      StorePointer(&vp.data, xp.data)

      StorePointer(&vp.typ, xp.typ)

      runtime_procUnpin()

      return

    }

    if uintptr(typ) == ^uintptr(0) {// 这个时候 typ 不为 nil,但可能为 ^uintptr(0),代表以后有一个 goroutine 正在写入,还没写完

        // 咱们先不做解决,保障那个写入线程操作的原子性

      // 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 { // atomic.Value 第一确定类型之后,后续都不能扭转

      panic("sync/atomic: store of inconsistently typed value into Value")

    }

    // 非第一次写入,则利用 StorePointer 这个原子操作间接写入。StorePointer(&vp.data, xp.data)

    return

  }

}

[]() 这个逻辑的次要思维就是,为了实现多个字段的原子性写入,咱们能够抓住其中的一个字段,以它的状态来标记整个原子写入的状态。这个想法在 TiDB 的事务实现中叫 Percolator 模型,次要思维也是先选出一个 primaryRow,而后所有的操作也是以 primaryRow 的胜利与否作为标记。

[]()atomic.Value 的读取则简略很多。

func (v *Value) Load() (x interface{}) {vp := (*ifaceWords)(unsafe.Pointer(v))

  typ := LoadPointer(&vp.typ) // 原子性读

  // 如果以后的 typ 是 nil 或者 ^uintptr(0),那就证实第一次写入还没有开始,或者还没实现,那就间接返回 nil(不对外裸露中间状态)。if typ == nil || uintptr(typ) == ^uintptr(0) {

    // First store not yet completed.

    return nil

  }

  // 否则,依据以后看到的 typ 和 data 结构出一个新的 interface{} 返回进来

  data := LoadPointer(&vp.data)

  xp := (*ifaceWords)(unsafe.Pointer(&x))

  xp.typ = typ

  xp.data = data

  return

}

[]() 以上 atomic.Value 局部借鉴自 https://blog.betacat.io/post/…

[]() 

[]() 内存屏障

[]() 在编译器层面也会对咱们写的代码做优化,导致 CPU 看到的指令程序跟咱们写的代码术程序并不齐全是统一的,这就也会导致多核执行状况下,数据不统一问题。而内存屏障也是解决这些问题的一种伎俩,各个语言封装底层指令,强制 CPU 指令依照代码写的程序执行。

[]() 在上文中能够看到为提供缓冲命中和缩小与内存通信频率,CPU 做了各种优化策略,有的会给咱们带来一些问题,比方某个外围更新了数据之后,如果没有进行原子操作会导致各个外围在 L1 中的数据不统一问题。内存屏障另一个作用是强制更新 CPU 的缓存,比方一个写屏障指令会把这个屏障前写入的数据更新到缓存中,这样任何前面试图读取该数据的线程都将失去最新值。

[]() 一般来说读写屏障是一起应用的,比方在 java 中,如果用 volatile 来润饰一个字段,Java 内存模型将在写操作后插入一个写屏障指令,而在读操作前插入一个读屏障指令。所以如果对一个 volatile 字段进行操作,一旦实现写入,任何拜访这个字段的线程都会失去最新值;在写入前 volatile 字段前,会被保障所有之前产生的事件都曾经产生,并且任何更新过的数据值也是可见的,因为内存屏障会把之前的写入值都更新到缓存。

[]() 理论中 Disruptor 的 Sequence 就是利用了内存屏障这点(新版本曾经不必了 https://github.com/LMAX-Excha…)

[]() 偏差锁、轻量级锁、重量级锁、自旋锁

[]() 锁是一个逻辑上的概念,锁的底层是互斥量和 CAS;CAS 咱们后面曾经介绍过了,他的底层是原子操作。互斥:是指某一资源同时只容许一个访问者对其进行拜访,具备唯一性和排它性。但互斥无奈限度访问者对资源的拜访程序,即拜访是无序的。互斥是在操作系统级别提供的多线程对共享资源的拜访机制,没有竞争到拜访权的线程会被挂起,等资源被开释后线程又被复原,整个过程是操作系统的调度机制实现的,线程挂起复原尽管比过程要快但在高并发场景来讲还是太慢。

[]() 一般来讲咱们说锁都是指操作系统级别通过互斥来进行调度的形式,自旋锁是特指依赖 CAS 进行资源抢占的形式(也有的中央把 CAS 自旋这种叫做无锁设计,概念比拟凌乱)。而 Java 语言中间接应用互斥锁比拟重,在某些场景下能够在 JVM 层面做一些轻量级的调度,所以它发明了很多概念。所以重量级锁就是 synchronized 关键字,底层是互斥锁。偏差锁、轻量级锁、自旋锁底层都是 CAS。

[]() 

[]() 偏差锁和轻量级锁

[]() 在 JVM 中,Java 对象内存模式分为三局部,对象头、实例数据和对齐填充。对象头中有一部分 MarkWord,在这部分中存储了一些锁的策略:

[]()JVM 默认是开启偏差锁的,在竞争比拟少的状况下,偏差锁或轻量级锁会晋升性能,JVM 会依据竞争条件,来进行锁的降级,保障逻辑正确性。(具体原理能够理解:https://blog.csdn.net/qq_4314…)

[]() 自旋锁

[]() 自旋是指线程不被挂起而是,在应用 CPU 不停的空转期待其余线程开释锁,我的了解自旋锁个别是联合 CAS 来进行抢占资源。如 Disruptor 中对 Entry 的更新尝试,其实是利用了 CAS 自旋。

/**@param delta the value to add

 * @return the previous value

 */

 * Atomically adds the given value to the current value.

 *

 *

public final int getAndAdd(int delta) {for (;;) {int current = get();

        int next = current + delta;

        if (compareAndSet(current, next))

            return current;

    }

}

  

/**@code ==} the expected value.

 *

 * @param expect the expected value

 * @param update the new value

 * @return true if successful. False return indicates that

 * the actual value was not equal to the expected value.

 */

 * Atomically sets the value to the given updated value

 * if the current value {public final boolean compareAndSet(int expect, int update) {return unsafe.compareAndSwapInt(this, valueOffset, expect, update);

}

[]() 从 Go 的 Context 到 atomic.Value,再到去学习 CAS,再到发现各种锁,而后找锁存在的意义找到 CPU 层,整个过程其实是带着问题自上向下的,而文章是我在了解这些概念原理之后,自下向上一步步解答其中的问题,心愿没有后端教训的前端同学可能看懂。原来想把整个 Disruptor 和 Go 的 Context 全副写完,当初曾经十点多了,不卷洗洗睡,剩下文章等下周把。

[]() 

[]() 参考资料

[]() 本文大量援用了相干参考资料的图片和语言,尤其是 CPU 硬件局部图片大部分来自于小林 coding(https://xiaolincoding.com/os/…)的图片。版权问题请与我分割,侵删。

[]() 深刻了解 Go Context:https://article.itxueyuan.com…

[]()context 源码:https://github.com/golang/go/…

[]() 聊一聊 Go 的 Context 上下文:https://studygolang.com/artic…

[]()go context 详解:https://www.cnblogs.com/juanm…

[]()Go 语言 Context(上下文):http://c.biancheng.net/view/5…

[]()atomic 原理以及实现:https://blog.csdn.net/u010853…

[]()atomic 前世今生:https://blog.betacat.io/post/…

[]()CAS 乐观锁:https://blog.csdn.net/yanluan…

[]()CAS 乐观锁:https://blog.csdn.net/nrsc272…

[]() 偏差锁、轻量级锁、重量级锁、自旋锁原理:https://blog.csdn.net/qq_4314…

[]() 自旋锁,偏差锁,轻量级锁,重量级锁:https://www.jianshu.com/p/272…

[]()CAS 与自旋锁:https://blog.csdn.net/weixin_…

[]() 自旋锁、CAS、乐观锁、乐观锁:https://blog.csdn.net/weixin_…

[]()Go 并发面试总结:https://www.iamshuaidi.com/89…

[]() 高性能队列 -Disruptor:https://tech.meituan.com/2016…

[]() 锁与原子操作的关系:https://www.cnblogs.com/lucon…

[]() 多线程程序打印:https://www.cnblogs.com/lazye…

[]() 如何实现一个乐观锁:https://zhuanlan.zhihu.com/p/…

[]()disruptor 与内存屏障:http://ifeve.com/disruptor-me…

[]()Java volatile 的作用:http://www.51gjie.com/java/57…

[]() 浅谈原子操作:https://zhuanlan.zhihu.com/p/…

[]()sync.Pool 设计思路:https://blog.csdn.net/u010853…

[]() 

正文完
 0