Go Mutex:保護併發訪問共享資源的利器
大家好,我是 陳明勇,一個熱愛技術,喜歡專研技術的程序員。
歡迎關注公衆號:Go 技術乾貨,一起學習,一起進步!
前言
Go
語言以 高併發 著稱,其併發操作是重要特性之一。雖然併發可以提高程序性能和效率,但同時也可能帶來 競態條件 和 死鎖 等問題。爲了避免這些問題,Go
提供了許多 併發原語,例如 Mutex
、RWMutex
、WaitGroup
、Channel
等,用於實現同步、協調和通信等操作。
本文將着重介紹 Go
的 Mutex
併發原語,它是一種鎖類型,用於實現共享資源互斥訪問。
★
說明:本文使用的代碼基於的 Go 版本:1.20.1
”
Mutex
基本概念
Mutex
是 Go
語言中互斥鎖的實現,它是一種同步機制,用於控制多個 goroutine
之間的併發訪問。當多個 goroutine
嘗試同時訪問同一個共享資源時,可能會導致數據競爭和其他併發問題,因此需要使用互斥鎖來協調它們之間的訪問。
在上述圖片中,我們可以將綠色部分看作是臨界區。當 g1
協程通過 mutex
對臨界區進行加鎖後,臨界區將會被鎖定。此時如果 g2
想要訪問臨界區,就會失敗並進入阻塞狀態,直到鎖被釋放,g2
才能拿到臨界區的訪問權。
結構體介紹
type Mutex struct {
state int32
sema uint32
}
字段:
-
state
state
是一個int32
類型的變量,它存儲着Mutex
的各種狀態信息(未加鎖、被加鎖、喚醒狀態、飢餓狀態),不同狀態通過位運算進行計算。 -
sema
sema
是一個信號量,用於實現Mutex
的等待和喚醒機制。
方法:
-
Lock()
Lock()
方法用於獲取Mutex
的鎖,如果Mutex
已經被其他的goroutine
鎖定,則Lock()
方法會一直阻塞,直到該goroutine
獲取到鎖爲止。 -
UnLock()
Unlock()
方法用於釋放Mutex
的鎖,將Mutex
的狀態設置爲未鎖定的狀態。 -
TryLock()
Go 1.18
版本以後,sync.Mutex
新增一個TryLock()
方法,該方法爲非阻塞式的加鎖操作,如果加鎖成功,返回true
,否則返回false
。雖然
TryLock()
的用法確實存在,但由於其使用場景相對較少,因此在使用時應該格外謹慎。TryLock()
方法註釋如下所示:// Note that while correct uses of TryLock do exist, they are rare, // and use of TryLock is often a sign of a deeper problem // in a particular use of mutexes.
代碼示例
我們先來看一個有併發安全問題的例子
package main
import (
"fmt"
"sync"
)
var cnt int
func main() {
var wg sync.WaitGroup
for i := 0; i < 10; i++ {
wg.Add(1)
gofunc() {
defer wg.Done()
for j := 0; j < 10000; j++ {
cnt++
}
}()
}
wg.Wait()
fmt.Println(cnt)
}
在這個例子中,預期的 cnt
結果爲 10 * 10000 = 100000
。但是由於多個 goroutine
併發訪問了共享變量 cnt
,並且沒有進行任何同步操作,可能導致讀寫衝突(race condition
),從而影響 cnt
的值和輸出結果的正確性。這種情況下,不能確定最終輸出的 cnt
值是多少,每次執行程序得到的結果可能不同。
在這種情況下,可以使用互斥鎖(sync.Mutex
)來保護共享變量的訪問,保證只有一個 goroutine
能夠同時訪問 cnt
,從而避免競態條件的問題。修改後的代碼如下:
package main
import (
"fmt"
"sync"
)
var cnt int
var mu sync.Mutex
func main() {
var wg sync.WaitGroup
for i := 0; i < 10; i++ {
wg.Add(1)
gofunc() {
defer wg.Done()
for j := 0; j < 10000; j++ {
mu.Lock()
cnt++
mu.Unlock()
}
}()
}
wg.Wait()
fmt.Println(cnt)
}
在這個修改後的版本中,使用互斥鎖來保護共享變量 cnt
的訪問,可以避免出現競態條件的問題。具體而言,在 cnt++
操作前,先執行 Lock()
方法,以確保當前 goroutine
獲取到了互斥鎖並且獨佔了共享變量的訪問權。在 cnt++
操作完成後,再執行 Unlock()
方法來釋放互斥鎖,從而允許其他 goroutine
獲取互斥鎖並訪問共享變量。這樣,只有一個 goroutine
能夠同時訪問 cnt
,從而確保了最終輸出結果的正確性。
易錯場景
忘記解鎖
如果使用 Lock()
方法之後,沒有調用 Unlock()
解鎖,會導致其他 goroutine
被永久阻塞。例如:
package main
import (
"fmt"
"sync"
"time"
)
var mu sync.Mutex
var cnt int
func main() {
go increase(1)
go increase(2)
time.Sleep(time.Second)
fmt.Println(cnt)
}
func increase(delta int) {
mu.Lock()
cnt += delta
}
在上述代碼中,通常情況下,cnt
的結果應該爲 3
。然而沒有解鎖操作,其中一個 goroutine
被阻塞,導致沒有達到預期效果,最終輸出的 cnt
可能只能爲 1
或 2
。
正確的做法是使用 defer
語句在函數返回前釋放鎖。
func increase(delta int) {
mu.Lock()
defer mu.Unlock() // 通過 defer 語句在函數返回前釋放鎖
cnt += delta
}
重複加鎖
重複加鎖操作被稱爲可重入操作。不同於其他一些編程語言的鎖實現(例如 Java
的 ReentrantLock
),Go
的 mutex
並不支持可重入操作,如果發生了重複加鎖操作,就會導致死鎖。例如:
package main
import (
"fmt"
"sync"
"time"
)
var mu sync.Mutex
var cnt int
func main() {
go increase(1)
go increase(2)
time.Sleep(time.Second)
fmt.Println(cnt)
}
func increase(delta int) {
mu.Lock()
mu.Lock()
cnt += delta
mu.Unlock()
}
在這個例子中,如果在 increase
函數中重複加鎖,將會導致 mu
鎖被第二次鎖住,而其他 goroutine
將被永久阻塞,從而導致程序死鎖。正確的做法是隻對需要加鎖的代碼段進行加鎖,避免重複加鎖。
基於 Mutex 實現一個簡單的線程安全的緩存
import"sync"
type Cache struct {
data map[string]any
mu sync.Mutex
}
func (c *Cache) Get(key string) (any, bool) {
c.mu.Lock()
defer c.mu.Unlock()
value, ok := c.data[key]
return value, ok
}
func (c *Cache) Set(key string, value any) {
c.mu.Lock()
defer c.mu.Unlock()
c.data[key] = value
}
上述代碼實現了一個簡單的線程安全的緩存。使用 Mutex
可以保證同一時刻只有一個 goroutine
進行讀寫操作,避免多個 goroutine
併發讀寫同一數據時產生數據不一致性的問題。
對於緩存場景,讀操作比寫操作更頻繁,因此使用 RWMutex
代替 Mutex
會更好,因爲 RWMutex
允許多個 goroutine
同時進行讀操作,只有在寫操作時纔會進行互斥鎖定,從而減少了鎖的競爭,提高了程序的併發性能。後續文章會對 RWMutex
進行介紹。
小結
本文主要介紹了 Go
語言中互斥鎖 Mutex
的概念、對應的字段和方法、基本使用和易錯場景,最後基於 Mutex
實現一個簡單的線程安全的緩存。
Mutex
是保證共享資源數據一致性的重要手段,但使用不當會導致性能下降或死鎖等問題。因此,在使用 Mutex
時需要仔細考慮代碼的設計和併發場景,發揮 Mutex
的最大作用。
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/08SNpteXGAneYlErQMDiTA