轉載丨併發程式設計與鎖的底層原理
背景
併發程式設計,多核、多執行緒的情況下,執行緒安全性問題都是一個無法迴避的難題。雖然我們可以用到CAS,互斥鎖,訊息佇列,甚至分散式鎖來解決,但是對於鎖的底層實現,這次課程,我們想更深入的來分析和探討鎖的底層原理,以便更好地理解和掌握併發程式設計。
大綱:
1.併發程式設計與鎖
2.快取和一致性協議MESI
3.CPU/快取與鎖
4.常見鎖總結
1 併發程式設計與鎖
我們寫的各種應用系統,像網路程式設計,基本上都是併發程式設計,不論是多程序還是多執行緒,亦或是協程、佇列的方式,也都是併發程式設計的範疇。併發程式設計中,在多核作業系統中,多執行緒的時候,就會出現**執行緒安全性**問題,有的也說**併發安全性**問題。這種問題,都是因為對共享變數的併發讀寫引起的資料不一致問題。所以,在併發程式設計中,就會經常用到鎖,當然也可能使用佇列或者單執行緒的方式來處理共享資料。
我們先來還原一下具體的問題,然後再用不同的方法來處理它們。
執行緒安全性問題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。
——不要通過共享記憶體來通訊,而應該通過通訊來共享記憶體
鎖的底層實現型別
1 鎖記憶體匯流排 ,針對記憶體的讀寫操作,在總線上控制,限制程式的記憶體訪問
2 鎖快取行 ,同一個快取行的內容讀寫操作,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位是鎖的等待佇列長度計數器);
以上便是這次分享的全部內容,有不足和紕漏的地方,還請指教,謝謝~
同時,小編是一個有著7年工作經驗的架構師,對於c++,自己有做資料的整合,一個完整學習C語言c++的路線,學習資料和工具。可以進我的群7418,18652領取,免費送給大家。希望你也能憑自己的努力,成為下一個優秀的程式設計師!