友情提示:此篇文章大約需要閱讀 5分鐘45秒,不足之處請多指教,感謝你的閱讀。 訂閱本站
我們比較常見的大型專案的設計中都會出現併發訪問問題,併發就是為了解決資料的準確性,保證同一個臨界區的資料只能被一個執行緒進行操作,日常中使用到的併發場景也是很多的:
- 計數器:計數器結果不準確;
- 秒殺系統:由於同一時間訪問量比較大,導致的超賣;
- 使用者賬戶異常:同一時間支付導致的賬戶透支;
- buffer 資料異常:更新 buffer 導致的資料混亂。
上面都是併發帶來的資料準確性的問題,決絕方案就是使用互斥鎖,也就是今天併發程式設計中的所要描述的 Mutex 併發原語。
實現機制
互斥鎖 Mutex 就是為了避免併發競爭建立的併發控制機制,其中有個“臨界區”的概念。
在併發程式設計過程中,如果程式中一部分資源或者變數會被併發訪問或者修改,為了避免併發訪問導致資料的不準確,這部分程式需要率先被保護起來,之後操作,操作結束後去除保護,這部分被保護的程式就叫做臨界區。
使用互斥鎖,限定臨界區只能同時由一個執行緒持有,若是臨界區此時被一個執行緒持有,那麼其他執行緒想進入到這個臨界區的時候,就會失敗或者等待釋放鎖,持有此臨界區的執行緒退出,其他執行緒才有機會獲得這個臨界區。
go mutex 臨界區示意圖
Mutex 是 Go 語言中使用最廣泛的同步原語,也稱為併發原語,解決的是併發讀寫共享資源,避免出現資料競爭 data race 問題。
基本使用
互斥鎖 Mutex 提供了兩個方法 Lock 和 Unlock:進入到臨界區使用 Lock 方法加鎖,退出臨界區使用 Unlock 方法釋放鎖 ?。
type Locker interface {
Lock()
Unlock()
}
func(m *Mutex)Lock()
func(m *Mutex)Unlock()
當一個 goroutine 呼叫 Lock 方法獲取到鎖後,其他 goroutine 會阻塞在 Lock 的呼叫上,直到當前獲取到鎖的 goroutine 釋放鎖。
接下來是一個計數器的例子,是由 100 個 goroutine 對計數器進行累加操作,最後輸出結果:
package main
import (
"fmt"
"sync"
)
func main() {
var mu sync.Mutex
countNum := 0
// 確認輔助變數是否都執行完成
var wg sync.WaitGroup
// wg 新增數目要和 建立的協程數量保持一致
wg.Add(100)
for i := 0; i < 100; i++ {
go func() {
defer wg.Done()
for j := 0; j < 1000; j++ {
mu.Lock()
countNum++
mu.Unlock()
}
}()
}
wg.Wait()
fmt.Printf("countNum: %d", countNum)
}
實際使用
很多時候 Mutex 並不是單獨使用的,而是巢狀在 Struct 中使用,作為結構體的一部分,如果嵌入的 struct 有多個欄位,我們一般會把 Mutex 放在要控制的欄位上面,然後使用空格把欄位分隔開來。
甚至可以把獲取鎖、釋放鎖、計數加一的邏輯封裝成一個方法。
package main
import (
"fmt"
"sync"
)
// 執行緒安全的計數器
type Counter struct {
CounterType int
Name string
mu sync.Mutex
count uint64
}
// 加一方法
func (c *Counter) Incr() {
c.mu.Lock()
defer c.mu.Unlock()
c.count++
}
// 取數值方法 執行緒也需要受保護
func (c *Counter) Count() uint64 {
c.mu.Lock()
defer c.mu.Unlock()
return c.count
}
func main() {
// 定義一個計數器
var counter Counter
var wg sync.WaitGroup
wg.Add(100)
for i := 0; i < 100; i++ {
go func() {
defer wg.Done()
for j := 0; j < 1000; j++ {
counter.Incr()
}
}()
}
wg.Wait()
fmt.Printf("%d\n", counter.Count())
}
思考問題
Q:你已經知道,如果 Mutex 已經被一個 goroutine 獲取了鎖,其它等待中的 goroutine 們只能一直等待。那麼,等這個鎖釋放後,等待中的 goroutine 中哪一個會優先獲取 Mutex 呢?
A:FIFO,先來先服務的策略,Go 的 goroutine 排程中,會維護一個保障 goroutine 執行的佇列,當獲取到鎖的 goroutine 執行完臨界區的操作的時候,就會釋放鎖,在佇列中排在第一位置的 goroutine 會拿到鎖進行臨界區的操作。
實現原理
Mutex 的架構演進目前分為四個階段:
Mutex 演化過程
- 初版 Mutex:使用一個 flag 變數表示鎖?是否被持有;
- 給新人機會:照顧新來的 goroutine 先獲取到鎖;
- 多給些機會:照顧新來的和被喚醒的 goroutine 獲取到鎖;
- 解決飢餓:存在競爭關係,有飢餓情況發生,需要解決。
初版 Mutex
// 互斥鎖的結構,包含兩個欄位
type Mutex struct {
key int32 // 鎖是否被持有的標識
sema int32 // 訊號量專用,用以阻塞/喚醒goroutine
}
Unlock 方法可以被任意的 goroutine 呼叫釋放鎖,即使是沒持有這個互斥鎖的 goroutine,也可以進行這個操作。這是因為,Mutex 本身並沒有包含持有這把鎖的 goroutine 的資訊,所以,Unlock 也不會對此進行檢查。Mutex 的這個設計一直保持至今。
在使用 Mutex 的時候,需要嚴格遵循 “誰申請,誰釋放” 原則。
解決飢餓
由於使用了給新人機會,又肯呢個會出現每次都會被新來的 goroutine 獲取到鎖,導致等待的 goroutine 一直獲取不到鎖,造成飢餓問題。
state 欄位設計
type Mutex struct {
state int32
sema uint32
}
const (
mutexLocked = 1 << iota // mutex is locked
mutexWoken
mutexStarving // 從state欄位中分出一個飢餓標記
mutexWaiterShift = iota
starvationThresholdNs = 1e6
)
func (m *Mutex) Lock() {
// Fast path: 幸運之路,一下就獲取到了鎖
if atomic.CompareAndSwapInt32(&m.state, 0, mutexLocked) {
return
}
// Slow path:緩慢之路,嘗試自旋競爭或飢餓狀態下飢餓goroutine競爭
m.lockSlow()
}
func (m *Mutex) lockSlow() {
var waitStartTime int64
starving := false // 此goroutine的飢餓標記
awoke := false // 喚醒標記
iter := 0 // 自旋次數
old := m.state // 當前的鎖的狀態
for {
// 鎖是非飢餓狀態,鎖還沒被釋放,嘗試自旋
if old&(mutexLocked|mutexStarving) == mutexLocked && runtime_canSpin(iter) {
if !awoke && old&mutexWoken == 0 && old>>mutexWaiterShift != 0 &&
atomic.CompareAndSwapInt32(&m.state, old, old|mutexWoken) {
awoke = true
}
runtime_doSpin()
iter++
old = m.state // 再次獲取鎖的狀態,之後會檢查是否鎖被釋放了
continue
}
new := old
if old&mutexStarving == 0 {
new |= mutexLocked // 非飢餓狀態,加鎖
}
if old&(mutexLocked|mutexStarving) != 0 {
new += 1 << mutexWaiterShift // waiter數量加1
}
if starving && old&mutexLocked != 0 {
new |= mutexStarving // 設定飢餓狀態
}
if awoke {
if new&mutexWoken == 0 {
throw("sync: inconsistent mutex state")
}
new &^= mutexWoken // 新狀態清除喚醒標記
}
// 成功設定新狀態
if atomic.CompareAndSwapInt32(&m.state, old, new) {
// 原來鎖的狀態已釋放,並且不是飢餓狀態,正常請求到了鎖,返回
if old&(mutexLocked|mutexStarving) == 0 {
break // locked the mutex with CAS
}
// 處理飢餓狀態
// 如果以前就在佇列裡面,加入到佇列頭
queueLifo := waitStartTime != 0
if waitStartTime == 0 {
waitStartTime = runtime_nanotime()
}
// 阻塞等待
runtime_SemacquireMutex(&m.sema, queueLifo, 1)
// 喚醒之後檢查鎖是否應該處於飢餓狀態
starving = starving || runtime_nanotime()-waitStartTime > starvationThresholdNs
old = m.state
// 如果鎖已經處於飢餓狀態,直接搶到鎖,返回
if old&mutexStarving != 0 {
if old&(mutexLocked|mutexWoken) != 0 || old>>mutexWaiterShift == 0 {
throw("sync: inconsistent mutex state")
}
// 有點繞,加鎖並且將waiter數減1
delta := int32(mutexLocked - 1<<mutexWaiterShift)
if !starving || old>>mutexWaiterShift == 1 {
delta -= mutexStarving // 最後一個waiter或者已經不飢餓了,清除飢餓標記
}
atomic.AddInt32(&m.state, delta)
break
}
awoke = true
iter = 0
} else {
old = m.state
}
}
}
func (m *Mutex) Unlock() {
// Fast path: drop lock bit.
new := atomic.AddInt32(&m.state, -mutexLocked)
if new != 0 {
m.unlockSlow(new)
}
}
func (m *Mutex) unlockSlow(new int32) {
if (new+mutexLocked)&mutexLocked == 0 {
throw("sync: unlock of unlocked mutex")
}
if new&mutexStarving == 0 {
old := new
for {
if old>>mutexWaiterShift == 0 || old&(mutexLocked|mutexWoken|mutexStarving) != 0 {
return
}
new = (old - 1<<mutexWaiterShift) | mutexWoken
if atomic.CompareAndSwapInt32(&m.state, old, new) {
runtime_Semrelease(&m.sema, false, 1)
return
}
old = m.state
}
} else {
runtime_Semrelease(&m.sema, true, 1)
}
}
思考問題
Q: 目前 Mutex 的 state 欄位有幾個意義,這幾個意義分別是由哪些欄位表示的?
A:state 欄位一共有四個子欄位,前三個 bit 是 mutexLocked(鎖標記)、mutexWoken(喚醒標記)、mutexStarving(飢餓標記),剩餘 bit 標示 mutexWaiter(等待數量)。
Q: 等待一個 Mutex 的 goroutine 數最大是多少?是否能滿足現實的需求?
目前的設計來看取決於 state 的型別,目前是 int32,由於3個位元組代表了狀態,還有: 2^(32 – 3) – 1 等於 536870911,一個 goroutine 初始化的為 2kb,約等於 1024 GB 即 1TB,目前記憶體體量那麼大的服務還是少有的,可以滿足現在的使用。
常見錯誤的四種場景
Lock/Unlock 不是成對出現、Copy 已使用的 Mutex、重入和死鎖。
本作品採用《CC 協議》,轉載必須註明作者和本文連結