併發編程的藝術:Go 語言中的 Sync 包技巧

Go 語言 sync 包與鎖: 限制線程對變量的訪問

在 Go 語言的併發編程中, 經常需要限制不同 goroutine 對共享資源的訪問, 避免出現競態問題 (race condition)。

Go 語言標準庫提供了 sync 包來實現這種互斥訪問和同步操作。

本文將介紹 sync 包中的各種鎖機制, 並通過示例展示它們的實際應用。主要內容包括

一、背景介紹

當有多個 goroutine 併發訪問同一個變量時, 很可能出現以下問題:

  • 數據競爭: 對變量的多次讀寫操作交錯執行, 導致結果不確定。

  • 髒讀: 一個 goroutine 讀取了另一個正在修改中的, 不一致的數據。

這時就需要使用鎖 (mutex) 來限制對變量的訪問, 確保同一時間只有一個 goroutine 可以訪問變量。

Go 語言標準庫 sync 包提供了豐富的鎖實現, 適用於不同的場景。

二、sync.Mutex 互斥鎖

sync.Mutex 是一個常用的互斥鎖, 它通過保證同一時間只有一個 goroutine 可以獲取鎖, 從而實現對共享資源的互斥訪問。

使用 sync.Mutex 有以下三個主要方法:

  • Lock 獲取鎖, 會阻塞直到獲取鎖爲止

  • Unlock 釋放鎖

  • TryLock 嘗試獲取鎖, 如果鎖已經被佔用則不會等待直接返回

var count int
var mutex sync.Mutex
func increase() {
mutex.Lock()
defer mutex.Unlock()
count++
}

這樣在 increase 函數中, 通過獲取互斥鎖確保同一時間只有一個 goroutine 可以增加 count 變量, 避免衝突。

Mutex 確保同一時間只有一個 goroutine 進入臨界區, 從而實現互斥訪問。

三、sync.RWMutex 讀寫互斥鎖

sync.RWMutex 可以提供更細粒度的讀寫訪問控制。

它包含以下方法:

  • RLock 獲取讀鎖

  • RUnlock 釋放讀鎖

  • Lock 獲取寫鎖

  • Unlock 釋放寫鎖

var count int
var rwMutex sync.RWMutex
func read() {
rwMutex.RLock()
defer rwMutex.RUnlock()
print(count)
}
func write() {
rwMutex.Lock()
defer rwMutex.Unlock()
count++
}

多個讀操作可以同時執行, 而寫需要等待前面的讀和寫完成。

這樣可以提高併發能力。

四、sync.WaitGroup 等待組

sync.WaitGroup 可以用於等待一組 goroutine 結束後再繼續執行。主要包含以下方法:

  • Add 添加一個等待單位

  • Done 表示一個等待單位完成

  • Wait 阻塞直到所有等待單位完成

var wg sync.WaitGroup
func worker() {
defer wg.Done()
// do work
}
wg.Add(1)
go worker()
wg.Wait() // 等待worker完成

WaitGroup 非常適合需要等待批量 goroutine 結束的場景。

五、sync.Once 一次性初始化

sync.Once 提供一次性初始化的功能, 確保某個初始化邏輯只執行一次。

var once sync.Once
var config *Config
func initialize() {
config = loadConfig()
}
func GetConfig() *Config {
once.Do(initialize)
return config
}

一次性初始化在一些如讀配置、建立數據庫連接等場景很有用。

六、sync.Map 線程安全 map

sync.Map 提供了一個可以併發安全使用的 map 實現:

var configMap sync.Map
configMap.Store("timeout", 500)
if _, ok := configMap.Load("timeout"); ok {
// 使用超時
}

sync.Map 內部使用鎖機制來保證併發安全, 相比傳統 map 有更好的擴展性。

七、sync.Pool 對象池

sync.Pool 實現了一個可以重用的對象池:

var bufferPool sync.Pool
func NewBuffer() *Buffer {
v := bufferPool.Get()
if v == nil {
return &Buffer{} 
}
return v.(*Buffer)
}
// 使用後
bufferPool.Put(b)

對象池可以有效減少對象頻繁創建和銷燬的性能開銷。

八、應用示例

1. 銀行轉賬

實現一個銀行轉賬的例子, 要保證併發轉賬時餘額計算正確:

type Account struct {
balance int
mutex   sync.Mutex
}
func (a *Account) transfer(amount int, target *Account) {
a.mutex.Lock()
target.mutex.Lock()
defer a.mutex.Unlock()
defer target.mutex.Unlock()
a.balance -= amount
target.balance += amount
}

使用鎖可以保證一次只有一個轉賬操作能夠進行。

2. 消息隊列

實現一個阻塞式的消息隊列, 支持多個接收者:

type MessageQueue struct {
queue []interface{}
mutex sync.RWMutex
}
func NewMessageQueue() *MessageQueue {
return &MessageQueue{queue: make([]interface{}, 0)}
}
func (q *MessageQueue) Enqueue(msg interface{}) {
q.mutex.Lock()
defer q.mutex.Unlock()
q.queue = append(q.queue, msg)
}
func (q *MessageQueue) Dequeue() interface{} {
q.mutex.RLock()
defer q.mutex.RUnlock()
// 獲取首元素
msg := q.queue[0]
q.queue = q.queue[1:]
return msg
}

讀寫鎖允許多個 goroutine 併發取消息, 提高效率。

九、原理分析

這些同步工具大都是基於 channel 和原子操作實現的。以 Mutex 爲例:

type Mutex struct {
state int32
sema  uint32
}
func (m *Mutex) Lock() {
// 嘗試用CAS修改state
// 如果失敗則進入阻塞通過sema信號阻塞
// 直到獲取鎖爲止
}

信號量 sema 配合 state 狀態實現鎖的排他訪問語義。其他同步工具原理類似。

十、總結

Go 語言通過 sync 包內的各種鎖機制可以安全方便地實現多線程同步訪問。要合理使用鎖, 還需要注意以下幾點:

  • 加鎖範圍要精簡, 避免影響併發度

  • 讀操作多時優先考慮讀寫鎖

  • 使用 defer 釋放鎖可以避免死鎖

  • 根據場景選擇合適的同步工具

學習使用 sync 包是 Go 併發編程的重要一步, 可以避免許多競態條件問題。只有掌握 Go 語言併發模式, 才能發揮它的最大效能。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/OmHr9LwroBJQ57glRcbd6w