天天看點

一文讀懂原子操作、記憶體屏障、鎖(偏向鎖、輕量級鎖、重量級鎖、自旋鎖)、Disruptor、Go Context之上半部分

我不想卷,我是被逼的

在做了幾年前端之後,發現網際網路行情比想象的差,不如趕緊學點後端知識,被裁之後也可接個私活不至于餓死。學習兩周Go,如盲人摸象般不知重點,那麼重點誰知道呢?肯定是使用Go的後端工程師,那便利用業餘時間找了幾個老哥對練一下。其中一位問道在利用多個goroutine發送請求拿到結果之後如果進行銷毀。是個好問題,研究了一下需要利用Context,而我一向喜歡研究源碼,繼續深挖發現細節非常多,于是乎有此這篇文章。

有句話叫做初出茅廬天下無敵,再練三年寸步難行。本着不服輸精神回來研究了一下這個問題,很簡單需要使用Go提供的Context,api使用起來也很簡單,但是我一向喜歡刨根問底,于是乎研究Context源碼發現互斥鎖(Mutex)、原子操作(atomic),研究atomic發現CAS,研究CAS發現了java的自旋鎖、偏向鎖、輕量級鎖、重量級鎖,研究鎖發現Disruptor,研究Disruptor發現CPU僞共享、MESI協定、記憶體屏障。

一文讀懂原子操作、記憶體屏障、鎖(偏向鎖、輕量級鎖、重量級鎖、自旋鎖)、Disruptor、Go Context之上半部分

是以這篇文章會自下向上的講解,先講CPU硬體設計帶來的優勢以及帶來的問題(多核并發沖突、僞共享),從底層上了解并發問題存在的根因,然後講解原子操作與CAS,之後講解作業系統為解決并發問題的鎖機制、信号量,然後介紹下高并發架構Disruptor利用這些機制的高性能實作,最後回到Go中的atomic.Value以及建立在aotmic.Value和Mutex之上的Context,最後的最後回答下那位老哥問題,怎麼使用Context來做goroutine的排程。

并發與并行

并行是并發的一個子集;并發(ConcurrentMode)強調的是從任務排程角度來看,同時安排多個是任務;任務可以穿插着執行,并不一定是同一時刻在同時進行;并行(Parallel)是從實際執行角度來看真的有多個任務在同一時刻同時執行。

現代CPU多半是多核設計,是以我了解會存在以多核并行的方式進行高并發運作。當然單核單線程依然存在并行,它上面也存在真正的“并行”。隻不過,這個并行并不是CPU内部的、線程之間的并行;而是CPU執行程式的同時,DMA控制器也在執行着網絡封包收發、磁盤讀寫、音視訊播放/錄制等等任務。

不像js這種單線程異步的語言,向java、Go文法上看起來都是以多線程同步阻塞的形式運作,一般多個請求來時,都是開啟一個線程池利用多線程的方式進行處理。得益于CPU多核的優勢,可以快速并行運作請求任務,但是同樣因為這個原因會天然的引起多線程通路資料沖突。

硬體底層原因

并發沖突

并發資源沖突的底層原因與CPU設計有關,先看CPU的緩存結構

一文讀懂原子操作、記憶體屏障、鎖(偏向鎖、輕量級鎖、重量級鎖、自旋鎖)、Disruptor、Go Context之上半部分
一文讀懂原子操作、記憶體屏障、鎖(偏向鎖、輕量級鎖、重量級鎖、自旋鎖)、Disruptor、Go Context之上半部分

可以看到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;當他們往記憶體寫的時候還是存在沖突。

一文讀懂原子操作、記憶體屏障、鎖(偏向鎖、輕量級鎖、重量級鎖、自旋鎖)、Disruptor、Go Context之上半部分

解決這個問題就是要在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 裡面的資料。

一文讀懂原子操作、記憶體屏障、鎖(偏向鎖、輕量級鎖、重量級鎖、自旋鎖)、Disruptor、Go Context之上半部分
一文讀懂原子操作、記憶體屏障、鎖(偏向鎖、輕量級鎖、重量級鎖、自旋鎖)、Disruptor、Go Context之上半部分

這個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保證這個過程的原子性。這是一個非常底層的函數,用在并發場景中非常有用。一般用來在并發場景中嘗試修改值,也是自旋鎖的底層。

一文讀懂原子操作、記憶體屏障、鎖(偏向鎖、輕量級鎖、重量級鎖、自旋鎖)、Disruptor、Go Context之上半部分

有了這個基礎之後我們來看下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
}      

記憶體屏障

在編譯器層面也會對我們寫的代碼做優化,導緻CPU看到的指令順序跟我們寫的代碼術順序并不完全是一緻的,這就也會導緻多核執行情況下,資料不一緻問題。而記憶體屏障也是解決這些問題的一種手段,各個語言封裝底層指令,強制CPU指令按照代碼寫的順序執行。

在上文中可以看到為提供緩沖命中和減少與記憶體通信頻率,CPU做了各種優化政策,有的會給我們帶來一些問題,比如某個核心更新了資料之後,如果沒有進行原子操作會導緻各個核心在L1中的資料不一緻問題。記憶體屏障另一個作用是強制更新CPU的緩存,比如一個寫屏障指令會把這個屏障前寫入的資料更新到緩存中,這樣任何後面試圖讀取該資料的線程都将得到最新值。

一般來說讀寫屏障是一起使用的,比如在java中,如果用volatile來修飾一個字段,Java記憶體模型将在寫操作後插入一個寫屏障指令,而在讀操作前插入一個讀屏障指令。是以如果對一個volatile字段進行操作,一旦完成寫入,任何通路這個字段的線程都會得到最新值;在寫入前volatile字段前,會被保證所有之前發生的事情都已經發生,并且任何更新過的資料值也是可見的,因為記憶體屏障會把之前的寫入值都更新到緩存。

實際中Disruptor的Sequence就是利用了記憶體屏障這點(新版本已經不用了https://github.com/LMAX-Exchange/disruptor/blob/master/src/main/java/com/lmax/disruptor/Sequence.java)

一文讀懂原子操作、記憶體屏障、鎖(偏向鎖、輕量級鎖、重量級鎖、自旋鎖)、Disruptor、Go Context之上半部分

偏向鎖、輕量級鎖、重量級鎖、自旋鎖

鎖是一個邏輯上的概念,鎖的底層是互斥量和CAS;CAS我們前面已經介紹過了,他的底層是原子操作。互斥:是指某一資源同時隻允許一個通路者對其進行通路,具有唯一性和排它性。但互斥無法限制通路者對資源的通路順序,即通路是無序的。互斥是在作業系統級别提供的多線程對共享資源的通路機制,沒有競争到通路權的線程會被挂起,等資源被釋放後線程又被恢複,整個過程是作業系統的排程機制實作的,線程挂起恢複雖然比程序要快但在高并發場景來講還是太慢。

一般來講我們說鎖都是指作業系統級别通過互斥來進行排程的方式,自旋鎖是特指依賴CAS進行資源搶占的方式(也有的地方把CAS自旋這種叫做無鎖設計,概念比較混亂)。而Java語言中直接使用互斥鎖比較重,在某些場景下可以在JVM層面做一些輕量級的排程,是以它創造了很多概念。是以重量級鎖就是synchronized關鍵字,底層是互斥鎖。偏向鎖、輕量級鎖、自旋鎖底層都是CAS。

偏向鎖和輕量級鎖

在JVM中,Java對象記憶體模式分為三部分,對象頭、執行個體資料和對齊填充。對象頭中有一部分MarkWord,在這部分中存儲了一些鎖的政策:

一文讀懂原子操作、記憶體屏障、鎖(偏向鎖、輕量級鎖、重量級鎖、自旋鎖)、Disruptor、Go Context之上半部分
一文讀懂原子操作、記憶體屏障、鎖(偏向鎖、輕量級鎖、重量級鎖、自旋鎖)、Disruptor、Go Context之上半部分

JVM預設是開啟偏向鎖的,在競争比較少的情況下,偏向鎖或輕量級鎖會提升性能,JVM會根據競争條件,來進行鎖的更新,保證邏輯正确性。(詳細原理可以了解:https://blog.csdn.net/qq_43141726/article/details/118581304和https://www.jianshu.com/p/36eedeb3f912)

一文讀懂原子操作、記憶體屏障、鎖(偏向鎖、輕量級鎖、重量級鎖、自旋鎖)、Disruptor、Go Context之上半部分

自旋鎖

自旋是指線程不被挂起而是,在使用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/1_hardware/cpu_mesi.html)的圖檔。版權問題請與我聯系,侵删。

  • 深入了解Go Context:https://article.itxueyuan.com/39dbvb
  • context源碼:https://github.com/golang/go/blob/master/src/context/context.go
  • 聊一聊Go的Context上下文:https://studygolang.com/articles/28726
  • go context詳解:https://www.cnblogs.com/juanmaofeifei/p/14439957.html
  • Go語言Context(上下文):http://c.biancheng.net/view/5714.html
  • atomic原理以及實作:https://blog.csdn.net/u010853261/article/details/103996679
  • atomic前世今生:https://blog.betacat.io/post/golang-atomic-value-exploration/
  • CAS樂觀鎖:https://blog.csdn.net/yanluandai1985/article/details/82686486
  • CAS樂觀鎖:https://blog.csdn.net/nrsc272420199/article/details/105032873
  • 偏向鎖、輕量級鎖、重量級鎖、自旋鎖原理:https://blog.csdn.net/qq_43141726/article/details/118581304
  • 自旋鎖,偏向鎖,輕量級鎖,重量級鎖:https://www.jianshu.com/p/27290e67e4d0
  • CAS與自旋鎖:https://blog.csdn.net/weixin_52904390/article/details/113700649
  • 自旋鎖、CAS、悲觀鎖、樂觀鎖:https://blog.csdn.net/weixin_45102619/article/details/120605691
  • Go并發面試總結:https://www.iamshuaidi.com/8942.html
  • 高性能隊列-Disruptor:https://tech.meituan.com/2016/11/18/disruptor.html
  • 鎖與原子操作的關系:https://www.cnblogs.com/luconsole/p/4944304.html
  • 多線程順序列印:https://www.cnblogs.com/lazyegg/p/13900847.html
  • 如何實作一個樂觀鎖:https://zhuanlan.zhihu.com/p/137818729
  • disruptor與記憶體屏障:http://ifeve.com/disruptor-memory-barrier/
  • Java volatile的作用:http://www.51gjie.com/java/574.html
  • 淺談原子操作:https://zhuanlan.zhihu.com/p/333675803
  • sync.Pool設計思路:https://blog.csdn.net/u010853261/article/details/90647884

您可以考慮給樹發個小額微信紅包以資鼓勵

go