エラーの内容
レースディテクターを有効にしてGoプログラムを実行すると、次のようなエラーが表示されます:
==================
WARNING: DATA RACE
Write at 0x00c0000b4010 by goroutine 7:
main.incrementCounter()
/home/user/app/main.go:15 +0x30
Previous read at 0x00c0000b4010 by goroutine 6:
main.readCounter()
/home/user/app/main.go:22 +0x2e
Goroutine 7 (running) created at:
main.main()
/home/user/app/main.go:35 +0x6e
==================
Found 1 data race(s)
exit status 66
または、go test -race ./... 実行中にレースディテクターが発動してCIパイプラインが失敗することもあります。いずれにせよ、これは深刻な問題です — 本番環境でのサイレントなデータ破損はデバッグが難しく、再現も困難です。
発生原因
データ競合は、2つのgoroutineが同じメモリ領域に同時アクセスし、少なくとも一方が書き込みであり、かつgoroutine間に同期処理がない場合に発生します。GoのランタイムレースディテクターはThreadSanitizerをベースに構築されており、実行時にメモリアクセスを計測してこれを検出します。
よくある原因:
- mutexなしで複数のgoroutineからインクリメントされるグローバルカウンター
- 並行して書き込みと読み込みが行われる
map— mapはgoroutineセーフではありません - 複数のgoroutineから同時に要素が追加されるスライス
- あるgoroutineがフィールドを更新している間に別のgoroutineが読み込む構造体フィールド
ステップ 1 — レースディテクターを有効にする
まだ -race フラグを使っていない場合は有効にしましょう。出力には、どのファイルの何行目で、どのgoroutineが競合を引き起こしたかが正確に示されます:
# レース検出を有効にしてバイナリを実行
go run -race main.go
# レース検出を有効にしてテストを実行
go test -race ./...
# レース計測済みバイナリをビルド
go build -race -o myapp .
スタックトレースをよく読んでください。どのgoroutineが書き込み、どのgoroutineが読み込んだか、そしてそれぞれがどこで生成されたかが分かります。まずそこから始めましょう。
ステップ 2 — 競合を修正する
状況に合ったアプローチを選んでください。
オプション A: sync.Mutex(最も一般的な修正方法)
mutexで共有変数を保護します。1つのgoroutineがロックを保持し、他のgoroutineは待機します。
修正前(バグあり):
var counter int
func increment() {
counter++ // DATA RACE: 複数のgoroutineがここにアクセスする
}
func main() {
for i := 0; i < 1000; i++ {
go increment()
}
}
修正後(Mutexを使用):
import "sync"
var (
counter int
mu sync.Mutex
)
func increment() {
mu.Lock()
counter++
mu.Unlock()
}
func main() {
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
increment()
}()
}
wg.Wait()
fmt.Println(counter) // 常に1000
}
オプション B: sync.RWMutex(読み込みが多いワークロード向け)
読み込みが多く書き込みが少ない場合は、RWMutex を使いましょう。複数のgoroutineが同時に読み込み可能で、書き込み時のみ全goroutineをブロックします:
var (
data map[string]string
mu sync.RWMutex
)
func read(key string) string {
mu.RLock()
defer mu.RUnlock()
return data[key]
}
func write(key, value string) {
mu.Lock()
defer mu.Unlock()
data[key] = value
}
オプション C: sync/atomic(シンプルなカウンターとフラグ向け)
整数カウンターやブールフラグにはフルのmutexは不要です。sync/atomic はより高速で、ロック競合を完全に回避できます:
import "sync/atomic"
var counter int64
func increment() {
atomic.AddInt64(&counter, 1)
}
func getCount() int64 {
return atomic.LoadInt64(&counter)
}
オプション D: チャネル(共有ではなく通信で解決)
チャネルはメモリを共有せずにgoroutine間でデータの所有権を移転します。共有状態がなければ競合も発生しません:
func worker(jobs <-chan int, results chan<- int) {
for j := range jobs {
results <- j * 2
}
}
func main() {
jobs := make(chan int, 100)
results := make(chan int, 100)
go worker(jobs, results)
jobs <- 5
close(jobs)
fmt.Println(<-results) // 10
}
オプション E: sync.Map(並行mapアクセス向け)
map で競合が発生している場合は、sync.Map に置き換えましょう。追加のロック処理は不要です:
var m sync.Map
// 書き込み
m.Store("key", "value")
// 読み込み
val, ok := m.Load("key")
if ok {
fmt.Println(val.(string))
}
ステップ 3 — 修正を確認する
レースディテクターを有効にして再実行します。競合がゼロであれば完了です:
go test -race ./...
# 期待される出力(競合なし):
ok github.com/yourorg/app 0.512s
go run -race main.go
# WARNING: DATA RACE の出力なしで実行される
さらに確信を持ちたい場合は、並行数を増やして複数回実行してみましょう:
# 並行数を最大にしてテストを実行
go test -race -count=10 -parallel=8 ./...
10回の並行実行でクリーンであれば、修正は完了です。
実践的なヒント
- CIでは常に
-raceを実行しましょう。 ディテクターはCPUオーバーヘッドが2〜20倍、メモリオーバーヘッドが5〜10倍になります — 本番環境には重すぎますが、テスト実行には十分許容範囲です。 sync.Mutexを値コピーしてはいけません。 mutexを値渡しすると動作が壊れますが、エラーは出ません。常にポインターを使用するか、ポインターで渡される構造体に埋め込んでください。- チャネルはデータ転送に、mutexは共有状態の管理に使います。 あらゆる操作にmutexを使おうとするのは設計の問題です。goroutineの構造を見直しましょう。
- goroutine内のクロージャはよくある落とし穴です。 クロージャでキャプチャされたループ変数は競合の典型的な原因です。代わりに変数を引数として渡しましょう:
// バグ: 全goroutineが同じ `i` をキャプチャする
for i := 0; i < 5; i++ {
go func() { fmt.Println(i) }()
}
// 修正済み: `i` を引数として渡す
for i := 0; i < 5; i++ {
go func(i int) { fmt.Println(i) }(i)
}

