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

木的樹發表於2022-04-23

我不想卷,我是被逼的

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

有句話叫做初出茅廬天下無敵,再練三年寸步難行。本著不服輸精神回來研究了一下這個問題,很簡單需要使用Go提供的Context,api使用起來也很簡單,但是我一向喜歡刨根問底,於是乎研究Context原始碼發現互斥鎖(Mutex)、原子操作(atomic),研究atomic發現CAS,研究CAS發現了java的自旋鎖、偏向鎖、輕量級鎖、重量級鎖,研究鎖發現Disruptor,研究Disruptor發現CPU偽共享、MESI協議、記憶體屏障。
0
所以這篇文章會自下向上的講解,先講CPU硬體設計帶來的優勢以及帶來的問題(多核併發衝突、偽共享),從底層上理解併發問題存在的根因,然後講解原子操作與CAS,之後講解作業系統為解決併發問題的鎖機制、訊號量,然後介紹下高併發框架Disruptor利用這些機制的高效能實現,最後回到Go中的atomic.Value以及建立在aotmic.Value和Mutex之上的Context,最後的最後回答下那位老哥問題,怎麼使用Context來做goroutine的排程。
 

併發與並行

並行是併發的一個子集;併發(ConcurrentMode)強調的是從任務排程角度來看,同時安排多個是任務;任務可以穿插著執行,並不一定是同一時刻在同時進行;並行(Parallel)是從實際執行角度來看真的有多個任務在同一時刻同時執行。
現代CPU多半是多核設計,所以我理解會存在以多核並行的方式進行高併發執行。當然單核單執行緒依然存在並行,它上面也存在真正的“並行”。只不過,這個並行並不是CPU內部的、執行緒之間的並行;而是CPU執行程式的同時,DMA控制器也在執行著網路報文收發、磁碟讀寫、音視訊播放/錄製等等任務。
不像js這種單執行緒非同步的語言,向java、Go語法上看起來都是以多執行緒同步阻塞的形式執行,一般多個請求來時,都是開啟一個執行緒池利用多執行緒的方式進行處理。得益於CPU多核的優勢,可以快速並行執行請求任務,但是同樣因為這個原因會天然的引起多執行緒訪問資料衝突。
 

硬體底層原因

併發衝突
併發資源衝突的底層原因與CPU設計有關,先看CPU的快取結構
0
0
可以看到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;當他們往記憶體寫的時候還是存在衝突。
0
解決這個問題就是要在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 裡面的資料。
 
0
0
這個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保證這個過程的原子性。這是一個非常底層的函式,用在併發場景中非常有用。一般用來在併發場景中嘗試修改值,也是自旋鎖的底層
0
有了這個基礎之後我們來看下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
0
 
 

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

鎖是一個邏輯上的概念,鎖的底層是互斥量和CAS;CAS我們前面已經介紹過了,他的底層是原子操作。互斥:是指某一資源同時只允許一個訪問者對其進行訪問,具有唯一性和排它性。但互斥無法限制訪問者對資源的訪問順序,即訪問是無序的。互斥是在作業系統級別提供的多執行緒對共享資源的訪問機制,沒有競爭到訪問權的執行緒會被掛起,等資源被釋放後執行緒又被恢復,整個過程是作業系統的排程機制實現的,執行緒掛起恢復雖然比程式要快但在高併發場景來講還是太慢。
一般來講我們說鎖都是指作業系統級別通過互斥來進行排程的方式,自旋鎖是特指依賴CAS進行資源搶佔的方式(也有的地方把CAS自旋這種叫做無鎖設計,概念比較混亂)。而Java語言中直接使用互斥鎖比較重,在某些場景下可以在JVM層面做一些輕量級的排程,所以它創造了很多概念。所以重量級鎖就是synchronized關鍵字,底層是互斥鎖。偏向鎖、輕量級鎖、自旋鎖底層都是CAS。
 
偏向鎖和輕量級鎖
在JVM中,Java物件記憶體模式分為三部分,物件頭、例項資料和對齊填充。物件頭中有一部分MarkWord,在這部分中儲存了一些鎖的策略:
 
 
0
0
JVM預設是開啟偏向鎖的,在競爭比較少的情況下,偏向鎖或輕量級鎖會提升效能,JVM會根據競爭條件,來進行鎖的升級,保證邏輯正確性。(詳細原理可以瞭解:https://blog.csdn.net/qq_43141726/article/details/118581304https://www.jianshu.com/p/36eedeb3f912
0
 
 
自旋鎖
自旋是指執行緒不被掛起而是,在使用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)的圖片。版權問題請與我聯絡,侵刪。
 

相關文章