本文 AI 產出,尚未審核
Golang – 通道與同步
主題:互斥鎖(sync.Mutex、sync.RWMutex)
簡介
在多執行緒(goroutine)環境下,共享資源的安全存取是所有 Go 程式設計師必須面對的核心問題。若沒有適當的同步機制,競爭條件(race condition)會導致資料錯亂、程式崩潰,甚至產生難以重現的 bug。Go 標準庫提供了 sync 套件,其中最常使用的兩個鎖是 sync.Mutex(互斥鎖)與 sync.RWMutex(讀寫互斥鎖)。本篇文章將從概念、使用方式、實務範例、常見陷阱與最佳實踐,完整說明這兩種鎖的運作與應用。
核心概念
1. sync.Mutex 基本原理
- 互斥鎖是一種 二元狀態(locked / unlocked)的同步原語。
- 當一個 goroutine
Lock()鎖時,其他任何嘗試Lock()同一把鎖的 goroutine 都會被阻塞,直到持有鎖的 goroutine 呼叫Unlock()。 Mutex不具備重入(re‑entrant)特性;同一個 goroutine 不能在未解鎖的情況下再次Lock(),否則會造成死鎖。
package main
import (
"fmt"
"sync"
"time"
)
func main() {
var mu sync.Mutex
counter := 0
// 產生 5 個 goroutine 同時遞增 counter
for i := 0; i < 5; i++ {
go func(id int) {
mu.Lock() // 取得鎖
defer mu.Unlock() // 確保離開時釋放
fmt.Printf("goroutine %d 取得鎖\n", id)
tmp := counter
time.Sleep(10 * time.Millisecond) // 模擬工作
counter = tmp + 1
fmt.Printf("goroutine %d 完成,counter=%d\n", id, counter)
}(i)
}
// 等待所有 goroutine 結束
time.Sleep(200 * time.Millisecond)
fmt.Println("最終結果:", counter)
}
重點:
defer mu.Unlock()可以保證即使程式在中途 panic,鎖也會被釋放,避免死鎖。
2. sync.RWMutex:讀寫分離的互斥鎖
RWMutex允許 多個讀者同時持有鎖(RLock()),只要沒有寫者持有寫鎖(Lock())。- 寫者 必須獨占鎖,寫入期間會阻止所有新讀者與寫者。
- 讀寫鎖適用於 讀多寫少 的情境,能顯著提升併發效能。
package main
import (
"fmt"
"sync"
"time"
)
type SafeMap struct {
m map[string]int
mu sync.RWMutex
}
// 讀取資料
func (s *SafeMap) Get(key string) (int, bool) {
s.mu.RLock() // 讀鎖
defer s.mu.RUnlock()
val, ok := s.m[key]
return val, ok
}
// 寫入資料
func (s *SafeMap) Set(key string, value int) {
s.mu.Lock() // 寫鎖
defer s.mu.Unlock()
s.m[key] = value
}
func main() {
sm := SafeMap{m: make(map[string]int)}
var wg sync.WaitGroup
// 多個讀者
for i := 0; i < 3; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
for j := 0; j < 5; j++ {
if v, ok := sm.Get("counter"); ok {
fmt.Printf("[Reader %d] counter=%d\n", id, v)
}
time.Sleep(10 * time.Millisecond)
}
}(i)
}
// 單一寫者
wg.Add(1)
go func() {
defer wg.Done()
for i := 0; i < 5; i++ {
sm.Set("counter", i)
fmt.Printf("[Writer] set counter=%d\n", i)
time.Sleep(30 * time.Millisecond)
}
}()
wg.Wait()
}
技巧:在大量讀取且偶爾寫入的情況下,使用
RWMutex能減少寫鎖造成的阻塞,提升整體吞吐量。
3. 鎖的零值(Zero Value)即可直接使用
sync.Mutex 與 sync.RWMutex 的 零值 已經是解鎖狀態,無需額外初始化。只要宣告變數即可直接使用:
var mu sync.Mutex // 零值已是 unlocked
var rw sync.RWMutex // 同上
注意:切勿將鎖的零值作為指標傳遞後再重新賦值(
mu = sync.Mutex{}),這會導致已持有鎖的 goroutine 失去對舊鎖的控制,產生不可預期的行為。
4. 鎖的使用範圍(Lock Scope)
為了降低死鎖與競爭的風險,鎖的範圍應盡可能小。只保護真正需要同步的程式碼段,而不是整個函式。
func UpdateBalance(accounts map[string]int, mu *sync.Mutex, id string, delta int) {
// 鎖住的區塊只包含對共享資源的存取
mu.Lock()
accounts[id] += delta
mu.Unlock()
// 其餘計算或 I/O 可以在鎖外執行
}
5. 何時不該使用 Mutex
- 高頻率的短暫操作:如果鎖的持有時間極短,且競爭激烈,可能會因為頻繁的上下文切換而降低效能。此時可考慮 channel、atomic(
sync/atomic)或 sharding(分片)策略。 - 跨程式碼層級的鎖:避免在高層呼叫者取得鎖後再傳遞給底層函式,這會增加耦合度,且容易忘記解鎖。
常見陷阱與最佳實踐
| 陷阱 | 說明 | 解決方式 |
|---|---|---|
| 死鎖 | 兩個或以上 goroutine 互相等待對方釋放鎖。 | - 保持鎖定順序一致(例如 A → B → C)。 - 使用 defer Unlock() 確保解鎖。 |
| 忘記解鎖 | 程式在錯誤路徑或 return 前未呼叫 Unlock()。 |
- defer 是最安全的寫法。 |
| 鎖的重入 | 同一 goroutine 兩次 Lock() 同一把 Mutex,會永久阻塞。 |
- 若需要重入,改用 sync.RWMutex(讀寫分離)或 channel。 |
| 過度鎖定 | 鎖住過大的程式區塊,導致其他 goroutine 長時間等待。 | - 最小化鎖定範圍,只保護必要的共享變數。 |
| 使用指標錯誤 | 把鎖作為值傳遞,導致每個副本都有自己的鎖,失去同步效果。 | - 傳遞指標(*sync.Mutex)或在結構體中直接嵌入鎖。 |
| 零值重新賦值 | mu = sync.Mutex{} 會產生新鎖,舊鎖仍被持有。 |
- 不要重新賦值,只使用 Lock/Unlock。 |
最佳實踐清單
- 使用
defer釋放鎖,保持程式可讀性與安全性。 - 只在必要時使用
RWMutex:寫入頻率若超過 30% 時,RWMutex的效益會下降,甚至比Mutex更慢。 - 避免在
Lock()之間執行 I/O(如網路、磁碟),以免阻塞其他 goroutine。 - 測試競爭:使用
go test -race來檢測資料競爭問題。 - 文件化鎖的擁有者:在程式碼註解中說明哪個函式或哪段流程負責取得與釋放鎖,降低維護成本。
實際應用場景
| 場景 | 使用哪種鎖 | 為何選擇 |
|---|---|---|
| Web 伺服器的 Session 管理 | sync.RWMutex |
多數請求僅讀取 Session,偶爾寫入。 |
| 計數器(如 API 請求次數) | sync.Mutex 或 atomic |
簡單遞增,若需求極高併發可改用 atomic.AddInt64。 |
| 共享緩存(Cache) | sync.RWMutex + map |
讀取頻繁,寫入較少,使用讀寫鎖提升效能。 |
| 資源池(Worker Pool) | sync.Mutex |
需要保證同時只有一個 goroutine 操作 pool 狀態。 |
| 配置檔動態重載 | sync.RWMutex |
多個 goroutine 同時讀取配置,重載時寫鎖阻止讀取。 |
範例:動態重載設定檔
以下示範如何使用RWMutex在不阻斷讀者的情況下安全更新全域設定。
package main
import (
"encoding/json"
"io/ioutil"
"log"
"sync"
"time"
)
type Config struct {
Port int `json:"port"`
Mode string `json:"mode"`
}
var (
cfg Config
cfgMu sync.RWMutex
)
func loadConfig(path string) error {
data, err := ioutil.ReadFile(path)
if err != nil {
return err
}
var newCfg Config
if err := json.Unmarshal(data, &newCfg); err != nil {
return err
}
cfgMu.Lock()
cfg = newCfg
cfgMu.Unlock()
return nil
}
func GetConfig() Config {
cfgMu.RLock()
defer cfgMu.RUnlock()
return cfg // 返回值是複製,安全給呼叫端
}
func main() {
// 初始載入
if err := loadConfig("config.json"); err != nil {
log.Fatal(err)
}
// 每 10 秒自動重載一次
go func() {
for {
time.Sleep(10 * time.Second)
if err := loadConfig("config.json"); err != nil {
log.Println("reload config error:", err)
} else {
log.Println("config reloaded")
}
}
}()
// 模擬服務持續讀取設定
for i := 0; i < 5; i++ {
go func(id int) {
for {
c := GetConfig()
log.Printf("[worker %d] using port=%d mode=%s\n", id, c.Port, c.Mode)
time.Sleep(2 * time.Second)
}
}(i)
}
select {}
}
總結
sync.Mutex為最基礎的互斥鎖,適用於「寫」操作頻繁或資料結構簡單的情境。sync.RWMutex則在「讀多寫少」的場景提供更高併發度,但使用時必須留意寫入會阻塞所有讀者。- 正確的 鎖定範圍、使用
defer、避免重入與過度鎖定,是防止死鎖與效能瓶頸的關鍵。 - 在實務開發中,先以
go test -race檢測競爭,再根據 讀寫比例、延遲需求 以及 維護成本 決定是使用Mutex、RWMutex,或是改用atomic、channel 等其他同步手段。
掌握好這兩把鎖,能讓你的 Go 程式在高併發環境下保持 正確性 與 效能,為後續更複雜的同步模型(如條件變數、工作池、分布式鎖)奠定堅實基礎。祝開發順利,玩得開心!