本文 AI 產出,尚未審核

Golang – 通道與同步

主題:互斥鎖(sync.Mutexsync.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.Mutexsync.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

  • 高頻率的短暫操作:如果鎖的持有時間極短,且競爭激烈,可能會因為頻繁的上下文切換而降低效能。此時可考慮 channelatomicsync/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

最佳實踐清單

  1. 使用 defer 釋放鎖,保持程式可讀性與安全性。
  2. 只在必要時使用 RWMutex:寫入頻率若超過 30% 時,RWMutex 的效益會下降,甚至比 Mutex 更慢。
  3. 避免在 Lock() 之間執行 I/O(如網路、磁碟),以免阻塞其他 goroutine。
  4. 測試競爭:使用 go test -race 來檢測資料競爭問題。
  5. 文件化鎖的擁有者:在程式碼註解中說明哪個函式或哪段流程負責取得與釋放鎖,降低維護成本。

實際應用場景

場景 使用哪種鎖 為何選擇
Web 伺服器的 Session 管理 sync.RWMutex 多數請求僅讀取 Session,偶爾寫入。
計數器(如 API 請求次數) sync.Mutexatomic 簡單遞增,若需求極高併發可改用 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 檢測競爭,再根據 讀寫比例延遲需求 以及 維護成本 決定是使用 MutexRWMutex,或是改用 atomic、channel 等其他同步手段。

掌握好這兩把鎖,能讓你的 Go 程式在高併發環境下保持 正確性效能,為後續更複雜的同步模型(如條件變數、工作池、分布式鎖)奠定堅實基礎。祝開發順利,玩得開心!