エンジニアリングガイド

クラウドMac CIでThread Sanitizerを使いSwiftのデータ競合を検出する

クラウドMac CIでThread Sanitizerを使いSwiftのデータ競合を検出する

同じXCTest一式がローカルでは連続して成功するのに、並列パイプラインへ移すと辞書への書き込み、配列の添字アクセス、状態アサーションでときどきクラッシュする場合、原因はたいてい「マシンが不安定」だからではありません。共有された可変状態へ、2つの実行経路が同時にアクセスしている可能性があります。データ競合は実際のスケジューリング順序に左右されるため、1回再実行するだけで痕跡が消えることも珍しくありません。より効果的なのは、クラウドMac上にThread Sanitizer専用のテストジョブを用意し、競合が起きる確率を高め、結果バンドルを保存したうえで、最初の競合アクセスから状態の所有権を逆にたどる方法です。

Sanitizerの検出ジョブを通常テストから分離する

Thread Sanitizerはメモリアクセスを記録し、スレッド間の関係を追跡するため、実行時間とメモリ使用量の両方が増加します。パイプライン全体に直接適用してはいけません。まずはConcurrencySanitizer SchemeまたはTest Planを作成し、キャッシュ、ダウンロードキュー、データベースラッパー、コールバックのブリッジ、並列パーサーなど、スレッドをまたいで状態を変更する可能性があるテストだけを含めます。

実行レベルは次の三段階に分けることを推奨します。

レベル テスト範囲 実行タイミング
高速チェック 純粋関数と通常のユニットテスト コミットごと
競合チェック 高リスクな並行処理テスト マージリクエストごと
拡張チェック ユニットテストと統合テスト全体 独立した定期ジョブ

Schemeはリポジトリで共有する必要があります。共有されていないと、コマンドライン環境から見つけられません。コミット前にxcodebuild -list -workspace App.xcworkspaceを実行し、名前を確認できます。また、テストターゲットでは、並列テスト実行が暗黙に持ち込むランダム性を無効にし、そのうえで各テスト内に明示的な並行処理を作ります。これにより、制御不能なテストスケジューラーではなく、読みやすいテストコードによって負荷を与えられます。

コマンドラインで再現可能な実行条件を固定する

まずクラウドMacにインストールされているシミュレーター名を確認し、例にあるデバイスを実際に利用できるものへ置き換えます。2つの実行環境が同じインデックスやビルド中間生成物を書き換えないよう、ジョブごとに独立したDerivedDataを割り当てます。

set -euo pipefail

RUN_ID="${CI_RUN_ID:-local}"
RESULT_DIR="$PWD/Artifacts/tsan"
DERIVED_DATA="$PWD/.derived-data/tsan-$RUN_ID"

mkdir -p "$RESULT_DIR"

xcodebuild test \
  -workspace App.xcworkspace \
  -scheme ConcurrencySanitizer \
  -configuration Debug \
  -destination 'platform=iOS Simulator,name=iPhone 16' \
  -derivedDataPath "$DERIVED_DATA" \
  -enableThreadSanitizer YES \
  -resultBundlePath "$RESULT_DIR/result.xcresult"

Address Sanitizerと同時に有効化してはいけません。2種類のインストルメンテーションを重ねると、リソース消費とログのノイズが増え、失敗原因も判別しにくくなります。Release構成には通常、最適化や異なるアサーション方針も含まれるため、最初の原因調査にはDebugを使用します。修正を確認した後で、プロジェクトの要件に応じてほかの構成でも検証します。

1回成功したという結果は、そのときのスケジューリングで競合が発生しなかったことを示すだけであり、共有状態が安全であることの証明にはなりません。

タスクが交錯する確率を意図的に高める

最も有用なストレステストは、App全体を闇雲にループさせるものではありません。1つの共有オブジェクトに対し、読み取り、書き込み、キャンセル、リセットを同時に実行するテストです。次のテストでは、複数のタスクにカウンターの更新を競合させます。検出経路が実際に機能しているかを確かめるのに適しています。

final class UnsafeCounter: @unchecked Sendable {
    private(set) var value = 0

    func increment() {
        value += 1
    }
}

func testConcurrentIncrement() async {
    let counter = UnsafeCounter()

    await withTaskGroup(of: Void.self) { group in
        for _ in 0..<100 {
            group.addTask {
                counter.increment()
            }
        }
    }

    XCTAssertEqual(counter.value, 100)
}

@unchecked Sendableは修正ではありません。責任を開発者へ移すだけです。実際のコードがこれに依存している場合は、使用箇所をすべてレビュー対象に含める必要があります。検出率を高めるため、高リスクなテストを20〜50回繰り返すこともできますが、パイプライン全体にはタイムアウトを設定してください。ランダムで長いスリープを加えてはいけません。短いTask.yield()のほうが、実行を制御可能に保ちながらタスクの交錯を増やすのに適しています。

状態の所有権を修正する

アプリケーションの状態では、1つのActorだけを唯一の書き込み元にする方法を優先します。

actor Counter {
    private var value = 0

    func increment() {
        value += 1
    }

    func currentValue() -> Int {
        value
    }
}

呼び出し側はawaitを介してアクセスする必要があるため、所有権の境界が型システムに組み込まれます。既存のインターフェースを同期のまま維持する必要がある場合は、明確に定めた1本の直列キューか、適用範囲の小さいロックを使用できます。ただし、一部の経路だけをロックし、別の経路から直接読み取る設計にしてはいけません。ロックが保護するのは不変条件であり、単なる1行の代入ではありません。

最初の競合アクセスからレポートを読む

Thread Sanitizerのレポートは長くなりがちです。まず後続の連鎖的なエラーは無視し、最初のReadWrite、または2つのWriteだけに注目します。それぞれのスレッド、キュー、ソースコードの行、オブジェクトの生成箇所を記録し、次の3点を確認します。

  1. 2つの経路が同じインスタンスへアクセスしているか。
  2. その状態は誰が所有する想定なのか。
  3. 同期境界が読み取り・変更・書き込みの処理全体を覆っているか。

たとえばcache[key] = valueは1行だけに見えますが、内部では検索、容量拡張、書き込みが行われる可能性があります。呼び出しの前後へ個別に目印を付けても、相互排他にはなりません。同様に、「配列が空ではないことを確認してから最初の要素を取得する」処理は、同じ隔離領域内で行う必要があります。そうしなければ、確認と読み取りの間に別のタスクが配列を空にできます。

ターミナル末尾の数十行だけを切り取るのではなく、.xcresultを保存します。まずは構造化された概要を出力できます。

xcrun xcresulttool get test-results summary \
  --path Artifacts/tsan/result.xcresult \
  --format json > Artifacts/tsan/summary.json

利用できるコマンドはローカルのXcodeバージョンによって変わるため、スクリプトでは先にxcrun xcresulttool helpを実行し、サブコマンドの有無を確認します。結果バンドル、ソースコードのコミット番号、Scheme名、シミュレーターのランタイムバージョンはまとめてアーカイブしてください。これらがそろっていないと、後から発生状況を再現するのが難しくなります。

修正の検証を安定した品質ゲートにする

修正後は、まず元のストレステストを実行し、Sanitizerが競合を報告しなくなったことを確認します。続いて、インストルメンテーションなしの通常テスト一式を実行し、隔離方法の変更によってデッドロック、実行順序の変化、タイムアウトが生じていないことを確認します。コードレビューでは、次の点もチェックします。

  • 根拠のない@unchecked Sendableが削除されているか。
  • 可変コレクションへの書き込み経路が1つだけになっているか。
  • コールバックをasyncへ変換するとき、continuationが複数回再開される可能性はないか。
  • Task.detachedが既存のActorを迂回していないか。
  • テストダブルやグローバルシングルトンがテストケース間で状態を共有していないか。
  • 失敗したジョブが完全な.xcresultをアップロードしているか。

Thread Sanitizerは実行時に実際に発生した競合を検出し、Swiftの厳格な並行性チェックはコンパイル時に隔離を制約します。両者は互いの代わりにはなりません。コンパイラー警告、並行処理のストレステスト、Sanitizerの結果を別々の段階に分ければ、失敗原因をより明確にできます。最終的な目標はレポートを消すことではなく、すべての可変状態に、説明可能かつレビュー可能な唯一の所有者を持たせることです。

よくある質問

Thread Sanitizerはコミットごとに実行すべきですか?

コミットごとには並行処理に関係する小さなテスト群を実行し、全テストは別ジョブに分けるのが現実的です。計測実行には時間とメモリの追加コストがあります。

テスト成功はデータ競合がないことを証明しますか?

証明しません。その実行で競合アクセスが発生しなかっただけです。反復回数と意図的な並行実行を増やすことで検出率を高められます。

Actorとロックはどう使い分けますか?

関連する可変状態の所有にはActorを優先します。ロックは短い同期区間で、所有権と呼び出し規則が明確な場合に限定します。

専用物理ノード

OpsVMでクラウドMacワークフローを構築

Apple Silicon構成3種類と販売中の6ノードから選択できます。実際の利用可能状況はコンソールでリアルタイムに確認できます。

構成を選んで注文する