Day 034 — race condition検出(go test -race)

2026-08-31 🟡 上級者 / Phase 3 デバッグ・コードレビュー race condition検出(go test -race)

📚 背景知識(読んでから問題へ)

Day 029〜033では Worker Pool・Fan-in/Fan-out・Pipeline・errgroup と、複数goroutineを協調させるパターンを一通り実装してきました。その過程で「共有カウンタはatomicかmutexで守る」というルールを繰り返し使ってきましたが、そのルールを破るとGoの実行結果がどうなるかを、実際に手を動かして確認したことはまだありません。

Goのメモリモデルでは、複数goroutineが同じメモリを同期なしに読み書きするとデータ競合(data race)が発生し、プログラムの動作は未定義になります。厄介なのは、データ競合があっても大抵の場合はプログラムが「たまたま正しく動いてしまう」ことです。同じマシン・同じgo runを10回実行して10回とも正常終了しても、11回目にクラッシュしたり、本番のCPUコア数・負荷状況が変わった瞬間に壊れたりします。目視のコードレビューだけでデータ競合を100%見つけるのは非現実的です。

そこでGoはgo test -race(およびgo run -race, go build -race)というRace Detectorを標準ツールチェーンに内蔵しています。これはコンパイル時にメモリアクセスへの計装(instrumentation)を挿入し、実行時に「同期されていない並行アクセス」が実際に発生した瞬間を検出する動的解析ツールです。静的解析ではなく動的解析なので、「そのテスト実行で実際に競合が起きたパス」しか検出できません——つまり再現条件を満たすテストコードを書くこと自体が技術になります。

Day 034ではこれまで書いてきた「安全なつもり」のコードに意図的な欠陥を混入させ、-raceで機械的に検出し、修正する一連の流れを体験します。

📝 問題

以下は、チームメンバーがDay 033のfetchAllLimitedを「もっとシンプルにできる」と言って書き直したコードです。レビューを依頼された想定で、このコードに潜むデータ競合をすべて指摘し、go test -raceで検出できるテストコードを書いた上で、正しく修正してください。

package main

import (
	"context"
	"fmt"
	"time"

	"golang.org/x/sync/errgroup"
)

type stats struct {
	total   int
	success int
	failed  int
	errMsgs []string
}

// fetchAllWithStats はfetchPriceを並列実行し、成功/失敗件数を集計する。
// atomic.Int32ではなく素朴なintフィールドに変更して「読みやすくした」つもりのコード。
func fetchAllWithStats(ids []int, limit int) (*stats, error) {
	g, ctx := errgroup.WithContext(context.Background())
	g.SetLimit(limit)

	s := &stats{}

	for _, id := range ids {
		id := id
		g.Go(func() error {
			s.total++ // (A)

			_, err := fetchPrice(ctx, id)
			if err != nil {
				s.failed++                                             // (B)
				s.errMsgs = append(s.errMsgs, fmt.Sprintf("id=%d: %v", id, err)) // (C)
				return nil // 集計目的なので早期打ち切りはしない
			}
			s.success++ // (D)
			return nil
		})
	}

	if err := g.Wait(); err != nil {
		return s, err
	}
	return s, nil
}

func fetchPrice(ctx context.Context, id int) (int, error) {
	select {
	case <-time.After(5 * time.Millisecond):
	case <-ctx.Done():
		return 0, ctx.Err()
	}
	if id%7 == 0 {
		return 0, fmt.Errorf("item %d: fetch failed", id)
	}
	return id * 10, nil
}

func main() {
	ids := make([]int, 50)
	for i := range ids {
		ids[i] = i + 1
	}
	s, _ := fetchAllWithStats(ids, 8)
	fmt.Printf("total=%d success=%d failed=%d errCount=%d\n", s.total, s.success, s.failed, len(s.errMsgs))
}

要件

  1. コード中の (A)〜(D) のうち、データ競合を起こしている箇所をすべて特定し、それぞれ「何と何が競合するのか」を説明する
  2. go test -race でこれらの競合を実際に検出できるテストコード(_test.go)を書く。ただのgo run -raceではなく、go testで自動検出できる形にすること(競合が発生する条件——limitやids件数——を意識してテストを設計する)
  3. stats構造体を、Day 033で学んだatomicパッケージとsync.Mutexを適材適所で使い分けて修正する(スライスへのappendはatomicでは守れないことに注意)
  4. 修正後、同じテストが-raceエラーなしで通ることを確認する手順をコメントで示す

🔍 ヒント(段階的開示)

ヒント1 — 方向性

(A)(B)(D)はいずれも「読んで、+1して、書き戻す」という単純な整数のインクリメントですが、これは1つのCPU命令ではありません。複数goroutineが同時にs.total++を実行すると、片方の加算結果が失われる可能性があります。(C)はさらに性質が異なります——スライスへのappendは、内部的に「新しい配列を確保してコピーする」処理を伴うことがあり、複数goroutineが同時に呼ぶと配列そのものが壊れます(クラッシュや不正な要素の混入すら起きえます)。

ヒント2 — アプローチ
  • total, success, failed は単純なカウンタなので atomic.Int64(またはInt32)に置き換えるのが最も軽量な修正です
  • errMsgs(スライス)はatomicでは守れません。複数フィールドへの複合的な書き込みをまとめて保護したい場合や、スライス・マップのような複合データ構造を守りたい場合はsync.Mutexを使います
  • テストで競合を確実に再現させるには、limitをある程度大きく(例: 10以上)し、idsの件数も十分に(例: 30件以上)増やして、複数goroutineが同時にフィールドへ触れる状況を作ります。go test -raceは「実際に競合が起きたパスだけ」を検出するため、並行度が低すぎると検出できないことがあります
  • テスト関数はfunc TestFetchAllWithStats_Race(t *testing.T)のように通常のtestingパッケージの形式で書き、go test -race ./...で実行できるようにします
ヒント3 — コード骨格
type stats struct {
	total   atomic.Int64
	success atomic.Int64
	failed  atomic.Int64

	mu      sync.Mutex
	errMsgs []string
}

func fetchAllWithStats(ids []int, limit int) (*stats, error) {
	g, ctx := errgroup.WithContext(context.Background())
	g.SetLimit(limit)

	s := &stats{}

	for _, id := range ids {
		id := id
		g.Go(func() error {
			s.total.Add(1)

			_, err := fetchPrice(ctx, id)
			if err != nil {
				s.failed.Add(1)
				s.mu.Lock()
				s.errMsgs = append(s.errMsgs, fmt.Sprintf("id=%d: %v", id, err))
				s.mu.Unlock()
				return nil
			}
			s.success.Add(1)
			return nil
		})
	}

	if err := g.Wait(); err != nil {
		return s, err
	}
	return s, nil
}

テストは ids を40件程度、limit を10以上にして、修正前のコードに対して実行すると -race が (A)(B)(C)(D) それぞれについて WARNING: DATA RACE を報告するはずです。

✅ 模範解答

ファイル構成: day034/go.mod + day034/main.go + day034/main_test.go

// go.mod
module example.com/day034

go 1.22

require golang.org/x/sync v0.8.0

main.go(修正後の完全版)

package main

import (
	"context"
	"fmt"
	"sync"
	"sync/atomic"
	"time"

	"golang.org/x/sync/errgroup"
)

// stats はfetchAllWithStatsの集計結果を保持する。
// 単純カウンタはatomicで、スライスはmutexで保護する。
// atomicはCPUのCAS命令1つで完結する単純な値にしか使えず、
// append によるスライスの再割り当てのような複合操作は守れないため、
// フィールドの性質ごとに保護手段を使い分けている。
type stats struct {
	total   atomic.Int64
	success atomic.Int64
	failed  atomic.Int64

	mu      sync.Mutex
	errMsgs []string
}

func (s *stats) recordError(msg string) {
	s.mu.Lock()
	defer s.mu.Unlock()
	s.errMsgs = append(s.errMsgs, msg)
}

func (s *stats) errCount() int {
	s.mu.Lock()
	defer s.mu.Unlock()
	return len(s.errMsgs)
}

// fetchAllWithStats はfetchPriceを並列実行し、成功/失敗件数を集計する。
// Day 033のSetLimitで同時実行数を制御しつつ、集計目的なのでctxのキャンセルには
// 依存させず、全件を最後まで実行する(returnは常にnil)。
func fetchAllWithStats(ids []int, limit int) (*stats, error) {
	g, ctx := errgroup.WithContext(context.Background())
	g.SetLimit(limit)

	s := &stats{}

	for _, id := range ids {
		id := id
		g.Go(func() error {
			s.total.Add(1)

			_, err := fetchPrice(ctx, id)
			if err != nil {
				s.failed.Add(1)
				s.recordError(fmt.Sprintf("id=%d: %v", id, err))
				return nil // 集計目的なので早期打ち切りはしない
			}
			s.success.Add(1)
			return nil
		})
	}

	if err := g.Wait(); err != nil {
		return s, err
	}
	return s, nil
}

func fetchPrice(ctx context.Context, id int) (int, error) {
	select {
	case <-time.After(5 * time.Millisecond):
	case <-ctx.Done():
		return 0, ctx.Err()
	}
	if id%7 == 0 {
		return 0, fmt.Errorf("item %d: fetch failed", id)
	}
	return id * 10, nil
}

func main() {
	ids := make([]int, 50)
	for i := range ids {
		ids[i] = i + 1
	}
	s, _ := fetchAllWithStats(ids, 8)
	fmt.Printf("total=%d success=%d failed=%d errCount=%d\n",
		s.total.Load(), s.success.Load(), s.failed.Load(), s.errCount())
}

main_test.go(データ競合を検出・再確認するテスト)

package main

import (
	"testing"
)

// TestFetchAllWithStats_Race は go test -race で実行することを前提にしたテスト。
// limitとids件数を十分に大きくし、複数goroutineが同時にstatsへ触れる状況を
// 意図的に作ることで、修正前のコード(素朴なint/append)なら確実に
// "WARNING: DATA RACE" が報告される条件を再現する。
//
// 実行手順:
//   go test -race -run TestFetchAllWithStats_Race -count=5 .
// (count=5で複数回実行し、たまたま競合しなかった実行に依存しないようにする)
func TestFetchAllWithStats_Race(t *testing.T) {
	ids := make([]int, 40)
	for i := range ids {
		ids[i] = i + 1
	}

	const limit = 12
	s, err := fetchAllWithStats(ids, limit)
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}

	total := s.total.Load()
	success := s.success.Load()
	failed := s.failed.Load()
	errCount := int64(s.errCount())

	if total != int64(len(ids)) {
		t.Errorf("total = %d, want %d", total, len(ids))
	}
	if success+failed != total {
		t.Errorf("success(%d) + failed(%d) != total(%d)", success, failed, total)
	}
	if failed != errCount {
		t.Errorf("failed(%d) != len(errMsgs)(%d)", failed, errCount)
	}
}
▶ 確認手順を見る
mkdir day034 && cd day034
go mod init example.com/day034
go get golang.org/x/sync@v0.8.0
# main.go, main_test.go を上記の内容で保存

# 1. 修正前(s.total++ 等の素朴なint版)に main.go を一時的に戻して実行すると:
go test -race -run TestFetchAllWithStats_Race -count=5 .
# => WARNING: DATA RACE
#    Write at 0x... by goroutine N:
#      main.fetchAllWithStats.func1()
#    Previous write at 0x... by goroutine M:
#      main.fetchAllWithStats.func1()
#    のような報告が(A)(B)(C)(D)それぞれについて出力される

# 2. atomic + mutex に修正した本ファイルの main.go で実行すると:
go test -race -run TestFetchAllWithStats_Race -count=5 .
# => ok  example.com/day034  0.XXXs   (DATA RACEの報告なし)

🪜 Step-by-Step 解説

1
競合箇所を「メモリ位置」単位で特定する
(A) s.total++、(B) s.failed++、(D) s.success++ はいずれも同じパターンで、*int型のフィールドに対する「読み出し→加算→書き戻し」という3ステップの非アトミック操作です。異なるgoroutineが同時にこの3ステップを実行すると、片方の書き込みがもう片方に上書きされ、加算結果が失われます(lost update)。(C) s.errMsgs = append(...) は、スライスの容量が足りなくなったタイミングで内部的に新しい配列を確保しコピーする可能性があり、複数goroutineが同時に呼ぶと「配列ヘッダ(ポインタ・len・cap)」自体が競合して壊れる、より深刻な競合です。
2
atomicで守れる範囲と守れない範囲を切り分ける
total, success, failedは「単一の整数値に対する加算」なのでatomic.Int64にそのまま置き換えられます。一方errMsgsは「スライスという複合データ構造への追加」であり、atomicパッケージには[]stringを安全にappendする操作は存在しません。ここでsync.Mutexが必要になります——atomicは単一のスカラー値、mutexは複数ステップの複合操作という使い分けの基準を明確にします。
3
-raceで確実に検出できるテストを設計する
-raceは動的解析なので、「そのテスト実行で実際に2つのgoroutineが同時に同じメモリへアクセスした」場合しか検出しません。idsが少なすぎたりlimitが小さすぎたりすると、たまたま競合が発生せずテストが偽陰性(誤って安全と判定)になることがあります。件数と並行度を十分に確保し、さらに-count=5のように複数回実行することで、検出漏れの確率を下げます。
4
修正後に同じテストで安全性を裏付ける
atomic/mutexで保護した後、同じテストコードを変更せずに再実行し、-raceの警告が消えることを確認します。これにより「修正前は検出され、修正後は検出されない」という対照実験が成立し、修正が正しく効いたことをコード自身で裏付けられます。

💡 設計思想・なぜこう書くのか

📌
並行性のプリミティブと検証ツールをセットで標準装備する: Goが-raceをサードパーティツールではなく標準ツールチェーンに内蔵している事実自体が、Goの設計思想を表しています。並行処理を言語の第一級市民(goroutine・channel)として扱う以上、「並行処理のバグを見つける手段」も同じレベルで標準装備すべきだという判断です。実際、Go 1.1(2013年)という比較的初期のバージョンからRace Detectorが搭載されており、「並行性を簡単に書けるようにする」ことと「並行性のバグを簡単に見つけられるようにする」ことをセットで提供する方針が一貫しています。
📌
開発時の安全性と本番の実行効率の分離: -raceは本番環境で使うものではなくCI・テストで使うものという位置づけが明確です。計装によって実行速度が数倍〜数十倍遅くなり、メモリ使用量も増えるため、-race付きバイナリを本番にデプロイすることは想定されていません。これは「開発時の安全性チェック」と「本番の実行効率」を明確に分離するGoらしい割り切りであり、CIパイプラインにgo test -race ./...を組み込むことが事実上の業界標準になっています。

🌐 他言語との比較

言語同等の概念Goとの違い
Javajcstress(Java Concurrency Stress tests)やFindBugs/SpotBugsの並行性ルールJavaには標準ツールチェーンに統合された動的race detectorが存在せず、jcstressのような専用ツールを別途導入する必要がある。静的解析ベースのツールも多く、動的解析中心のGoの-raceとはアプローチが異なる
Python (asyncio)該当ツールなし(GILにより素朴なデータ競合は起きにくい)CPythonはGlobal Interpreter Lockにより、単純なバイトコード単位の操作はある程度保護される。ただし複合操作(x += 1のような読み書き)やC拡張を跨ぐ場合は依然として競合しうるが、GoのようなRace Detectorは標準では提供されない
C/C++ThreadSanitizer (TSan)GoのRace DetectorはLLVMのThreadSanitizerと同じ技術(shadow memoryによるアクセス追跡)をベースにしている。C/C++では-fsanitize=threadでコンパイル時に有効化する必要があり、思想はGoと近いが「標準ツールチェーンにgo test -raceとして統合されている」利便性はGo特有

🏆 実務での使いどころ

  • CIパイプラインへの組み込み: go test -race ./... をCIの必須ステップにし、データ競合を含むコードがマージされることを機械的に防ぐ(多くのGoプロジェクトで標準的な運用)
  • 並行処理を含むPRのレビュー基準: 「goroutineを複数起動するコードには、必ず-race付きテストを書く」というレビューチェックリスト項目として使う
  • 本番障害の再現・切り分け: 本番で「たまに値がおかしい」「たまにクラッシュする」といった再現困難な障害が出た際、疑わしい並行処理コードに対して-race付きでテストを再実行し、原因箇所を特定する
  • リファクタリング時の安全網: Day 033のように「読みやすくしよう」とした変更がデータ競合を混入させていないか、-race付きテストで機械的に確認してからマージする

⚠️ よくある誤解・ミス

誤解・ミスなぜ起こるか正しい理解
-raceを付けずに何度も実行して「エラーが出ないから安全」と判断する素朴な整数インクリメントのデータ競合は、実行環境によっては結果が偶然正しく見えることが多いデータ競合は未定義動作であり「たまたま動く」ことと「安全である」ことは無関係。-raceという動的解析ツールで実際に検証しない限り安全性は保証できない
atomic型に変えればappendもアトミックにできると考えるtotal++とerrMsgs = append(...)を同列に見てしまうatomicパッケージが提供するのは単一のスカラー値(整数・ポインタ等)への操作のみ。スライスのappendのような「複数ステップからなる複合操作」はsync.Mutexのような排他制御が必要
-raceはコンパイルオプションを付けるだけで自動的に全ての並行バグを検出してくれると思い込む「テストしなくても安全性チェックツールが守ってくれる」という期待-raceは動的解析であり、そのテスト実行で実際に踏んだパスの競合しか検出できない。並行度・データ量が足りず競合が発生しなければ検出漏れが起きる。十分な負荷・繰り返し実行(-count)でテストを設計する責任は開発者側にある
s.mu.Lock()をfetchPrice呼び出し全体を囲むように書いてしまう「とりあえずLockで囲めば安全」という思考停止的な適用fetchPriceはネットワークI/Oを含む重い処理であり、これをmutexで囲むと並列実行の意味がなくなる(実質的に直列化される)。ロックの範囲は「共有状態への書き込み」という最小限の範囲に絞るべき

🚀 次のステップ

  • 発展: -raceが検出する競合には今回のような「単純な変数競合」以外に、「一度closeしたchannelへの再度のclose」や「nilマップへの書き込み」に近い、より発見しにくいパターンもあります。Day 010〜011で書いたchannelコードに戻り、-race付きで再実行して問題がないか確認してみましょう
  • 次回予告: メモリモデルとhappens-before関係 — Day 035では「なぜデータ競合が未定義動作になるのか」を、Goのメモリモデル仕様が定義するhappens-beforeの概念から正確に理解します

🎯 自己評価

自分の回答

気づき・メモ