大家好,今天將梳理出的 Go語言併發知識內容,分享給大家。 請多多指教,謝謝。
本次《Go語言併發知識》內容共分為三個章節,本文為第三章節。
- Golang 基礎之併發知識 (一)
- Golang 基礎之併發知識 (二)
- Golang 基礎之併發知識 (三)
本章節內容
- 基本同步原語
- 常見的鎖型別
- 擴充套件內容
基本同步原語
Go 語言在 sync
包中提供了用於同步的一些基本原語,包括常見的互斥鎖 Mutex
與讀寫互斥鎖 RWMutex
以及 Once
、WaitGroup
。這些基本原語的主要作用是提供較為基礎的同步功能,本次僅對 Mutex
展開介紹,剩餘其他原語將在後續併發章節中使用。
Mutex 是什麼
Mutex 是 golang 標準庫的互斥鎖,主要用來處理併發場景下共享資源的訪問衝突問題。
Mutex
互斥鎖在 sync
包中,它由兩個欄位 state
和 sema
組成,state
表示當前互斥鎖的狀態,而 sema
真正用於控制鎖狀態的訊號量,這兩個加起來只佔 8 個位元組空間的結構體就表示了 Go 語言中的互斥鎖。
type Mutex struct {
state int32
sema uint32
}
互斥鎖的作用,就是同步訪問共享資源。互斥鎖這個名字來自互斥(mutual exclusion)的概念,互斥鎖用於在程式碼上建立一個臨界區,保證同一個時間只有一個 goroutine 可以執行這個臨界區程式碼。
package main
import (
"fmt"
"runtime"
"sync"
)
var (
counter int
wg sync.WaitGroup
mutex sync.Mutex // 定義程式碼臨界區
)
func main() {
wg.Add(2)
go incCounter()
go incCounter()
wg.Wait()
fmt.Println("counter:", counter)
}
func incCounter() {
defer wg.Done()
for count := 0; count < 2; count++ {
mutex.Lock() // 臨界區, 同一時刻只允許一個 goroutine 進入
{
value := counter
runtime.Gosched() // goroutine退出,返回佇列
value++
counter = value
}
mutex.Unlock() // 釋放鎖
}
}
Lock()
和 Unlock()
函式呼叫定義的臨界區裡被保護起來。 使用大括號只是為了讓臨界區看起來更清晰,並不是必需的。同一時刻只有一個 goroutine 可以進入臨界區,直到呼叫 Unlock()
函式之後,其他 goroutine 才能進入臨界區。
Mutex 幾種狀態
- mutexLocked — 表示互斥鎖的鎖定狀態;
- mutexWoken — 表示從正常模式被從喚醒;
- mutexStarving — 當前的互斥鎖進入飢餓狀態;
- waitersCount — 當前互斥鎖上等待的 Goroutine 個數;
正常模式和飢餓模式
sync.Mutex
有兩種模式 — 正常模式和飢餓模式。
在正常模式中,鎖的等待者會按照先進先出的順序獲取鎖。但是剛被喚起的 Goroutine 與新建立的 Goroutine 競爭時,大概率會獲取不到鎖,為了減少這種情況的出現,一旦 Goroutine 超過 1ms 沒有獲取到鎖,它就會將當前互斥鎖切換飢餓模式,防止部分 Goroutine 被 "餓死"。
飢餓模式是在 Go 語言在 1.9 中通過提交 sync: make Mutex more fair 引入的優化,引入的目的是保證互斥鎖的公平性。
在飢餓模式中,互斥鎖會直接交給等待佇列最前面的 Goroutine。新的 Goroutine 在該狀態下不能獲取鎖、也不會進入自旋狀態,它們只會在佇列的末尾等待。如果一個 Goroutine 獲得了互斥鎖並且它在佇列的末尾或者它等待的時間少於 1ms,那麼當前的互斥鎖就會切換回正常模式。
常見鎖型別
死鎖、活鎖與飢餓
關於這三種鎖模式,已經在 [Golang 基礎之併發知識 (一)]() 文章中進行了簡單說明,上文中針對飢餓模式進行一次補充。
死鎖,作為最常見的鎖,這裡在進行一次補充。
死鎖可以理解為完成一項任務的資源被兩個(或多個)不同的協程分別佔用了,導致它們全都處於等待狀態不能完成下去。在這種情況下,如果沒有外部干預,程式將永遠不會恢復。
// 死鎖案例
package main
import (
"fmt"
"sync"
"time"
)
type value struct {
mu sync.Mutex
value int
}
var wg sync.WaitGroup
func main() {
printSum := func(v1, v2 *value) {
defer wg.Done()
v1.mu.Lock() // 加鎖
defer v1.mu.Unlock() // 釋放鎖
time.Sleep(1 * time.Second)
v2.mu.Lock()
defer v2.mu.Unlock()
fmt.Printf("sum=%v\n", v1.value+v2.value)
}
var a, b value
wg.Add(2)
go printSum(&a, &b) // 協程1
go printSum(&b, &a) // 協程2
wg.Wait()
}
輸出
fatal error: all goroutines are asleep - deadlock!
死鎖的三個動作
- 試圖訪問帶鎖的部分
- 試圖呼叫defer關鍵字釋放鎖
- 新增休眠時間 以造成死鎖
實質上,我們建立了兩個不能一起運轉的齒輪: 我們的第一個列印總和呼叫a鎖定,然後嘗試鎖定b,但與此同時,我們列印總和的第二個呼叫鎖定了b並嘗試鎖定a。 兩個goroutine都無限地等待著彼此。
自旋鎖
介紹
自旋鎖是指當一個執行緒在獲取鎖的時候,如果鎖已經被其他執行緒獲取,那麼該執行緒將迴圈等待,然後不斷地判斷是否能夠被成功獲取,直到獲取到鎖才會退出迴圈。
獲取鎖的執行緒一直處於活躍狀態,但是並沒有執行任何有效的任務,使用這種鎖會造成 busy-waiting。
它是為實現保護共享資源而提出的一種鎖機制。其實,自旋鎖與互斥鎖比較類似,它們都是為了解決某項資源的互斥使用。無論是互斥鎖,還是自旋鎖,在任何時刻,最多隻能由一個保持者,也就說,在任何時刻最多隻能有一個執行單元獲得鎖。但是兩者在排程機制上略有不同。對於互斥鎖,如果資源已經被佔用,資源申請者只能進入睡眠狀態。但是自旋鎖不會引起呼叫者睡眠,如果自旋鎖已經被別的執行單元保持,呼叫者就一直迴圈在那裡看是否該自旋鎖的保持者已經釋放了鎖,“自旋”一詞就是因此而得名。
自旋鎖與互斥鎖
- 自旋鎖與互斥鎖都是為了實現保護資源共享的機制。
- 無論是自旋鎖還是互斥鎖,在任意時刻,都最多隻能有一個保持者。
- 獲取互斥鎖的執行緒,如果鎖已經被佔用,則該執行緒將進入睡眠狀態;獲取自旋鎖的執行緒則不會睡眠,而是一直迴圈等待鎖釋放。
總結
- 自旋鎖:執行緒獲取鎖的時候,如果鎖被其他執行緒持有,則當前執行緒將迴圈等待,直到獲取到鎖。
- 自旋鎖等待期間,執行緒的狀態不會改變,執行緒一直是使用者態並且是活動的(active)。
- 自旋鎖如果持有鎖的時間太長,則會導致其它等待獲取鎖的執行緒耗盡CPU。
- 自旋鎖本身無法保證公平性,同時也無法保證可重入性。
- 基於自旋鎖,可以實現具備公平性和可重入性質的鎖。
讀寫鎖
讀寫鎖即針對讀寫操作的互斥鎖。 它與普通的互斥鎖最大的不同,就是可以分別針對讀操作和寫操作進行鎖定和解鎖操作。讀寫鎖遵循的訪問控制規則有所不同。讀寫鎖控制下的多個寫操作之間都是互斥的,並且寫操作與讀操作之間也都是互斥的。
但是,多個讀操作之間卻不存在互斥關係。在這樣的互斥策略之下,讀寫鎖可以在大大降低因使用鎖造成的效能損耗的情況下,完成對共享資源的訪問控制。
Go語言中的讀寫鎖由結構體型別 sync.RWMutex
表示。 與互斥鎖一樣, sync.RWMutex
型別的零值就已經是可用的讀寫鎖例項了。
// 型別方法集
func (*RWMutex) Lock()
func (*RWMutex) Unlock()
func (*RWMutex) RLock()
func (*RWMutex) RUnlock()
擴充套件內容
不公平的鎖
不公平的鎖可被看成是飢餓的一種不太嚴重的表現形式,當某些執行緒爭搶同一把鎖時,其中一部分執行緒在絕大多數時間都可獲取到鎖,另一部分執行緒則遭遇不公平對待。這在帶有共享快取記憶體或者NUMA記憶體 的機器中可能出現,如果CPU 0釋放了一把其他CPU都 想獲取的鎖,因為CPU 0與CPU 1共享內部連線,所以CPU 1相較於CPU 2到7更容易搶到鎖。
反之亦然,如果一段時間後CPU 0又開始爭搶該鎖,那麼CPU 1釋放鎖時CPU 0也更容易獲取鎖,導致鎖繞過了CPU 2到 7,只在CPU 0和1之間換手。
低效率的鎖
鎖是由原子操作和記憶體屏障實現,並且常常帶來快取記憶體未命中。 這些指令代價都比較昂貴,粗略地說開銷比簡單指令高兩個數量級。這可能是鎖的一個嚴重問題,如果用鎖來保護一條指令,你很可能在以百倍的速度帶來開銷。對於相同的程式碼,即使假設擴充套件性非常完美,也需要100個CPU才能跟上一個執行不加鎖版本的CPU。
不過一旦持有了鎖,持有者可以不受干擾地訪問被鎖保護的程式碼。 獲取鎖可能代價高昂,但是一旦持有,特別是對較大的臨界區來說,CPU的快取記憶體反而是高效的效能加速器。
技術文章持續更新,請大家多多關注呀~~
搜尋微信公眾號,關注我【 帽兒山的槍手 】
參考材料
《Go語言設計與實現》書籍
《Concurrency in Go》書籍
《Go 併發程式設計實戰》書籍
《Go 語言實戰》書籍
晁嶽攀老師(鳥窩)的《Go 併發程式設計實戰課》
《深入理解並行程式設計》書籍