Claude Media
Claude Codeでgo test -raceのレポートを読み競合する2つのgoroutineを特定する

Claude Codeでgo test -raceのレポートを読み競合する2つのgoroutineを特定する

go test -raceのWARNING: DATA RACEをClaude Codeに読ませ、競合する2つのgoroutineを特定し、mutex・channel・atomicから修正を選んで再実行で確かめる手順です。

go test -race が WARNING: DATA RACE を出したとき、Claude Codeに渡すべきなのはレポートの全文です。要約や該当行だけを渡すと、競合する2つのgoroutineを特定する手がかりが落ちます。

Goのレース検出器のレポートは、衝突した2つのアクセスのスタックと、関わったgoroutineの作成位置を並べて出力します。この3種類の情報がそろうと、どのデータを誰が同時に触っているかを読み取れます。この記事では、レポートの読み方、Claude Codeへの渡し方、mutex・channel・atomicの選び方、-race を付けた再実行での確かめ方を順に扱います。

DATA RACEレポートは何を教えてくれるか

データ競合とは、2つのgoroutineが同じ変数に同時にアクセスし、少なくとも一方が書き込みである状態を指します。レース検出器は go test・go run・go build・go install のどれにも -race を付けて使います。

レポートの構成は次のとおりです。

ブロック見出しの例分かること
警告行見出しの例WARNING: DATA RACE分かること競合を検出したという合図
1つ目のアクセス見出しの例Read by goroutine 185:分かること読み書きの種別とgoroutine番号、呼び出しスタック
2つ目のアクセス見出しの例Previous write by goroutine 184:分かることもう一方のアクセスの種別、番号、スタック
作成位置見出しの例Goroutine 185 (running) created at:分かることそのgoroutineを起動した場所

レースの原因を探すには、1つ目と2つ目のアクセスが同じ変数を指していることを確かめます。作成位置は、2つのgoroutineが同じ親から生まれたのか、一方が他方を起動したのかを教えてくれます。

公式の例で2つのgoroutineを突き合わせる

Goの解説にあるレポートを使って、読む順番を確認します。

WARNING: DATA RACE
Read by goroutine 185:
  net.(*pollServer).AddFD()
      src/net/fd_unix.go:89 +0x398
  net.(*pollServer).WaitWrite()
      src/net/fd_unix.go:247 +0x45
  net.(*netFD).Write()
      src/net/fd_unix.go:540 +0x4d4
  net.(*conn).Write()
      src/net/net.go:129 +0x101
  net.func·060()
      src/net/timeout_test.go:603 +0xaf
 
Previous write by goroutine 184:
  net.setWriteDeadline()
      src/net/sockopt_posix.go:135 +0xdf
  net.setDeadline()
      src/net/sockopt_posix.go:144 +0x9c
  net.(*conn).SetDeadline()
      src/net/net.go:161 +0xe3
  net.func·061()
      src/net/timeout_test.go:616 +0x3ed
 
Goroutine 185 (running) created at:
  net.func·061()
      src/net/timeout_test.go:609 +0x288
 
Goroutine 184 (running) created at:
  net.TestProlongTimeout()
      src/net/timeout_test.go:618 +0x298
  testing.tRunner()
      src/testing/testing.go:301 +0xe8

読む順番は3段です。

  1. 各スタックの先頭フレームが、実際にメモリへアクセスした場所です。ここでは AddFD の読み取りと setWriteDeadline の書き込みが衝突しています
  2. 各スタックの末尾フレームが、そのgoroutineの入口です。185番は timeout_test.go:603 の関数、184番は timeout_test.go:616 の関数から動いています
  3. 作成位置を見ると、185番は184番の入口と同じ関数の609行目で起動されています。184番は TestProlongTimeout の618行目からです

つまり、184番が185番を起動した後も、184番側が SetDeadline を呼び続けて、185番の Write と同じ状態に触れている構造です。作成位置を渡さないと、この親子関係が分からなくなります。

レポートをClaude Codeに渡す

Claude Codeにバグの調査を頼むときは、再現するコマンドとスタックトレースを渡すと効率が上がります。エラーが間欠的か常に起きるかを伝えるのも有効です。これらはClaude Codeの一般的なワークフロー集に書かれた指針で、データ競合の調査にもそのまま使えます。

レポートをファイルに残す

まずレポートを標準エラーごとファイルに保存します。途中で切れないよう、全文を残すのが目的です。

go test -race ./... 2>&1 | tee race.log

レポートが複数出ているときは、1件ずつ扱います。1件目を直すと、2件目以降が連動して消えることがあるためです。

渡し方のプロンプト例

対話セッションでは、次のように書きます。

race.log の最初の DATA RACE を調べて。
1. 衝突している2つのアクセスのファイル・行・読み書きの別を表にして
2. 2つのgoroutineが「Goroutine N created at」のどこで起動されたかを説明して
3. 共有されている変数が何かを特定して。コードを直すのはその後

最初のターンでは修正を頼まないのがコツです。修正を先に頼むと、原因の特定を飛ばして、それらしいmutexを足して終わる流れになりがちです。共有変数を言葉にさせてから修正に進むと、後の判断がぶれません。

非対話で回すときは、標準入力にレポートを流せます。Claude Codeは標準入力と標準出力をほかのUnixツールと同じように扱うので、パイプで渡せます。

claude -p "この DATA RACE を読み、衝突する2つのアクセスと共有変数を答えて" < race.log

mutex・channel・atomicをどう選ぶか

共有変数が分かったら、修正の手段を選びます。選ぶ基準は、共有しているものの形です。Goの解説に載っている典型例を、判断の表にまとめます。

共有しているもの解説にある修正判断の理由
mapなどのグローバル変数解説にある修正mutexで読み書きを保護する判断の理由同じmapの同時読み書きは安全でない
int64 などの単純な値解説にある修正channel、mutex、または sync/atomic判断の理由ロックなしの動きを保ちたいならatomic
ループ変数をgoroutineが参照解説にある修正goroutineの引数にコピーを渡す判断の理由関数リテラルがループと同じ変数を指している
goroutine内で共有された err解説にある修正:= で新しい変数を導入する判断の理由外側の変数への書き込みが競合する
同じchannelへの送信とclose解説にある修正受信を挟んで送信の完了を保証する判断の理由送信とcloseが同期していない

共有変数の見分け方は型ごとに違う

レポートの先頭フレームを見れば、どの種類の競合かをおおよそ絞れます。Claude Codeに「共有変数の特定」を頼むときは、次の見分け方を添えると、調査の精度が上がります。

ループの中でgoroutineを起動している場合は、関数リテラルがループ変数をそのまま参照していないかを疑います。解説にある例では、for i = 0; i < 5; i++ の中で起動したgoroutineが fmt.Println(i) を呼び、読み取りとループの増分が競合します。プログラムは 01234 ではなく 55555 と表示するのが普通だと説明されています。直し方は、go func(j int) のように引数でコピーを渡す形です。

エラー変数が絡む場合は、err を外側と共有していないかを見ます。2つのgoroutineが同じ err に書き込んでいると、書き込み同士の競合になります。レポートでは、2つのアクセスがどちらも書き込みとして出ます。直すときは、goroutine内で := を使って新しい変数を作ります。

mapを守りたい場合は、読み取りと書き込みを別の関数が担当していても、同じmapを指していればレポートの2つのスタックが別々の関数名で出ます。このときは、読み書きの両方の関数に同じmutexを掛ける必要があります。片方だけに掛けても競合は残ります。

channelでは、送信とcloseが同期していないと競合になります。解説は、受信を1回挟んで送信の完了を保証してからcloseする形を示しています。同じchannelに対する送信とcloseが2つのスタックに現れていれば、この型です。

どの型でも、最初に確かめるのは「2つのスタックが同じ変数を指しているか」です。指していないなら、別の原因を探します。

3つの手段の使い分け

Claude Codeに選ばせるときは、次の観点で理由を書かせます。

  • mutex: 複数のフィールドを一緒に整合させたいとき、またはmapのようにatomicで扱えない型を守るとき。ロックの範囲が広がるほど待ち時間も増えます
  • channel: データの所有者を1つのgoroutineに決め、他は依頼を送るだけにしたいとき。設計が変わるので、修正の範囲が広がります
  • atomic: カウンタやタイムスタンプのような、1つの単純な値だけが競合しているとき。2つ以上の変数を同時に整合させる用途には足りません

解説にある Watchdog の例は、単純な値の競合でも見過ごせないことを示しています。last に書き込む側と、last を読んで10秒間の無通信を判定する側が競合します。解説は、こうした単純な競合でも、アクセスがアトミックでないことやコンパイラ最適化との干渉で、デバッグしにくい問題が起きるとしています。atomicへの置き換えは次のようになります。

type Watchdog struct{ last int64 }
 
func (w *Watchdog) KeepAlive() {
	atomic.StoreInt64(&w.last, time.Now().UnixNano())
}
 
func (w *Watchdog) Start() {
	go func() {
		for {
			time.Sleep(time.Second)
			if atomic.LoadInt64(&w.last) < time.Now().Add(-10*time.Second).UnixNano() {
				fmt.Println("No keepalives for 10 seconds. Dying.")
				os.Exit(1)
			}
		}
	}()
}

選ばせるときのプロンプト

共有変数の特定が済んだら、修正案を3つ並べさせます。

共有変数が `last` だと分かった。次の3案それぞれについて、
変更範囲と、他のコードに与える影響を書いて。
(a) mutexで保護 (b) channelで所有者を1つにする (c) sync/atomic
選んだ理由と、選ばなかった案を捨てた理由も添えて。

この形にすると、提案が1案に偏らず、人が判断するための材料になります。小さなカウンタならatomic、構造体全体を守るならmutexと、答えが分かれる場面で効きます。

-race付きの再実行で消えたことを確かめる

修正後は、同じコマンドで再実行します。レポートが出なくなれば、その経路では競合が検出されていないことを意味します。

go test -race ./... 2>&1 | tee race-after.log

消えただけでは足りない理由

レース検出器が見つけるのは、実行時に通った経路の競合だけです。テストのカバレッジが不完全だと、通らなかった経路の競合は見つかりません。解説は、より多くの競合を見つけたいときに、-race 付きでビルドしたバイナリを実際の負荷に近い条件で動かす方法を挙げています。

そのため、修正の確認では次の2点を分けて確かめます。

  • 元のレポートが指していた経路を、テストが実際に通っているか
  • 再実行でレポートが出なくなったか

後者だけを見て完了とするのは、前者が満たされているときに限ります。Claude Codeには「修正対象の2つのアクセスを両方とも実行するテストが走ったか」を確認させると、見落としを減らせます。

レポートを扱いやすくするGORACE

環境変数 GORACE でレース検出器の動作を調整できます。調査中に役立つ設定は次のとおりです。

オプション既定値用途
log_path既定値stderr用途レポートを log_path.pid というファイルへ書く
strip_path_prefix既定値空用途ファイルパスの接頭辞を削ってレポートを短くする
halt_on_error既定値0用途最初の競合を報告した時点でプログラムを終了する
history_size既定値1用途増やすと「failed to restore the stack」を避けられる。メモリ使用量は増える
exitcode既定値66用途競合検出時の終了コード

strip_path_prefix を指定すると、長いパスが縮んでClaude Codeに渡すテキストが減ります。

GORACE="log_path=/tmp/race/report strip_path_prefix=/my/go/sources/" go test -race ./...

調査を毎回同じ型で回す設定

権限を先に許可する

go test -race を毎回の確認プロンプトなしで回したいときは、権限ルールで許可します。ルールは * をサブコマンドの後ろに置く形で書きます。

{
  "permissions": {
    "allow": [
      "Bash(go test *)"
    ]
  }
}

go test に限定した許可なので、go run や go build は従来どおり確認が入ります。

CLAUDE.mdに調査の型を書く

レポートの扱いを CLAUDE.md に書いておくと、毎回プロンプトを書き直さずに済みます。

## データ競合の調査
- DATA RACE が出たら、スタック全文と「created at」を読んでから直す
- 修正の前に、共有変数と衝突する2つのアクセスを表で示す
- 修正後は同じ `go test -race` を再実行し、レポートが消えたことを報告する
- テストを `!race` タグで除外して消すのは修正として扱わない

最後の項目は、Claude Codeが「通す」ことを優先して近道を選ぶ場合に備えています。race ビルドタグは、競合を含むテストを意図して除外する用途に使えますが、原因を直さずに警告だけを消す手にもなります。

つまずきやすい点

そもそも -raceが動かない環境

レース検出器はcgoの有効化を必要とし、Darwin以外ではCコンパイラも必要です。対応するのは linux/amd64・linux/ppc64le・linux/arm64・linux/s390x・linux/loong64・freebsd/amd64・netbsd/amd64・darwin/amd64・darwin/arm64・windows/amd64 です。Windowsでは、Go 1.21以降、mingw-w64のバージョン8以降のランタイムを含むCコンパイラが要ります。

実行時間とメモリが増える

典型的なプログラムでは、メモリ使用量が5〜10倍、実行時間が2〜20倍になります。CIにそのまま足すとテスト時間が伸びるので、全パッケージで回すか、並行処理を含むパッケージに絞るかは、時間とのかねあいで決めます。

複数のレポートをまとめて直そうとしない

複数のレポートが出るとき、Claude Codeに「全部まとめて直して」と頼むのは避けます。原因が同じなら1つの修正で複数の警告が消えますが、原因が別々なら、それぞれ共有変数が違います。1件ずつ「共有変数の特定 → 修正案の比較 → 再実行」を回すと、どの修正がどの警告を消したか追えます。

まとめ

DATA RACEレポートは、衝突した2つのアクセスと、2つのgoroutineの作成位置が一組になって初めて意味を持ちます。Claude Codeに渡すときは全文を渡し、最初のターンでは共有変数の特定に絞ります。修正は共有しているものの形で選び、-race 付きの再実行で消えたことと、元の経路をテストが通っていることを両方確かめます。

実行時に通った経路しか見えない点は、修正後の安心感を過信しないための前提です。GoアプリでClaude Codeを使う全体の設定はClaude CodeでGoアプリを開発する手順に、リンタをhookで回す構成はClaude Codeでgolangci-lintを回すにまとめています。

参照した一次ソース

この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。

  1. go.dev/doc/articles/race_detector
  2. code.claude.com/docs/en/common-workflows
  3. code.claude.com/docs/en/permissions
この記事を共有:XはてブLinkedIn