Race detector 的核心作用是找出測試執行期間發生的 data race。它能指出未同步讀寫同一份記憶體的位置,但不能取代 ownership、mutex、channel 與狀態邊界設計。

本章目標

學完本章後,你將能夠:

  1. 分辨 data race 與一般邏輯競爭
  2. go test -race ./... 檢查並發路徑
  3. 寫出能觸發共享狀態讀寫的測試
  4. 依 race report 找到讀寫來源
  5. 選擇 mutex、channel owner 或 atomic 修正同步邊界

【觀察】並發 bug 常常不會穩定重現

Data race 的核心問題是測試可能偶爾通過、偶爾失敗,也可能完全不失敗但資料已經不安全。單次執行結果正確,不代表沒有未同步讀寫。

反模式:

1var count int
2
3func increment() {
4    count++
5}

count++ 不是原子操作。它包含讀取、加一、寫回。多個 goroutine 同時執行時,可能互相覆蓋結果,也可能被 race detector 偵測到未同步讀寫。

【判讀】data race 是未同步的並發讀寫

Data race 的核心定義是至少兩個 goroutine 同時存取同一份記憶體,其中至少一個是寫入,而且沒有同步保護。

觸發測試:

 1func TestIncrementRace(t *testing.T) {
 2    var wg sync.WaitGroup
 3
 4    for i := 0; i < 100; i++ {
 5        wg.Add(1)
 6        go func() {
 7            defer wg.Done()
 8            increment()
 9        }()
10    }
11
12    wg.Wait()
13}

一般 go test 不一定會失敗。go test -race 會在 runtime 偵測這類未同步讀寫,並輸出讀取與寫入發生的位置。

【執行】用 go test -race 跑到相關路徑

Race detector 的核心限制是只能檢查實際執行到的程式路徑。沒有被測試覆蓋的 goroutine、handler、repository 或 broadcast path,不會被它發現。

1go test -race ./...

這個指令會用 race detector 跑所有 package 的測試。它會比一般測試慢,但對含有 goroutine、共享 map、WebSocket hub、background worker 的服務非常重要。

若專案很大,可以先針對相關 package:

1go test -race ./internal/websocket ./internal/storage ./internal/worker

範圍縮小能讓日常執行更快,但合併前仍應跑完整路徑。

【策略】併發測試要讓共享狀態真的被同時讀寫

Race detector 的核心前提是測試要製造相關路徑。只建立 repository 卻不並發讀寫,race detector 沒有機會回報。

 1func TestRepositoryConcurrentAccess(t *testing.T) {
 2    repo := NewUserRepository()
 3    ctx := context.Background()
 4
 5    var wg sync.WaitGroup
 6    for i := 0; i < 100; i++ {
 7        i := i
 8        wg.Add(1)
 9        go func() {
10            defer wg.Done()
11            id := fmt.Sprintf("user_%d", i)
12            _ = repo.Save(ctx, User{ID: id})
13            _, _, _ = repo.Find(ctx, id)
14        }()
15    }
16
17    wg.Wait()
18}

這個測試的主要斷言在「讓 race detector 執行共享 map 的讀寫路徑」。若 repository 忘記加 lock,-race 會指出問題。

【執行】WebSocket hub 也需要 race path

WebSocket hub 的核心並發風險是 client 註冊、取消註冊、訂閱變更與 broadcast 可能同時發生。測試應讓這些路徑交錯執行。

 1func TestHubConcurrentBroadcastAndUnregister(t *testing.T) {
 2    hub := NewHub()
 3    clients := make([]*Client, 0, 100)
 4
 5    for i := 0; i < 100; i++ {
 6        client := NewTestClient(fmt.Sprintf("client_%d", i), 8)
 7        client.Subscribe("alerts")
 8        hub.clients[client] = struct{}{}
 9        clients = append(clients, client)
10    }
11
12    var wg sync.WaitGroup
13    wg.Add(2)
14
15    go func() {
16        defer wg.Done()
17        for i := 0; i < 100; i++ {
18            hub.Broadcast("alerts", ServerMessage{Type: "notification"})
19        }
20    }()
21
22    go func() {
23        defer wg.Done()
24        for _, client := range clients {
25            hub.unregisterClient(client)
26        }
27    }()
28
29    wg.Wait()
30}

這個測試是否需要 lock,取決於 hub 的設計。如果 hub 保證所有操作都在單一 event loop 中執行,測試就應該透過 channel 操作,而不是直接呼叫未同步方法。測試要符合 ownership 設計,不應製造不被 API 允許的並發。

【判讀】race report 要看讀寫兩端

Race report 的核心資訊是兩個位置:一端讀或寫,另一端寫。修正時不要只看最後一行,要找出是哪個共享資料缺少同步。

典型報告會包含:

1WARNING: DATA RACE
2Read at 0x...
3  example.com/app.(*UserRepository).Find()
4
5Previous write at 0x...
6  example.com/app.(*UserRepository).Save()

這表示 FindSave 同時碰到同一份資料,且缺少同步。修正方向是在 repository owner 補上 mutex、channel ownership 或其他同步邊界。

【策略】修正方式要對應狀態形狀

修正 data race 的核心選擇是建立正確同步邊界。常見方法有 mutex、channel owner、atomic。

方法適用情境注意事項
mutex多方法讀寫同一份 map/slice/statelock 要保護完整不變式
channel owner狀態修改可集中成事件 loop要設計 reply、shutdown、backpressure
atomic單一數值 counter 或 flag不適合複雜狀態

Mutex 範例:

 1type Counter struct {
 2    mu    sync.Mutex
 3    value int
 4}
 5
 6func (c *Counter) Inc() {
 7    c.mu.Lock()
 8    defer c.mu.Unlock()
 9    c.value++
10}
11
12func (c *Counter) Value() int {
13    c.mu.Lock()
14    defer c.mu.Unlock()
15    return c.value
16}

鎖應該屬於擁有狀態的型別,並保護一個清楚的不變條件。只為了讓 race detector 安靜而到處加鎖,會讓 ownership 分散,後續仍然難以判斷資料一致性。

【判讀】race-free 不代表行為正確

Race detector 的核心邊界是它只找 data race,不保證並發邏輯正確。沒有 data race 的程式仍可能 deadlock、漏訊息、順序錯誤、重複 close 或違反資料語意。

例如:

1select {
2case client.send <- message:
3default:
4    // drop
5}

這段程式可能沒有 data race,但「queue full 時丟訊息」是否正確是服務語意問題。Race detector 不會告訴你該丟、該斷線、還是該寫可靠 queue。

因此並發測試要分成兩層:

  • go test -race 找未同步記憶體存取。
  • 用行為測試檢查 channel close、queue full、context cancel、cleanup、timeout

【測試】把 race check 納入固定流程

Race check 的核心價值來自重複執行。只在出事後手動跑,效果有限。

建議流程:

1go test ./...
2go test -race ./...

日常開發可以先跑相關 package,提交前或 CI 跑完整 race suite。若 race suite 太慢,至少讓含有 hub、repository、worker、client state 的 package 固定跑 -race

本章不處理

本章先處理共享 state、channel ownership 與 goroutine lifecycle 的 race 風險;lock-free 與完整 memory model,會在下列章節再往外延伸:

和 Go 教材的關係

這一章承接的是共享狀態、channel ownership 與 lifecycle;如果你要先回看語言教材,可以讀:

小結

go test -race 是 Go 並發服務的基本安全網,但它只檢查測試執行到的 data race。你仍然需要設計清楚的 state owner、lock boundary、channel ownership 與行為測試。Race-free 不是正確性的全部;它只是可靠性的第一層檢查。