什麼是併發安全
併發情況下,多個執行緒或協程會同時操作同一個資源,例如變數、資料結構、檔案等。如果不保證併發安全,就可能導致資料競爭、髒讀、髒寫、死鎖、活鎖、飢餓等一系列併發問題,產生重大的安全隱患,比如12306搶到同一張火車票、多個使用者搶到只剩一件庫存的商品。而併發安全就是為了避免這些問題。Golang 中有一些原則和工具來保證併發安全,例如:
- 遵循“透過通訊來共享記憶體,而不是透過共享記憶體通訊”的理念,儘量使用 channel 來傳遞資料,而不是使用共享變數。
- 如果必須使用共享變數,那麼要使用合理的鎖來避免資料競爭。
- 如果使用鎖,要注意鎖的粒度和範圍,儘量減少鎖的持有時間和影響範圍,避免死鎖和活鎖。
關於更為詳細的併發安全性:可以參考:理解Golang 賦值的併發安全性。
資源競爭
所有資源競爭就是多個 goroutine 訪問某個共享的資源,我們來看一個資源競爭的例子:
var wg sync.WaitGroup
func add(count *int) {
defer wg.Done()
for i := 0; i < 10000; i++ {
*count = *count + 1
}
}
func main() {
count := 0
wg.Add(3)
for i := 0; i < 3; i++ {
go add(&count)
}
wg.Wait()
fmt.Println(count)
}
該程式的每一次執行結果都不同, 就是因為協程之間出現了資源競爭,在讀取更新 count 這個過程中,被其他協程橫插了一腳,改變了 count 的值,沒有保證原子性。下面我們透過互斥鎖來鎖住在讀取更新過程的 count 的值,來使 count 的值列印正確。
互斥鎖和讀寫互斥鎖
sync 包提供了透過 sync.Mutex
和 sync.RWMutex
來實現互斥鎖和讀寫互斥鎖。
sync 互斥鎖(sync.Mutex)是一種最簡單的鎖型別,當一個 goroutine 獲得了資源後,其他 goroutine 就只能等待這個 goroutine 釋放該資源。互斥鎖可以保證對共享資源的原子訪問,避免併發衝突。
sync 讀寫互斥鎖(sync.RWMutex)是一種更復雜的鎖型別,它允許多個 goroutine 同時獲取讀鎖,但只允許一個 goroutine 獲取寫鎖。讀寫互斥鎖適用於讀多寫少的場景下,它比互斥鎖更高效。
sync.Mutex
sync.Mutex 使用 Lock() 加鎖,Unlock() 解鎖,如果對未解鎖的 Mutex 使用 Lock() 會阻塞當前程式執行,我們來看加入了互斥鎖後的程式:
var wg sync.WaitGroup
var l sync.Mutex
func add(count *int) {
defer wg.Done()
l.Lock() // 鎖住 count 資源,阻塞程式執行,直到 Unlock
for i := 0; i < 10000; i++ {
*count = *count + 1
}
l.Unlock()
}
func main() {
count := 0
wg.Add(3)
for i := 0; i < 3; i++ {
go add(&count)
}
wg.Wait()
fmt.Println(count)
}
sync.RWMutex
- RWMutex 是單寫多讀鎖,該鎖可以加多個讀鎖或者一個寫鎖。
- 讀鎖佔用的情況下會阻止寫,不會阻止讀,多個 goroutine 可以同時獲取資源,使用
RLock
和RUnlock
加鎖解鎖。 - 寫鎖會阻止其他 goroutine 進來,讀寫不論,整個鎖住的資源由該 goroutine 獨佔,使用
Lock
和Unlock
加鎖解鎖。 - 應該只在頻繁讀取,少量寫入的情況下使用讀寫互斥鎖
var m sync.RWMutex
var i = 0
func main() {
go write()
go write()
go read()
go read()
go read()
time.Sleep(2 * time.Second)
}
func read() {
fmt.Println(i, "我準備獲取讀鎖了")
m.RLock()
fmt.Println(i, "我要開始讀資料了,所有寫資料的都需要等待1s")
time.Sleep(1 * time.Second)
m.RUnlock()
fmt.Println(i, "我已經釋放了讀鎖,可以繼續寫資料了")
}
func write() {
fmt.Println(i, "我準備獲取寫鎖了")
m.Lock()
fmt.Println(i, "我要開始寫資料了,所有人都需要等待1s")
time.Sleep(1 * time.Second)
i++
m.Unlock()
fmt.Println(i, "我已經釋放了寫鎖,你們可以繼續了")
}
// 結果
0 我準備獲取讀鎖了
0 我要開始讀資料了,所有寫資料的都需要等待1s
0 我準備獲取讀鎖了
0 我要開始讀資料了,所有寫資料的都需要等待1s
0 我準備獲取讀鎖了
0 我要開始讀資料了,所有寫資料的都需要等待1s
0 我準備獲取寫鎖了
0 我準備獲取寫鎖了
0 我已經釋放了讀鎖,可以繼續寫資料了
0 我已經釋放了讀鎖,可以繼續寫資料了
0 我已經釋放了讀鎖,可以繼續寫資料了
0 我要開始寫資料了,所有人都需要等待1s
1 我已經釋放了寫鎖,你們可以繼續了
讀寫互斥鎖有點難以理解,但是隻要記住讀寫互斥永遠是互斥的,就理解了大半。為了應對讀鎖長久佔用,導致寫鎖遲遲不能更新資料,導致併發飢餓問題,所以在 Golang 的讀寫互斥鎖中,寫鎖比讀鎖優先順序更高。
sync.once
sync.once 是一個極為強大的功能,它可以確保一個函式只能被執行一次。通常做來在併發執行前初始化一次的共享資源。
func main() {
once := &sync.Once{}
for i := 0; i < 10; i++ {
go func(i int) {
once.Do(func() {
fmt.Printf("i的值 %d\n", i)
})
}(i)
}
time.Sleep(1 * time.Second)
}
這段程式碼始終只會列印一次 i 的值。
原子操作
為了實現變數值的併發情況下安全賦值,除了互斥鎖外,Golang 還提供了 atomic 包,他能保證在變數在讀寫時不受其他 goroutine 影響。atomic 是透過 CPU 指令在硬體層面上實現的,比互斥鎖效能更好。當然,互斥鎖一般來說是對程式碼塊的併發控制,atomic 是對某個變數的併發控制,二者側重點不同。另外,atomic 是一個很底層的包,除非在一些非常追求的效能的地方,否則其他地方都不推薦使用。
atomic.Add
add 方法比較容易理解,就是對一個值進行增加操作:
func AddInt32(addr *int32, delta int32) (new int32)
func AddInt64(addr *int64, delta int64) (new int64)
func AddUint32(addr *uint32, delta uint32) (new uint32)
func AddUint64(addr *uint64, delta uint64) (new uint64)
func AddUintptr(addr *uintptr, delta uintptr) (new uintptr)
使用示例:
var a int32 = 1
atomic.AddInt32(&a, 2)
fmt.Println(a) // 輸出3
atomic.AddInt32(&a, -1) // delta 是負值的話會減少該值
fmt.Println(a) // 輸出2
atomic.CompareAndSwap
CompareAndSwap用作比較置換值,如果等於,則更新值,返回 true,否則返回 false:
func CompareAndSwapInt32(addr *int32, old, new int32) (swapped bool)
func CompareAndSwapInt64(addr *int64, old, new int64) (swapped bool)
func CompareAndSwapPointer(addr *unsafe.Pointer, old, new unsafe.Pointer) (swapped bool)
func CompareAndSwapUint32(addr *uint32, old, new uint32) (swapped bool)
func CompareAndSwapUint64(addr *uint64, old, new uint64) (swapped bool)
func CompareAndSwapUintptr(addr *uintptr, old, new uintptr) (swapped bool)
使用示例:
var (
a int32 = 1
b bool
)
b = atomic.CompareAndSwapInt32(&a, 1, 2)
fmt.Println(a) // 輸出2
fmt.Println(b) // 輸出true
b = atomic.CompareAndSwapInt32(&a, 1, 3)
fmt.Println(a) // 輸出2
fmt.Println(b) // 輸出false
atomic.Swap
Swap方法不比較,直接置換值:
func SwapInt32(addr *int32, new int32) (old int32)
func SwapInt64(addr *int64, new int64) (old int64)
func SwapPointer(addr *unsafe.Pointer, new unsafe.Pointer) (old unsafe.Pointer)
func SwapUint32(addr *uint32, new uint32) (old uint32)
func SwapUint64(addr *uint64, new uint64) (old uint64)
func SwapUintptr(addr *uintptr, new uintptr) (old uintptr)
使用示例:
var (
a int32 = 1
old int32
)
old = atomic.SwapInt32(&a, 2)
fmt.Println(a) // 輸出2
fmt.Println(old) // 輸出1
atomic.Load
Load 用來讀取值:
func LoadInt32(addr *int32) (val int32)
func LoadInt64(addr *int64) (val int64)
func LoadPointer(addr *unsafe.Pointer) (val unsafe.Pointer)
func LoadUint32(addr *uint32) (val uint32)
func LoadUint64(addr *uint64) (val uint64)
func LoadUintptr(addr *uintptr) (val uintptr)
使用示例:
var (
a int32 = 1
value int32
)
value = atomic.LoadInt32(&a)
fmt.Println(value) // 輸出1
atomic.Store
Store 用來將一個值存到變數中,Load 不會讀取到存到一半的值:
func StoreInt32(addr *int32, val int32)
func StoreInt64(addr *int64, val int64)
func StorePointer(addr *unsafe.Pointer, val unsafe.Pointer)
func StoreUint32(addr *uint32, val uint32)
func StoreUint64(addr *uint64, val uint64)
func StoreUintptr(addr *uintptr, val uintptr)
使用示例:
var a int32
atomic.StoreInt32(&a, 1)
fmt.Println(a) // 輸出1
atomic.Value
Value 實現了對任意值的儲存、讀取、置換、比較置換:
func (v *Value) Store(val any)
func (v *Value) Load() (val any)
func (v *Value) Swap(new any) (old any)
func (v *Value) CompareAndSwap(old, new any)
使用示例:
var v atomic.Value
v.Store(1)
fmt.Println(v.Load()) // 1
v.Swap(2)
fmt.Println(v.Load()) // 2
b := v.CompareAndSwap(2, 3)
fmt.Println(v.Load()) // 3
fmt.Println(b)
使用Swap置換值時,必須要保持原有的資料型別,否則就會 panic: sync/atomic: swap of inconsistently typed value into Value [recovered]。
需要注意的是,atomic.value 對於複雜的資料結構不能保證原子操作,如切片、對映等。
sync.map
go 在併發下,同時讀 map 是安全的,但是讀寫 map 會引發競爭,導致 panic: fatal error: concurrent map read and map write。
// 建立一個map
m := make(map[int]int)
// 開啟兩個協程不停的對map寫入資料
go func() {
for {
m[1] = 1
}
}()
go func() {
for {
_ = m[1]
}
}()
for {
}
// 結果
fatal error: concurrent map read and map write
為了解決這個問題,可以在寫 map 之前加入鎖:
l := sync.Mutex{}
l.Lock()
m[1] = 1
l.Unlock()
這樣處理程式上執行是沒問題了,但是效能並不高。go 在 1.9 版本中加入了效率較高的併發安全:sync.map:
func (m *Map) Store(key, value any) // 儲存一個資料
func (m *Map) Load(key any) (value any, ok bool) // 讀取一個資料
func (m *Map) Delete(key any) // 刪除一個資料
func (m *Map) Range(f func(key, value any) bool) // 遍歷資料
例項:
var smap sync.Map
// 儲存資料
smap.Store("shanghai", 40000)
smap.Store("nanjing", 10000)
smap.Store("wuhan", 20000)
smap.Store("shenzhen", 30000)
// 讀取值
if v, ok := smap.Load("nanjing"); ok {
fmt.Printf("鍵名:%s,值:%v\n", "nanjing", v)
}
// 刪除
smap.Delete("wuhan")
if v, ok := smap.Load("wuhan"); !ok {
fmt.Printf("鍵名:%s,值:%v\n", "wuhan", v)
}
// 遍歷資料
smap.Range(func(k, v interface{}) bool {
fmt.Printf("鍵名:%s,值:%v\n", k, v)
return true
})
// 結果
鍵名:nanjing,值:10000
鍵名:wuhan,值:<nil>
鍵名:shenzhen,值:30000
鍵名:shanghai,值:40000
鍵名:nanjing,值:10000
sync.map
並沒有獲取長度的方法,只能在遍歷的時候自行計算。
本系列文章: