sync.RWMutex 是 Go 用來保護共享狀態的讀寫鎖。它的核心用途是允許多個讀取者同時讀取,但寫入者必須獨占資料,避免 goroutine 同時讀寫 map、slice 或 struct 時產生資料競爭。

本章目標

學完本章後,你將能夠:

  1. 理解 data race 的風險
  2. 區分 MutexRWMutex
  3. RLock / RUnlock 保護讀取
  4. Lock / Unlock 保護寫入
  5. 避免回傳內部 map 或 slice 破壞鎖邊界

【觀察】共享 map 不能被多個 goroutine 無保護地讀寫

共享狀態的核心規則是:只要多個 goroutine 可能同時讀寫同一份資料,就必須用同步機制保護。以下程式同時讀寫 map,存在 data race:

 1type UserRepository struct {
 2    users map[string]User
 3}
 4
 5func (r *UserRepository) Set(id string, user User) {
 6    r.users[id] = user
 7}
 8
 9func (r *UserRepository) Get(id string) (User, bool) {
10    user, ok := r.users[id]
11    return user, ok
12}

如果 SetGet 從不同 goroutine 同時執行,map 可能被同時讀寫。Go 的 map 不保證這種情境安全。

【判讀】RWMutex 區分讀取與寫入

RWMutex 的核心規則是:讀取使用 RLock,寫入使用 Lock;多個讀取可並行,寫入會排他。

 1type UserRepository struct {
 2    users map[string]User
 3    mu    sync.RWMutex
 4}
 5
 6func (r *UserRepository) Set(id string, user User) {
 7    r.mu.Lock()
 8    defer r.mu.Unlock()
 9    r.users[id] = user
10}
11
12func (r *UserRepository) Get(id string) (User, bool) {
13    r.mu.RLock()
14    defer r.mu.RUnlock()
15    user, ok := r.users[id]
16    return user, ok
17}

Set 修改 map,所以用 LockGet 只讀 map,所以用 RLock

【策略】鎖保護的是資料不變式

鎖範圍的核心規則是:鎖要包住所有需要一致觀察或一致修改的資料。鎖的邊界應涵蓋完整不變式,慢速 I/O、網路呼叫與和共享資料無關的計算則應放在鎖外。

例如,這個更新同時修改兩個欄位,兩個欄位要在同一把鎖內更新:

1func (r *UserRepository) Add(user User) {
2    r.mu.Lock()
3    defer r.mu.Unlock()
4
5    r.users[user.ID] = user
6    r.count++
7}

如果 userscount 分開鎖,讀者可能看到 map 已更新但 count 還沒更新的中間狀態。

【執行】回傳資料時要保留 copy boundary

鎖邊界的核心規則是:鎖只能保護鎖內操作;回傳內部 map 會讓呼叫者在鎖外修改資料,破壞 repository 對狀態的控制權。

不安全做法:

1func (r *UserRepository) Users() map[string]User {
2    r.mu.RLock()
3    defer r.mu.RUnlock()
4    return r.users
5}

安全做法是回傳複製:

 1func (r *UserRepository) Users() map[string]User {
 2    r.mu.RLock()
 3    defer r.mu.RUnlock()
 4
 5    result := make(map[string]User, len(r.users))
 6    for id, user := range r.users {
 7        result[id] = user
 8    }
 9    return result
10}

呼叫者拿到的是複本,不能繞過 UserRepository 修改內部狀態。

Mutex 還是 RWMutex?

選擇鎖的核心規則是:讀多寫少且讀操作可並行時用 RWMutex;不確定時先用 Mutex,設計更簡單。

適合情境
sync.Mutex狀態小、讀寫都簡單、沒有明顯讀多寫少
sync.RWMutex讀取頻繁、寫入較少、讀操作可安全並行

sync.Mutex 的核心優勢是簡單。若狀態很小、讀寫都很快,或讀寫比例尚未明確,先使用 Mutex 通常更容易維護。它讓每次存取都走同一條鎖路徑,讀者也比較容易確認資料何時被保護。

sync.RWMutex 的核心優勢是讀多寫少時可以讓多個讀取並行。它適合像 in-memory cache、狀態查詢 repository 或連線註冊表這類讀取頻繁的資料結構。使用它時,寫入仍然要用 Lock,因為 RLock 只適合保護純讀取。

鎖選擇的判斷重點是資料不變式與讀寫比例。若讀取本身會組裝複雜資料、需要複製大型 map,或很快就會呼叫外部 I/O,RWMutex 帶來的並行讀取收益可能被複雜度抵消。

替代方案:什麼時候不用 RWMutex

RWMutex 不是共享狀態保護的唯一選擇。三類替代方案各有適用條件:

方案適用情境跟 RWMutex 對比
sync.Mapkey 集合大、entries 異步增減、讀寫分散在不同 key內建讀寫並行、無全域鎖;但語意不同(無 size、無 range 一致性)
sync/atomic單一純量(counter、flag、pointer)無鎖、最快;但只能保護單一值、不能保護結構不變式
Channel-based coordination狀態由單一 owner goroutine 持有、其他 goroutine 透過 channel 傳訊息用 ownership 取代 sharing;適合 producer / consumer pattern、見 4.2 channel

判別準則:

  • 保護多欄位不變式(如 users + count 同步)→ RWMutexMutex
  • 保護單一純量且操作可表達為 atomic op(CAS、increment)→ sync/atomic
  • 保護大量獨立 key 且無跨 key 不變式 → sync.Map
  • 狀態可由單一 owner 持有、外部用訊息驅動 → channel-based、見 4.2 / 4.5 backpressure

選錯方案的代價:用 sync/atomic 保護需要不變式的多欄位 → silent atomicity violation;用 sync.Map 期待 range 一致性 → 拿到 inconsistent snapshot;用 channel 處理需要嚴格 ordering 的 fan-in → 順序錯亂。

RWMutex 不解的問題

RWMutex 解的是 data race(多 goroutine 同時讀寫同一份資料的 visible race)。下列問題不在 RWMutex 防護範圍、必須由其他機制處理:

不防的問題為什麼不解該用什麼
Deadlock多把鎖的鎖順序不一致、RWMutex 沒有偵測能力鎖排序協議、go test -race 並非 deadlock detector
StarvationRWMutex 設計上 reader 多時 writer 可能長期等不到(Go 實作有部分 fairness 保護)量測 lock 等待時間、讀多時切 channel-based 或 sharded 鎖
Lock contention scalinggoroutine 增多時、單把鎖的競爭成本可能 dominate;RWMutex 多核 scalability 弱sharded lock、sync.Map、無鎖結構
Context cancellationreader 已經 hold RLock 時、context 取消不會強制釋放;reader 必須主動 check ctxlock 內快進快出、長操作放鎖外、check ctx
Atomicity violation把多步操作拆到多次 Lock/Unlock 中間、其他 goroutine 可能看到中間狀態拉大鎖範圍、或改 transaction-like API
Memory ordering(跨鎖)RWMutex 只保證鎖內 happens-before、跨鎖讀寫的 ordering 沒保證用 channel 傳遞 ordering、或 atomic load/store

判讀訊號:

  • go test -race pass、production 仍偶發資料異常 → 可能 atomicity violation 或 ordering bug、不是 data race
  • 多核 CPU 加倍但 throughput 不增 → lock contention dominate、考慮 shard
  • p99 latency 在高 concurrency 下爆炸 → reader 排隊或 starvation、查 lock 等待 metric
  • shutdown 時 goroutine 不退 → reader hold RLock + 未 check ctx、補 context 檢查

Context dependence:scale 改變策略

RWMutex 的有效性會隨 deployment 條件變化:

  • Map 大小:copy 成本隨 entries 線性增長、1k entries 廉價、1M entries 每次 copy 都是 GC pressure 來源;大 map 改 sync.Map 或 sharded
  • 讀寫比例:90% 讀以下、RWMutex 收益不顯著、Mutex 簡單;讀寫接近時 RWMutex 的內部 atomic 操作成本可能反而比 Mutex 慢
  • Goroutine 數量:少(< 10)時 contention 微、多(> 1000)時 RWMutex 不適合、要 shard 或換 lock-free 結構
  • 持鎖時間:鎖內 microsecond 級 OK、毫秒級會堆隊;鎖內絕不做 I/O / 網路呼叫

選擇 RWMutex 前先問四件事

RWMutex 只解 data race subset——不解 deadlock / starvation / atomicity violation / context cancellation / 多核 contention scaling。狀態可表達為 atomic op、單 owner channel、或大量獨立 key 時、sync/atomic / channel-based / sync.Map 通常更合適。選擇前先問:「不變式跨幾個欄位?讀寫比例?goroutine 數量?持鎖時間?」