並行性のテスト(決定性再生とモデル検査)
たまにしか落ちない並行バグを、運任せでなく原理で潰す手立てがある。インターリーブの網羅探索・決定性再生・モデル検査の三本柱で、再現と検証を機械に任せる方法を整理します。
- 1.並行バグは実行ごとに変わるスレッドのインターリーブに依存するため、通常のテストは再現も網羅もできない。鍵はスケジューリングを制御下に置くこと。
- 2.決定性再生は失敗時のスケジュールやイベント順を記録・固定し同じ実行を再現する。網羅的探索(systematic testing)はインターリーブを系統的に列挙し、部分順序簡約で爆発を抑える。
- 3.TLA+などのモデル検査は設計そのものを状態遷移系として全状態を探索し、安全性・活性を反例つきで検証する。コードのテストでは届かない設計レベルの欠陥を捉える。
なぜ並行バグはテストで捕まらないのか
逐次プログラムの実行は入力で一意に決まるので、同じ入力なら同じ結果が出ます。ところが複数スレッドが共有状態を触ると、結果は**スレッドのインターリーブ(命令の交差順序)**にも依存します。どの順でスケジューラがスレッドを走らせるかはOSやCPUの都合で毎回変わり、しかもメモリモデルとhappens-beforeが許す範囲で命令の並び替えまで起こります。
ここに2つの困難が生じます。第一に再現性の欠如。バグを踏むのは特定のインターリーブだけで、テストを再実行しても同じ順序にならず再現できない(ハイゼンバグ)。第二に網羅の困難。n個の処理を持つ2スレッドのインターリーブ数は組み合わせ的に増え、3スレッド以上では天文学的になります。普通のテストは多数あるインターリーブのうち1本を運任せで踏むだけなので、残りの危険な順序は素通りします。
スレッドA: a1 a2 可能なインターリーブの一部:
スレッドB: b1 b2 a1 a2 b1 b2 / a1 b1 a2 b2 / a1 b1 b2 a2 ...
→ 2スレッドでも C(4,2)=6 通り。普通のテストは1本だけ実行
対策は「スケジューリングを偶然に任せず、制御下に置く」ことに尽きます。具体策が決定性再生・網羅的探索・モデル検査の三本柱です。
決定性再生(deterministic replay)
決定性再生は、ある実行で起きた非決定の源を記録しておき、後でそれを固定して同じ実行を再現する技法です。再現できれば、デバッガを当てて何度でも同じ失敗を観察できます。鍵は「何が非決定を生むか」を漏れなく捕まえることです。
| 非決定の源 | 記録する内容 | 再生時の扱い |
|---|---|---|
| スレッドの切替 | コンテキストスイッチの位置 | 同じ点で切り替える |
| 共有変数の競合アクセス | 読み書きの順序(論理時刻) | 同じ順序を強制する |
| ロック獲得順 | ロックを取った順番 | 同順で付与する |
| 外部入力・乱数・時刻 | 返した値そのもの | 記録値を返す |
実装方式は大別して2つ。順序ベースは共有メモリアクセスやロック獲得の順序を論理時刻で記録し、再生時にその順序を再現します(命令レベルまで追うと重いので、同期点だけを追うことが多い)。入力ベースはシステムコールや乱数・タイマなど外界とのやり取りだけ記録し、内部計算は決定的とみなして再実行します。後者は記録が軽い一方、データ競合のある実行は内部が非決定なので忠実に再生できないことがあります。
全メモリアクセスを記録すれば完全再現に近づくが、実行は何倍も遅くなり本番では使えません。逆に同期点だけだと、ロックで保護されていない競合(データ競合)は捕捉できず再生がずれます。rr(Mozilla)のようなツールは、単一CPUに固定して並行性を擬似直列化し、システムコールと非決定命令だけ記録することで低オーバーヘッドと高い再現性を両立しています。
決定性再生はプロパティベーステストが乱数シードで反例を再現するのと同じ思想で、非決定をシードのように記録・固定するものだと捉えると見通しが良くなります。
インターリーブの網羅的探索(systematic testing)
再生がバグの再現なら、網羅的探索(systematic concurrency testing)はバグをまだ起こしていない順序を能動的に試す手法です。スケジューラ自体をツールが乗っ取り、スレッド切替の地点(同期操作や共有アクセス)でどのスレッドを次に走らせるかを選び、可能な選択を系統的に列挙します。CHESS(Microsoft)が代表で、Rust の Loom や Java の JPF(Java PathFinder)も同じ発想です。これは単一実行を観測して競合を報告するだけの動的レース検出器(Go の -race など、後述)とは別物で、こちらは複数のスケジュールそのものを能動的に切り替える点が異なります。
素朴に全インターリーブを試すと状態爆発で破綻するため、3つの実用的な枝刈りが効きます。
- 部分順序簡約(partial-order reduction, POR): 独立な操作(互いに別のメモリを触り順序が結果に影響しない操作)の入れ替えは同じ最終状態に至るため、代表1本だけ調べて残りを省く。可換な遷移を畳み込むのが核心。
- コンテキストスイッチ境界(bounded preemption): 大半の並行バグは少数(経験的に2回程度)の割り込み的切替で再現できる、という観察に基づき、強制的な横取り(preemption)回数に上限を設けて探索空間を劇的に絞る。バグを取りこぼす可能性と引き換えに現実的なコストに収める。
- 公平性スケジューリング: 活性(後述)を調べるとき、特定スレッドを永遠に飢えさせる非現実的な順序を除外する。
POR と境界化はあくまで近似です。境界を超える順序や、探索器が見ない並び替え(弱いメモリモデル由来の再順序など)は依然取りこぼせます。データ競合があると探索すべき順序が一気に増えるため、まず競合検出器(TSanなど)でデータ競合をゼロにし、その上で同期レベルのインターリーブを探索するのが定石です。
モデル検査と TLA+
決定性再生も網羅探索も「動くコード」が前提です。しかし並行性モデルの設計段階、つまりプロトコルやアルゴリズムの正しさは、コードを書く前に検証したい。そこで使うのがモデル検査で、TLA+ はその代表です。
TLA+ ではシステムを状態と遷移の集合として宣言的に書きます。状態は変数の値の組、遷移は「現在の値(無印)と次の値(プライム付き)の関係」を述べる述語です。検証したい性質は主に2種類で、これらは並行設計の正しさの両輪です。
| 性質の種類 | 意味 | 例 |
|---|---|---|
| 安全性(safety) | 悪いことが決して起きない | 二者が同時に臨界区間に入らない |
| 活性(liveness) | 良いことがいつか必ず起きる | 要求はいつか必ず処理される(飢餓なし) |
モデル検査器(TLC)は初期状態から到達可能な全状態を幅優先で展開し、各状態で不変条件(安全性)が破れないかを総当たりで確認します。違反が見つかると、初期状態から違反状態までの反例トレースを返します。これがモデル検査の最大の価値で、「ここでAがロックを取り、Bが割り込み、Aが古い値を見る」という具体的な失敗の筋道を、人手のデバッグなしに突きつけてくれます。活性は無限実行に関する性質なので、状態遷移グラフ上のサイクル探索(公平性仮定のもとで望ましい状態に到達できないループの検出)で確かめます。
TLC の探索(概念):
frontier = {初期状態}
while frontier:
s = frontier から取り出す
assert Invariant(s) # 安全性: 破れたら反例トレースを出力
for s' in 次状態(s):
if s' が未訪問: frontier に追加
# 全到達状態を訪問し終えれば安全性は証明済み
モデル検査が通っても、その通りに実装したという保証はありません。仕様と実装の乖離(仕様にない最適化、取りこぼした遷移)は別問題です。だからモデル検査は実装テストの代替ではなく上流の補完です。AWS がS3やDynamoDBの分散プロトコルをTLA+で検証し、設計段階で深い欠陥を発見したのは有名な実例です。なお状態数が膨大だと探索が終わらない(状態爆発)ため、モデルは検証したい性質に必要な抽象度まで小さく保つのが要です。
並行バグはインターリーブ依存ゆえ普通のテストでは再現も網羅もできない。決定性再生=非決定源を記録・固定して再現、網羅探索=インターリーブを系統的に列挙(POR・切替境界で爆発を抑制)、モデル検査(TLA+)=設計を状態遷移系として全状態探索し安全性・活性を反例つきで検証。検証対象が「コードの実行」か「設計の仕様」かを区別すること。
まとめ
並行バグの厄介さは、結果がスレッドのインターリーブという実行ごとに変わる非決定に依存する点にあり、普通のテストは多数の順序のうち1本を運任せで踏むだけです。決定性再生は非決定の源(切替・競合アクセス・外部入力)を記録して固定し、同じ失敗を再現可能にします。網羅的探索はスケジューラを乗っ取って危険なインターリーブを能動的に列挙し、部分順序簡約と切替回数の境界化で状態爆発を抑えます。**モデル検査(TLA+)**は設計を状態遷移系として全状態を探索し、安全性と活性を反例トレースつきで検証して、コードのテストでは届かない上流の欠陥を捉えます。これらはデッドロックの検出・回避のような個別対策の土台となる、並行性を「偶然ではなく原理で」確かめるための道具立てです。
プログラミング Article
並行性のテスト(決定性再生とモデル検査)を実務で読む
TL;DRは入口です。実際に選ぶ・使う段階では、何を解決するか、何と比較するか、導入後にどこで詰まるかまで見る必要があります。
解決すること
並行性
比較で見る軸
難易度: advanced / カテゴリ: プログラミング / タグ数: 5
導入後に効く点
決定性再生は失敗時のスケジュールやイベント順を記録・固定し同じ実行を再現する。網羅的探索(systematic testing)はインターリーブを系統的に列挙し、部分順序簡約で爆発を抑える。
先に潰すリスク
用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。
- 難易度
- advanced
- カテゴリ
- プログラミング
- タグ数
- 5
判断チェックリスト
- 自社の用途が「並行性 / テスト」に近いか確認する。
- 強みである「並行バグは実行ごとに変わるスレッドのインターリーブに依存するため、通常のテストは再現も網羅もできない。鍵はスケジューリングを制御下に置くこと。」が本当に評価軸になるか確認する。
- 注意点の「用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。」を運用で吸収できるか確認する。
- 公開値や仕様値は、対象プラン・対象機種・対象リージョンまで確認する。
- 既存システム、ID、ネットワーク、監視、バックアップとの接続方法を先に洗い出す。
- 小さく試してから、本番移行、権限設計、障害時手順、コスト監視を決める。