Article

Go Racedetectorで並行処理のバグを確実に見つける

高田晃太郎
Go Racedetectorで並行処理のバグを確実に見つける

Go公式ドキュメントによれば、Racedetectorを有効にすると実行時間はおおむね5〜10倍、メモリ使用量は2〜3倍に増えるとされています¹。それでもなお、クラッシュに至らず静かに状態を壊すデータレースは、発見の遅れが重大な障害コストに直結します⁷。再現性が低く、ローカルでは発生しないのに本番だけで顔を出すという厄介さも、経営的には看過できません。私はこのギャップを埋める実用的な選択として、Racedetectorを「日常的に」回す設計を推します。CIに恒常的に組み込み、ローカルでは狙いを定めて短時間で回す。そうすることで、速度低下の痛みよりも、事故予防の便益が勝つ状態を作れます。この記事では、原理と限界、実戦での使い分け、ありがちなバグの直し方、そしてROIの考え方を、最小限のコードと具体的な運用手順とともに整理します。

Racedetectorは何を見つけ、どう動くのか

データレースは、同じメモリに対する複数のアクセスがハプンズビフォー(happens-before)関係で順序付けられておらず、そのうち少なくとも1つが書き込みである状況を指します²。ここでのhappens-beforeとは「ある操作が他の操作よりも前に起きたことを、メモリモデル上で保証できる関係」のことです。Goのメモリモデルでは、同期プリミティブ(チャネル送受信、ミューテックス、atomic操作など)によって順序を確立し、並行アクセスを直列化することが求められます⁴。Racedetectorはバイナリにインストゥルメンテーションを挿入し、実行時にアクセスを動的追跡して競合を報告します³。検出は動的であるため、テストや実行が当該コードパスを通らなければ気付けない点は忘れてはいけません³。一方で、false positiveは少なく、unsafeや外部FFI(C/C++との連携)を跨ぐ場合に注意が要る程度です³。

まずは最小の競合例を確認します。共有変数に複数ゴルーチンが無保護でアクセスすると、-raceで確実に赤信号が灯ります。ファイルに保存して go run -race main.go のように実行すると、どの行でデータレースが発生したかがスタックトレース付きで表示されます。

package main

import (
    "fmt"
    "sync"
    "time"
)

func main() {
    var x int
    var wg sync.WaitGroup
    for i := 0; i < 4; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            for j := 0; j < 1000; j++ {
                // 競合: 同じ変数xに並行アクセス(書き込みあり・同期なし)
                x++
            }
        }()
    }
    time.Sleep(10 * time.Millisecond) // スケジューリングの揺らぎを誘発
    wg.Wait()
    fmt.Println("x=", x)
}

修正にはsync.Mutexやsync/atomicの導入が有効です。atomicを使う場合は、読み書き双方をatomicで包み、可視性(happens-before)を満たすことが鍵になります⁴。

package main

import (
    "fmt"
    "runtime"
    "sync/atomic"
)

func main() {
    var x int64
    n := int64(runtime.NumCPU())
    done := make(chan struct{})
    for i := int64(0); i < n; i++ {
        go func() {
            for j := 0; j < 100000; j++ {
                atomic.AddInt64(&x, 1)
            }
            done <- struct{}{}
        }()
    }
    for i := int64(0); i < n; i++ { <-done }
    // 読み取りもatomicで行い、happens-beforeを守る
    fmt.Println("x=", atomic.LoadInt64(&x))
}

Racedetectorは5〜10倍のCPUオーバーヘッド、2〜3倍のメモリを要するとドキュメントは明記しています¹。代わりに、非決定的で見落としやすい欠陥の早期検知を提供します。CIで常時計測する箇所と、ローカルで必要時だけ回す箇所を分けることで、体感の遅さと検出力の両立が図れます。

原理の勘所と限界を押さえる

Racedetectorはランタイムの計測によるアクセス監視で実装され、チャネル送受信、Mutex/RWMutex、WaitGroup、Cond、atomic操作などでhappens-before関係を構築します²³。unsafe.Pointerでのポインタ演算やC/C++連携でGo外へ出ると、検出のカバレッジは下がります³。また、競合がないのに性能劣化だけを招く同期の過剰導入はアンチパターンです。検出結果に対しては、まず最小の再現テストを作り、その上で「必要最小限の同期」で直すのが王道です。

ローカル・CI・コンテナでの実戦運用

Racedetectorは主要な64bitプラットフォーム(Linux/macOS/Windowsのamd64やarm64など)で利用できます⁵。日々の開発では、ローカルの単体テストで狙い撃ちに走らせ、CIでは変更パッケージ周辺は必ず、定期ジョブで全体をカバーする構成が現実的です。Goはテストバイナリ単位で-raceを有効化できるため、重いパッケージを毎回全走査する必要はありません³。テストキャッシュの影響を避けたいときは -count=1 を併用します。

# ローカルでの狙い撃ち(失敗箇所の切り出しに有効)
GOFLAGS="-race" go test ./pkg/cache -run TestConcurrent* -count=1

# リポジトリ全体を一発で(時間はかかる)
go test -race ./... -count=1

# 実行バイナリで再現確認(本番相当の動作を模す)
GOFLAGS="-race" go run ./cmd/service --config ./config/dev.yaml

コンテナ環境ではマルチステージで「テストは-race、本番バイナリは通常ビルド」という切り分けが有効です。こうすることで、CIの検出力と本番イメージの軽量性を両立できます。

# syntax=docker/dockerfile:1
FROM golang:1.22 as builder
WORKDIR /src
COPY . .
# 依存解決
RUN --mount=type=cache,target=/go/pkg/mod go mod download
# Racedetector付きでテスト
RUN --mount=type=cache,target=/root/.cache/go-build go test -race ./... -count=1
# 本番バイナリ(-raceなし)
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /out/app ./cmd/app

FROM gcr.io/distroless/static:nonroot
COPY --from=builder /out/app /app
USER nonroot:nonroot
ENTRYPOINT ["/app"]

GitHub Actionsでも同様にワークフローへ恒常化します。ワークフローは回帰検出のネットを広げ、レビューの心理的負担を下げます³。

name: test-race
on: [push, pull_request]
jobs:
  race:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-go@v5
        with:
          go-version: '1.22'
      - name: Test with race
        run: go test -race ./... -count=1

運用の落とし穴として、-raceは内部的にcgoを有効にする場面があり、クロスコンパイルの制約やランタイム依存が変化します⁵。CIとローカルでglibcやカーネルの差による挙動差が見えたら、まずはテストを縮小再現の形に切り出して検証します。テストのタイムアウトは余裕を持たせ、-race専用ジョブでは並列度を落とすのが安定化に効きます。

頻出パターンで学ぶ:検出と修正の実例

実務でよく遭遇するレースのパターンは似ています。共有マップへの並行書き込み、ループ変数のクロージャ捕捉、HTTPハンドラのキャッシュやカウンタの素朴な実装などです。Racedetectorで赤くなる瞬間を確認し、どの同期が最小かを考える訓練が、チームの保守速度を上げます。

まず共有マップです。Goのmapはスレッドセーフではないため、並行書き込みは即座にレースです。以下はHTTPハンドラで素朴なメモ化を行い、負荷時に壊れる例です。

package main

import (
    "encoding/json"
    "log"
    "net/http"
    "strconv"
)

type cache struct {
    data map[string]string // 同期なしで危険
}

func main() {
    c := &cache{data: make(map[string]string)}
    http.HandleFunc("/u", func(w http.ResponseWriter, r *http.Request) {
        id := r.URL.Query().Get("id")
        if id == "" {
            http.Error(w, "missing id", http.StatusBadRequest)
            return
        }
        // 読みのつもりが、存在しない場合に書くため競合が発生
        if v, ok := c.data[id]; ok {
            _ = json.NewEncoder(w).Encode(map[string]string{"name": v})
            return
        }
        // 疑似的な重い処理
        v := "user-" + strconv.Itoa(len(c.data)+1)
        c.data[id] = v // ← 並行書き込みで競合
        _ = json.NewEncoder(w).Encode(map[string]string{"name": v})
    })
    log.Fatal(http.ListenAndServe(":8080", nil))
}

修正はRWMutexで十分です。読みはRロック、初回生成時のみWロックを取る二重チェックにすると、性能と可読性のバランスが取れます。

package main

import (
    "encoding/json"
    "log"
    "net/http"
    "strconv"
    "sync"
)

type cache struct {
    mu   sync.RWMutex
    data map[string]string
}

func main() {
    c := &cache{data: make(map[string]string)}
    http.HandleFunc("/u", func(w http.ResponseWriter, r *http.Request) {
        id := r.URL.Query().Get("id")
        if id == "" {
            http.Error(w, "missing id", http.StatusBadRequest)
            return
        }
        c.mu.RLock()
        v, ok := c.data[id]
        c.mu.RUnlock()
        if ok {
            _ = json.NewEncoder(w).Encode(map[string]string{"name": v})
            return
        }
        // 初回のみ書き込みロック
        c.mu.Lock()
        // 二重チェック: 別ゴルーチンが先に作った可能性
        if v2, ok2 := c.data[id]; ok2 {
            c.mu.Unlock()
            _ = json.NewEncoder(w).Encode(map[string]string{"name": v2})
            return
        }
        v = "user-" + strconv.Itoa(len(c.data)+1)
        c.data[id] = v
        c.mu.Unlock()
        _ = json.NewEncoder(w).Encode(map[string]string{"name": v})
    })
    log.Fatal(http.ListenAndServe(":8080", nil))
}

次に、ループ変数の捕捉です。forループのインデックスをクロージャが参照すると、意図せず同じ変数を共有し、結果が揺れます。Racedetectorはこれも拾い上げます⁶。

package main

import (
    "fmt"
    "sync"
)

func main() {
    var wg sync.WaitGroup
    for i := 0; i < 3; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            // iを直接参照: 共有されて結果が揺れる
            fmt.Println("bad:", i)
        }()
    }
    wg.Wait()
}

修正は値をループ内で新しい変数へ束縛して渡すことです。これでゴルーチンごとに独立した値になります。

package main

import (
    "fmt"
    "sync"
)

func main() {
    var wg sync.WaitGroup
    for i := 0; i < 3; i++ {
        i := i // シャドウイングで新しいi
        wg.Add(1)
        go func(v int) {
            defer wg.Done()
            fmt.Println("good:", v)
        }(i)
    }
    wg.Wait()
}

最後に、カウンタやメトリクスの更新はatomicが簡潔です。ただし、複合操作(チェックしてから増やす等)はミューテックスの方が安全です。以下はベンチマークで差を確認するための最小例です。-raceの有無で実行時間や割り込みの影響がどう変わるかを観察できます。

package counter

import (
    "runtime"
    "sync"
    "sync/atomic"
    "testing"
)

type Atomic struct{ x int64 }
func (a *Atomic) Inc() { atomic.AddInt64(&a.x, 1) }

type Locked struct{
    mu sync.Mutex
    x  int64
}
func (l *Locked) Inc() { l.mu.Lock(); l.x++; l.mu.Unlock() }

func bench(b *testing.B, fn func()) {
    b.ReportAllocs()
    n := runtime.GOMAXPROCS(0)
    b.SetParallelism(n)
    b.RunParallel(func(pb *testing.PB) {
        for pb.Next() { fn() }
    })
}

func BenchmarkAtomic(b *testing.B) { a := &Atomic{}; bench(b, a.Inc) }
func BenchmarkLocked(b *testing.B) { l := &Locked{}; bench(b, l.Inc) }

傾向として、-raceを有効にすると両者とも遅延が大きくなり、計測が「遅くなる方向」にバイアスします。この傾向はGoドキュメントの「5〜10倍」の説明と整合的です¹。性能評価は-raceなしで行い、正しさの検証は-raceで行うという役割分担が基本戦略になります。

テスト設計のコツ:検出率を上げて時間を抑える

動的検出である以上、単体テストが並行部分を叩かなければ検出できません。ユニットに対しては短時間でゴルーチン数を増やすストレステストを用意し、I/Oやsleepに頼らず同期プリミティブを活用して競合の露出を高めます。結合・統合テストでは、疑似負荷を小さく刻んでケースを増やし、-race専用のスモークを厚くします。長時間テストはナイトリーに回し、PRでの待ち時間を抑えましょう³⁹。

運用とROI:チームに根付かせる設計

Racedetectorの採用は技術的判断であると同時に、投資判断でもあります。たとえば、PRあたり-raceテストに追加で数分かかり、開発者10人が1日5本のPRを出す場合、日次で数時間分のCI時間が増えるという試算ができます。クラウドCIの従量課金が月額で増加したとしても、データレース由来の本番障害を未然に防げれば、深夜対応や機会損失を含めたコスト回収は十分に期待できます。なお、データレースは「一般的で、しかもデバッグが難しい」部類の不具合であることが公式にも強調されています³。障害の平均復旧時間(MTTR)短縮と、障害発生頻度(件数)の低下は、投資対効果の両輪です。

導入初期は、-raceで落ちたテストを「必ず直す」文化づくりが肝心です。Flakyに見える失敗でも、レースは非決定的に表面化するのが常です。再実行で通ったから良しとせず、最小再現テストを作り、原因に同期を与えるか、設計そのものを見直すかを判断します。全体のスループットを維持するには、重いパッケージを対象にした-raceジョブと、全体スモークの-raceジョブを分け、前者をPRで必須、後者をスケジュール実行とする分離が効きます。ローカルでは、失敗の起点になったパッケージに絞って-raceを回し、修正の反復を速くします。これらの運用が回り始めると、レビュー時の「これは安全か」という抽象議論が減り、同期の意図がコードとテストに明示されるため、チーム全体の変更速度が上がります。

設計レベルでの再発防止

Racedetectorは最後の防波堤に過ぎません。並行設計の段階で、共有可変状態を減らし、所有権をチャネルで移譲するスタイルを優先することが、本質的な再発防止策になります。ミューテックスで「守る」より、そもそも「共有しない」設計に倒す。具体的には、ワーカーごとのシャーディング、immutableデータの採用、集計は専任ゴルーチンに集める等です。Goの並行設計思想の背景にはCSP(Communicating Sequential Processes)があり、チャネルを通じたコミュニケーションと所有権移譲の設計はその実践形です¹⁰。

まとめ:正しさの見える化を日常運用に

Racedetectorは速さを犠牲にして正しさを可視化する道具です。Goのドキュメントが示す通りオーバーヘッドは小さくありませんが、非決定的な不具合を開発段階で露出させる効果は代えがたいものがあります¹⁷。ローカルでは狙い撃ちの-raceテストで反復を速くし、CIでは変更範囲に必須化しつつ、ナイトリーで全体カバーを回す。実例で見たように、同期はミニマルに、設計では共有を減らす方針を一貫させる。こうした運用が根付くほど、障害は減り、レビューは軽く、プロダクトの変更速度は上がります。あなたのチームでは、どのパッケージから-raceを常時ONにしますか。今日の小さな開始が、来月の大きな事故を確実に減らします。

参考文献

  1. Go 公式ドキュメント「The Go Race Detector — Runtime Overhead」https://go.dev/doc/articles/race_detector.html#:~:text=Runtime%20Overhead
  2. Go 公式ドキュメント「Go Memory Model — A data race is defined …」https://go.dev/ref/mem?v=1#:~:text=A%20data%20race%20is%20defined
  3. Go 公式ドキュメント「The Go Race Detector — To start, run your tests …」https://go.dev/doc/articles/race_detector.html#:~:text=To%20start%2C%20run%20your%20tests,under%20a%20realistic%20workload
  4. Go 公式ドキュメント「Go Memory Model — Programs that modify data … must serialize …」https://go.dev/ref/mem?v=1#:~:text=Programs%20that%20modify%20data%20being,goroutines%20must%20serialize%20such%20access
  5. Go 公式ドキュメント「The Go Race Detector(サポートプラットフォーム/cgo要件など)」https://go.dev/doc/articles/race_detector.html
  6. Go 公式ドキュメント「The Go Race Detector — Race on loop counter」https://go.dev/doc/articles/race_detector.html#:~:text=Race%20on%20loop%20counter
  7. Go 公式ドキュメント「Go Memory Model — arbitrary memory corruption」https://go.dev/ref/mem?v=1#:~:text=When%20the%20values%20depend%20on,lead%20to%20arbitrary%20memory%20corruption
  8. Go 公式ドキュメント「Go Memory Model(総合)」https://go.dev/ref/mem
  9. Go 公式ドキュメント「The Go Race Detector — Data races are among the most common and hardest to debug」https://go.dev/doc/articles/race_detector.html#:~:text=Data%20races%20are%20among%20the
  10. Go 公式ブログ「Communicating Sequential Processes(Go並行設計の背景)」https://go.dev/blog/codelab-share?utm_source=pocket_shared#:~:text=Communicating%20Sequential%20Processes,read%20for%20any%20Go%20programmer