[]()背景
[]()在做了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...
[]()