【動手】併發編程與鎖的底層原理

https://mp.weixin.qq.com/s/DX_GsBq31-cemADrqQKfqw編程

併發編程與鎖的底層原理

背景:數組

併發編程,多核、多線程的狀況下,線程安全性問題都是一個沒法迴避的難題。雖然咱們能夠用到CAS,互斥鎖,消息隊列,甚至分佈式鎖來解決,可是對於鎖的底層實現,此次分享,咱們想更深刻的來分析和探討鎖的底層原理,以便更好地理解和掌握併發編程。緩存

大綱:安全

1.併發編程與鎖網絡

2.緩存和一致性協議MESI多線程

3.CPU/緩存與鎖架構

4.常見鎖總結併發

1 併發編程與鎖app

咱們寫的各類應用系統,像網絡編程,基本上都是併發編程,不管是多進程仍是多線程,亦或是協程、隊列的方式,也都是併發編程的範疇。併發編程中,在多核操做系統中,多線程的時候,就會出現線程安全性問題,有的也說併發安全性問題。這種問題,都是由於對共享變量的併發讀寫引發的數據不一致問題。因此,在併發編程中,就會常常用到鎖,固然也可能使用隊列或者單線程的方式來處理共享數據。異步

咱們先來還原一下具體的問題,而後再用不一樣的方法來處理它們。

線程安全性問題1

代碼中共享變量num是一個簡單的計數器,main主線程啓動了兩個協程,分別循環一萬次對num進行遞增操做。正常狀況下,預期的結果應該是1w+1w=2w,可是,在併發執行的狀況下,最終的結果只有10891,離2w差的好多。

典型應用場景:

1 庫存數量扣減

2 投票數量遞增

併發安全性問題:

num+ +是三個操做(讀、改、寫),不知足原子性

併發讀寫全局變量,線程不安全

線程安全性問題2

代碼中共享變量list做爲一個數據集合,由兩個協程併發的循環append數據進去。一樣是每一個協程執行一萬次,正常狀況下,預期的list長度應該是2w,可是,在併發執行下,結果卻可能連1w都不到。

具體的緣由,你們能夠思考下,爲何併發執行的狀況下,2個協程,居然list長度還小於1w呢?

典型應用場景:

1 發放優惠券

2 在線用戶列表

併發安全性問題:

append(list, i) 內部是一個複雜的數組操做函數

併發讀寫全局變量,線程不安全

問題修復

方法一:經過WaitGroup將兩個協程分開執行,第一個執行完成再執行第二個,避免併發執行,串行化兩個任務。

方法二:經過互斥鎖,在數字遞增的先後加上鎖的處理,數值遞增操做時互斥。

方法三:針對int64的數字指針遞增操做,能夠利用atomic.AddInt64原子遞增方法來處理。

固然還會有更多的實現方法,可是內部的實現原理也都相似,原子操做,避免對共享數據的併發讀寫。

併發編程的幾個基礎概念

概念1:併發執行不必定是並行執行。

概念2:單核CPU能夠併發執行,不能並行執行。

概念3:單進程、單線程,也能夠併發執行。

並行是同一時刻的多任務處理,併發是一個時間段內(1秒、1毫秒)的多任務處理。

區別併發和並行,多核的並行處理涉及到多核同時讀寫一個緩存行,因此很容易出現數據的髒讀和髒寫;單核的併發處理中由於任務內部的中間變量,因此有可能存在髒寫的狀況。

鎖的做用

  • 避免並行運算中,共享數據讀寫的安全性問題。

  • 並行執行中,在鎖的位置,同時只能有一個程序能夠得到鎖,其餘程序不能得到鎖。

  • 鎖的出現,使得並行執行的程序在鎖的位置串行化執行。

  • 多核、分佈式運算、併發執行,纔會須要鎖。

不用鎖,也能夠實現一樣效果?

單線程串行化執行,隊列式,CAS。

——不要經過共享內存來通訊,而應該經過通訊來共享內存

鎖的底層實現類型

鎖內存總線,針對內存的讀寫操做,在總線上控制,限制程序的內存訪問

鎖緩存行,同一個緩存行的內容讀寫操做,CPU內部的高速緩存保證一致性

,做用在一個對象或者變量上。現代CPU會優先在高速緩存查找,若是存在這個對象、變量的緩存行數據,會使用鎖緩存行的方式。不然,才使用鎖總線的方式。

速度,加鎖、解鎖的速度,理論上就是高速緩存、內存總線的讀寫速度,它的效率是很是高的。而出現效率問題,是在產生衝突時的串行化等待時間,再加上線程的上下文切換,讓多核的併發能力直線降低。

2 緩存和一致性協議MESI

英文首字母縮寫,也就是英文環境下的術語、俚語、成語,新人理解和學習有難度,可是,掌握好了既能夠省事,又能夠縮小文化差距。

另外就是對英文的異形化,也相似漢字的變形體,「表醬紫」,「藍瘦香菇」,老外是很難懂得,反之同樣。

MESI「生老病死」緩存行的四種狀態

  • M: modify 被修改,數據有效,cache和內存不一致

  • E: exclusive 獨享,數據有效,cache與內存一致

  • S: shared 共享,數據有效,cache與內存一致,多核同時存在

  • I: invalid 數據無效

  • F: forward 向前(intel),特殊的共享狀態,多個S狀態,只有一個F狀態,從F高速緩存接受副本

當內核須要某份數據時,而其它核有這份數據的備份時,本cache既能夠從內存中導入數據,也能夠從其它cache中導入數據(Forward狀態的cache)。

四種狀態的更新路線圖

高效的狀態: E, S

低效的狀態: I, M

這四種狀態,保證CPU內部的緩存數據是一致的,可是,並不能保證是強一致性。

每一個cache的控制器不只知道本身的讀寫操做,並且也要監聽其它cache的讀寫操做。

緩存的意義

1 時間局部性:若是某個數據被訪問,那麼不久還會被訪問

2 空間局部性:若是某個數據被訪問,那麼相鄰的數據也很快可能被訪問

侷限性:空間、速度、成本

更大的緩存容量,須要更大的成本。更快的速度,須要更大的成本。均衡緩存的空間、速度、成本,才能更有市場競爭力,也是如今咱們看到的狀況。固然,隨着技術的升級,成本降低,空間、速度也就能繼續穩步提升了。

緩存行,64Byte的內容

緩存行的存儲空間是64Byte(字節),也就是能夠放64個英文字母,或者8個int64變量。

注意僞共享的狀況——56Byte共享數據不變化,可是8Byte的數據頻繁更新,致使56Byte的共享數據也會頻繁失效。

解決方法:緩存行的數據對齊,更新頻繁的變量獨佔一個緩存行,只讀的變量共享一個緩存行。

3 CPU/緩存與鎖

鎖的底層實現原理,與CPU、高速緩存有着密切的關係,接下來一塊兒看看CPU的內部結構。

CPU與計算機結構

內核獨享寄存器、L1/L2,共享L3。在早先時候只有單核CPU,那時只有L1和L2,後來有了多核CPU,爲了效率和性能,就增長了共享的L3緩存。

多顆CPU經過QPI鏈接。再後來,同一個主板上面也能夠支持多顆CPU,多顆CPU也須要有通訊和控制,纔有了QPI。

內存讀寫都要經過內存總線。CPU與內存、磁盤、網絡、外設等通訊,都須要經過各類系統提供的系統總線。

CPU流水線

CPU流水線,裏面還有異步的LoadBuffer,

Store Buffer, Invalidate Queue。這些緩衝隊列的出現,更多的異步處理數據,提升了CPU的數據讀寫性能。

CPU爲了保證性能,默認是寬鬆的數據一致性。

編譯器、CPU優化

    • 編譯器優化:重排代碼順序,優先讀操做(讀有更好的性能,由於cache中有共享數據,而寫操做,會讓共享數據失效)

    • CPU優化:指令執行亂序(多核心協同處理,自動優化和重排指令順序)


編譯器、CPU屏蔽

  • 優化屏蔽:禁止編譯器優化。按照代碼邏輯順序生成二進制代碼,volatile關鍵詞

  • 內存屏蔽:禁止CPU優化。防止指令之間的重排序,保證數據的可見性,store barrier, load barrier, full barrier

  • 寫屏障:阻塞直到把Store Buffer中的數據刷到Cache中

  • 讀屏障:阻塞直到Invalid Queue中的消息執行完畢

  • 全屏障:包括讀寫屏障,以保證各核的數據一致性

Go語言中的Lock指令就是一個內存全屏障同時禁止了編譯器優化。

x86的架構在CPU優化方面作的相對少一些,只是針對「寫讀」的順序纔可能調序。

加鎖,加了些什麼?

  • 禁止編譯器作優化(加了優化屏蔽)

  • 禁止CPU對指令重排(加了內存屏蔽)

  • 針對緩存行、內存總線上的控制

  • 衝突時的任務等待隊列

4 常見鎖總結

最後,咱們一塊兒來看看常見的自旋鎖、互斥鎖、條件鎖、讀寫鎖的實現邏輯,以及在Go源碼中,是如何來實現的CAS/atomic.AddInt64和Mutext.Lock方法的。

自旋鎖

只要沒有鎖上,就不斷重試。

若是別的線程長期持有該鎖,那麼你這個線程就一直在 while while while 地檢查是否可以加鎖,浪費 CPU 作無用功。

優勢:不切換上下文;

不足:燒CPU;

適用場景:衝突很少,等待時間不長的狀況下,或者少次數的嘗試自旋。

互斥鎖

操做系統負責線程調度,爲了實現「鎖的狀態發生改變時再喚醒」就須要把鎖也交給操做系統管理。

因此互斥器的加鎖操做一般都須要涉及到上下文切換,操做花銷也就會比自旋鎖要大。

優勢:簡單高效;

不足:衝突等待時的上下文切換;

適用場景:絕大部分狀況下均可以直接使用互斥鎖。

條件鎖

它解決的問題不是「互斥」,而是「等待」。

消息隊列的消費者程序,在隊列爲空的時候休息,數據不爲空的時候(條件改變)啓動消費任務。

條件鎖的業務針對性更強。

讀寫鎖

內部有兩個鎖,一個是讀的鎖,一個是寫的鎖。

若是隻有一個讀者、一個寫者,那麼等價於直接使用互斥鎖。

不過因爲讀寫鎖須要額外記錄讀者數量,花銷要大一點。

也能夠認爲讀寫鎖是針對某種特定情景(讀多寫少)的「優化」。

但我的仍是建議忘掉讀寫鎖,直接用互斥鎖。

適用場景:讀多寫少,並且讀的過程時間較長,能夠經過讀寫鎖,減小讀衝突時的等待。

無鎖操做CAS

Compare And Swap 比較並交換,相似於將 num+ + 的三個指令合併成一個指令 CMPXCHG,保證了操做的原子性。

爲了保證順序一致性和數據強一致性,還須要有一個LOCK指令。 

源碼,參見 runtime/internal/atomic/asm_amd64.s

LOCK指令的做用就是禁止編譯器優化,同時加上內存全屏障,能夠保證LOCK指令以後的一個指令執行時的數據強一致性和可見性。

數字的原子遞增操做 atomic.AddInt64

在原始指針數字的基礎上,原子性遞增 delta 數值,而且返回遞增後的結果值。

源碼1,參見sync/atomic/asm.s

XADDQ 數據交換,數值相加,寫入目標數據

ADDQ 數值相加,寫入目標數據

在XADDQ以前加上LOCK指令,保證這個指令執行時的數據強一致性和可見性。

源碼2,參見runtime/internal/atomic/asm_amd64.s

互斥鎖操做 sync.Mutex.Lock

源碼,參見 sync/mutex.go

大概的源碼處理邏輯以下:

1 經過CAS操做來競爭鎖的狀態 &m.state;

2 沒有競爭到鎖,先主動自旋嘗試獲取鎖runtime_canSpin 和 runtime_doSpin (原地燒CPU);

3 自旋嘗試失敗,再次CAS嘗試獲取鎖;

4 runtime_SemacquireMutex 鎖請求失敗,進入休眠狀態,等待信號喚醒後從新開始循環;

5 m.state等待隊列長度(複用的int32位數字,第一位是鎖的狀態,後31位是鎖的等待隊列長度計數器)。

以上即是此次分享的所有內容,有不足和紕漏的地方,還請指教,謝謝~

相關文章
相關標籤/搜索