在上一篇《踩坑記:Go服務靈異panic》裏咱們提到了 mutex 和 atomic ,感受意猶未盡,這篇再展開一點。html
前面咱們講過好多面試題了,其實鎖也很適合用來作套題,好比能夠這麼切入:sync.Mutex 是悲觀鎖仍是樂觀鎖?程序員
有些候選人不瞭解它們的區別,回答靠猜,缺少邏輯以致於我都記不住。雖然這只是一個概念性的知識,可是卻很能反映候選人的工做經驗,好比讀多寫少的併發場景,樂觀鎖能夠減小加鎖衝突帶來的開銷。golang
固然大多數人仍是知道的,因而能夠繼續問:你有了解過鎖是怎麼實現的嗎?面試
不少人都能想到:維護一個初值爲 false 的變量,當一個線程加鎖成功的時候,將它置爲 true ,就能夠保證其餘線程沒法再獲取。 算法
邏輯是沒錯,但真正的問題是:兩個線程同時檢查,發現它的值都是 false ,如何保證只有一個線程會把它置爲 true 呢? segmentfault
這樣的提問讓很多候選人意識到,本身其實並無真正理解鎖。緩存
學過操做系統原理的同窗應該都知道,靠的是原子操做(atomic operations)。安全
那麼具體是什麼原子操做呢?數據結構
在早期只有單核的系統上只須要關閉中斷就能夠保證原子地執行一段代碼 —— 但這一般效率較低,且還存在些問題,例如由於 bug 或惡意代碼致使未能正常開啓中斷,系統就會鎖死;而對於多核系統,一般也沒法作到在多個核心上同時關閉中斷。併發
所以 CPU 引入了硬件支持的原子操做,例如 x86 體系下的 LOCK 信號(在彙編裏給指令加上 LOCK 前綴),經過鎖定總線,禁止其餘 CPU 對內存的操做來保證原子性。但這樣的鎖粒度太粗,其餘無關的內存操做也會被阻塞,大幅下降系統性能,而隨着核數逐漸增長該問題會愈發顯著 —— 要知道如今連家用 CPU 都有16核了。
所以 Intel 在 Pentium 486 開始引入了用於保證緩存一致性的 MESI 協議,經過鎖定對應的 cache line,使得其餘 core 沒法修改指定內存,從而實現了原子操做(緩存鎖)。這裏不展開了,對細節感興趣的話,詳見參考資料《原子操做是如何實現的》[1]。
針對前面問的「什麼原子操做」,大多數候選人的回答是 CAS (compare-and-swap),也有人會提到 test-and-set 等其餘操做,原理都同樣,就是用前述機制實現的。
下面這段 Go 代碼展現了 CAS 的邏輯:
func CompareAndSwap(p *int, oldValue int, newValue int) bool { if *p != oldValue { return false } *p = newValue return true }
請注意:這不是 CAS 的實現,如前所述,真正的 CAS 是硬件級別的指令支持的,最先出如今 1970 年 IBM 的 System 370 上,在 x86 上則是 80486 開始新增的 CMPXCHG 這個指令。
注:在多核系統上 CMPXCHG 也須要使用 LOCK 前綴,可是若是對應內存已經在 cache 裏,就不用發出 LOCK 信號鎖定總線,而是使用緩存鎖。
因爲不用鎖定總線,這樣的原子操做指令不會限制其他 CPU core 操做非鎖定內存,所以對系統總體的吞吐量影響不大。這一點對於當今核數愈來愈多的系統來講尤其重要。
因爲原子操做指令仍然須要在 CPU 之間傳遞消息用於對 cache line 的鎖定,其性能仍有必定損耗,具體來講大概就至關於一個未命中 cache 的 Load Memory 指令[2]。
基於 CAS 咱們能夠用實現不少實用的原子操做,例如原子加法:
func atomicAdd(p *int32, incr int32) int32 { for { oldValue := *p newValue := oldValue + incr if atomic.CompareAndSwapInt32(p, oldValue, newValue) { return newValue } } }
看,這就是一個典型的使用樂觀鎖的實現了:先作加法,若是更新失敗,就換個姿式再來一次。
注:Go 語言 atomic.AddInt32 的實現是直接使用匯編 LOCK XADDL 完成的,不是基於 CAS 和循環。
回到鎖的問題上,基於 CAS 咱們能夠很容易實現一個鎖:
type spinLock int32 func (p *spinLock) Lock() { for !atomic.CompareAndSwapInt32((*int32)(p), 0, 1) { } } func (p *spinLock) Unlock() { atomic.StoreInt32((*int32)(p), 0) }
這就是經典的自旋鎖[3] —— 經過反覆檢測鎖變量是否可用來完成加鎖。在加鎖過程當中 CPU 是在忙等待,所以僅適用於阻塞較短期的場合;其優點在於避免了線程切換的開銷。
注:spinlock 是 Linux 內核中主要的兩種鎖之一(另外一種是Mutex),感興趣的同窗能夠去看看內核源碼裏的實現,具體位於 include/asm/spinlock.h (吐槽:內核源碼真是難讀)。
在 Go 版的實現裏還要注意:若是 GOMAXPROCS 被設置成 1 (Go Runtime只會給用戶代碼分配一個系統線程),會致使上述代碼陷入死循環,所以更完善的實現是:
func (p *spinLock) Lock() { for !atomic.CompareAndSwapInt32((*int32)(p), 0, 1) { runtime.Gosched() } }
經過將當前系統線程的使用權暫時歸還給 Go Runtime(至關於其餘語言的 yield),能夠避免前述狀況,但這也在必定程度上破壞了自旋鎖的語義、使其變得更重了。
值得一提的是,研究人員發現,若是鎖衝突比較頻繁,在 CAS 失敗時使用指數退避算法(Exponential Backoff)每每能獲得更好的總體性能[2]。
實際上 Go 語言沒有提供自帶的自旋鎖實現,咱們在代碼中用得更多的是 Mutex 。
對比於 Spinlock 的忙等待,若是 Mutex 未得到鎖,會釋放對 CPU 的佔用。
上一篇 咱們在說 Mutex 性能不夠好的時候有提到「lock does not scale with the number of the processors」,這裏的 lock 指的是用 CPU LOCK信號實現的鎖;而經過閱讀 Mutex 的源碼,我發現實際上 Mutex 底層也是使用原子操做來實現的,因此前述說法不太準確。
Mutex 針對實際應用場景作了許多優化,是一個從輕量級鎖逐漸升級到重量級鎖的過程,從而平衡了各類場景下的需求和性能。
具體來講有這麼幾項:
注:對具體實現感興趣的同窗,能夠結合參考資料《golang中的鎖源碼實現:Mutex》[5] 閱讀源碼。
這裏提到的「公平」,指的是先到先得,這意味着每個競爭者都須要進入等待隊列,而這意味着CPU控制權的切換和對應的開銷;而非公平鎖,指的是在進入等待隊列以前先嚐試加鎖,若是加鎖成功,能夠減小排隊從而提升性能,但代價是隊列中的競爭者可能會處於「飢餓」狀態。
除了 Mutex,Go 在 sync 包裏還實現了不少用於解決併發問題的工具,這裏簡單介紹下:
· RWMutex
讀寫鎖,經過將資源的訪問者分紅讀者和寫者,容許多個讀者同時訪問資源,從而提升共享資源的併發度。適用於讀遠多於寫的場景。
· WaitGroup
用於對 goroutine 的併發控制,在主 goroutine 裏使用 Add(n) 指定併發數量,並使用 Wait() 等待全部任務都調用 Done() (配合 defer 使用效果更佳)。
· Pool
對象池,用於緩存後續會用到的對象,從而緩解 gc 壓力。例如 fmt 包用它來緩存輸出緩衝區。
· Once
「單例」:once.Do(f) 保證 f 只會被執行一次。f 被執行後,經過原子操做保證了性能。
· Cond
條件同步:當條件不知足時(一般是等待一個任務執行完成),goroutine調用 Wait() 等待通知;另外一個 goroutine 完成任務後,調用 Signal() 或 Broadcast() 通知在等待的 goroutine。
· Map
支持併發的 map:經過 Load、Store、LoadOrStore、Delete、Range 等方法提供線程安全的 map 數據結構。
· atomic
提供 Add、CAS、Load、Store、Swap 等對基礎數據類型的原子操做,以及 atomic.Value 來支持其餘類型的 Load、Store 原子操做。
哎呀,這篇寫得乾巴巴的,連一個表情包都沒有(忍住)。
最後照例小結一下:
下次考慮結合一些具體案例來說講,可能更有意思一點兒,爲了不錯過,記得關注↓↓↓
推薦閱讀:
參考資料: