Go 併發程式設計 - 併發安全(二)

燈火消逝的碼頭發表於2023-10-31

什麼是併發安全

併發情況下,多個執行緒或協程會同時操作同一個資源,例如變數、資料結構、檔案等。如果不保證併發安全,就可能導致資料競爭、髒讀、髒寫、死鎖、活鎖、飢餓等一系列併發問題,產生重大的安全隱患,比如12306搶到同一張火車票、多個使用者搶到只剩一件庫存的商品。而併發安全就是為了避免這些問題。Golang 中有一些原則和工具來保證併發安全,例如:

  1. 遵循“透過通訊來共享記憶體,而不是透過共享記憶體通訊”的理念,儘量使用 channel 來傳遞資料,而不是使用共享變數。
  2. 如果必須使用共享變數,那麼要使用合理的鎖來避免資料競爭。
  3. 如果使用鎖,要注意鎖的粒度和範圍,儘量減少鎖的持有時間和影響範圍,避免死鎖和活鎖。

關於更為詳細的併發安全性:可以參考:理解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.Mutexsync.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

  1. RWMutex 是單寫多讀鎖,該鎖可以加多個讀鎖或者一個寫鎖。
  2. 讀鎖佔用的情況下會阻止寫,不會阻止讀,多個 goroutine 可以同時獲取資源,使用 RLockRUnlock 加鎖解鎖。
  3. 寫鎖會阻止其他 goroutine 進來,讀寫不論,整個鎖住的資源由該 goroutine 獨佔,使用 LockUnlock 加鎖解鎖。
  4. 應該只在頻繁讀取,少量寫入的情況下使用讀寫互斥鎖
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 並沒有獲取長度的方法,只能在遍歷的時候自行計算。

本系列文章:

  1. Go 併發程式設計 - Goroutine 基礎 (一)
  2. Go 併發程式設計 - 併發安全(二)
  3. Go 併發程式設計 - runtime 協程排程(三)

相關文章