並行処理のバグ — data race・race condition・false sharing¶
一言で言うと¶
- data race(データ競合): 2つ以上のスレッドが同期なしに同じメモリに触れ、少なくとも1つが書き込み——C++では未定義動作
- race condition(競合状態): 実行タイミング(順序)によって結果が変わる論理バグ。データ競合がなくても起きる
- false sharing(偽共有): 別々のデータがたまたま同じキャッシュラインに載り、ライン所有権の奪い合いで性能が激減する現象(正しさは壊れない)
data race¶
// C++20(問題例)— 2スレッドが同期なしでカウンタを増やす
int killCount = 0;
// スレッドA: ++killCount; スレッドB: ++killCount;
// ++ は 読み→加算→書き の3ステップ。交錯すると増分が消える。
// そして規格上は「値がおかしい」どころか未定義動作(何が起きてもよい)
なぜUBなのか: コンパイラとCPUはシングルスレッド前提で命令を並べ替え・キャッシュするため(最適化)、同期なしの共有アクセスには意味を定義できない。「変数への書き込みが他スレッドからいつ見えるか」は同期操作(mutex/atomic)を使って初めて保証されます(C++メモリモデル)。
// 修正1: アトミック(単一変数の単純操作向け)
std::atomic<int> killCount{0};
killCount.fetch_add(1); // 不可分な読み書き+可視性の保証
// 修正2: ミューテックス(複数の操作・データをまとめて守る)
std::mutex m;
{ std::lock_guard lock(m); killCount++; combo.Update(); } // RAIIでロック(→ RAII)
race condition(データ競合とは別物)¶
// 各操作は同期済みでも、順序のバグは残る
if (inventory.Count(potionId) > 0) { // スレッドA: 在庫1を確認
// ← ここでスレッドBが最後の1個を使った
inventory.Remove(potionId); // Aも消費 → 在庫-1(二重消費)
}
// 「確認してから使う」(check-then-act)が不可分でない、という設計の穴。
// 修正: 確認と消費を1つのロック区間に/「TryConsume」のような不可分APIに
data raceを直してもrace conditionは残る——atomicにしただけで安心しない、が重要です。
false sharing¶
// 各スレッドが「自分専用」のカウンタに書いているのに遅い
struct Counters {
int threadA; // 同じ64バイトのキャッシュラインに同居
int threadB; // → 書き込みのたびにライン所有権が2コア間を往復(性能数分の1)
};
// 修正: alignas(64) でラインを分ける
struct alignas(64) PaddedCounter { std::atomic<int> value{0}; };
正しさは壊れないためプロファイルでしか見つからない。並列ジョブの結果書き込み先の設計(スレッドごとに独立したライン/ローカル集計→最後に統合)が定石です。
ゲーム開発での現実的な指針¶
- 共有可変状態を最小化するのが最強の対策: ジョブには入力をコピー/読み取り専用で渡し、出力は分離バッファへ(Double Buffer、ジョブごとの結果配列→統合)
- データをフレームのフェーズで区切る(更新フェーズは書く人が1人/描画フェーズは全員読むだけ)——エンジンのジョブグラフの考え方
- ロックの粒度: 細かすぎ=デッドロックと複雑さ、粗すぎ=並列性の喪失。まず「共有しない」を検討してからロック
- UnityのJob System・UEのTaskGraphは、この規律(依存宣言・読み書き分離)をAPIで強制する仕組み
C#との対応¶
- C#にもdata race/race conditionは普通にある(lock, Interlocked, volatileが対応物)。ただしC#のデータ競合は「未定義動作」ではなく壊れ方がやや穏やか(tearingが起きない型のサイズ保証など)——それでもバグはバグ
- UnityのJob Systemが「同じNativeArrayへの並行書き込み」をSafety Systemで検出するのは、data race検出の実行時版
- C++の
std::thread/std::async/std::atomicはC#のThread/Task/Interlockedに概念対応
Unity開発者が誤解しやすい点¶
- 「Unityではメインスレッドだけ考えればよかった」— C++エンジン開発では並列が前提。「どのデータを誰がいつ触るか」の設計figureが必須
- 「atomicにすれば全部安全」— 単一変数の単純操作だけ。check-then-actや複数データの整合はロック/設計で守る
- 「たまに変になる」バグをprintfで追う——並行バグはタイミングで消える(ハイゼンバグ)。ThreadSanitizer (TSan) が検出ツール(GCC/Clang。ゲーム全体では重いのでテストで)
理解度チェック¶
- data raceとrace conditionの違いを、それぞれの修正手段と共に言えますか。
- data raceがC++で「未定義動作」とされる背景(コンパイラ・CPUの前提)は?
- false sharingはなぜプロファイルでしか見つからないのですか。
演習¶
samples/atomic_counter.cpp(検証済み: 2スレッドで非atomic/atomicカウンタを増やし結果を比較 — 非atomic版の結果が環境により欠損することの観察)を実行してください。次に「所持金の確認→購入」をTryBuy(不可分API)に設計し直す擬似コードを書いてください。
前: メモリバグ図鑑 | カテゴリ目次 | 次: キャッシュ局所性と AoS/SoA