Swift Digest
Blog | Swift.org Blog

Linux での Swift 向け Thread Sanitizer

Thread Sanitizer for Swift on Linux

このダイジェストはClaude Opus 4.7 / 4.8によって生成されたものです(License)。原文はこちら

この記事の要点

背景: データ競合とは何か

Swift はメモリ安全性を単一スレッド環境で保証します。しかしマルチスレッドのコードで複数のスレッドが同じメモリに競合してアクセスすると データ競合(data race) が生じます。Swift におけるデータ競合は予期しない挙動を引き起こし、最悪の場合はメモリを破壊して Swift のメモリ安全性を壊してしまいます。

たとえば次のコードは、効率的な並列 for ループを実現する DispatchQueue.concurrentPerform を使って、100 個の部分結果を計算し配列にまとめようとするものです。

import Dispatch

func computePartialResult(chunk: Int) -> Result {
    var result = Result()
    // 結果の計算は重い処理とする
    return result
}

var results = [Result]()

DispatchQueue.concurrentPerform(iterations: 100) { index in
    let r = computePartialResult(chunk: index)
    results.append(r)
}

print("Result count: \(results.count)")

一見すると “Result count: 100” と表示されそうですが、実際には “91” や “94” になったり、クラッシュしたりします。複数のスレッドが同期なしに results 配列を変更しているためです。この例では原因の箇所が明らかですが、実際のアプリケーションではデータ競合の症状が散発的にしか現れず、挙動を微妙に変えるため、診断が非常に難しいことがあります。Thread Sanitizer は、こうしたデータ競合を検出・診断するのに有効なツールです。

Thread Sanitizer の使い方

プログラムを Thread Sanitizer で計測するには、-sanitize=thread コンパイラフラグを使い、Debug モード でビルドします。Thread Sanitizer は問題を説明するためにデバッグ情報に依存するためです。

Swift コンパイラから使う

コマンドラインで swiftc から直接利用できます。

swiftc -g -sanitize=thread

Thread Sanitizer は、デバッグ情報付きの最適化されていないコードで最もよく機能します。最適化用のフラグを付けないか、既存の最適化レベルを打ち消すために -Onone を指定してください。

Swift Package Manager から使う

Swift Package Manager でも直接利用できます。

swift build -c debug --sanitize=thread

build の代わりに test を使えば、Thread Sanitizer を有効にしてパッケージのテストを実行できます。ただし、テストが実際にマルチスレッドのコードを動かしていないと、Thread Sanitizer はデータ競合を見つけられない点に注意してください。

レポートの読み方

先ほどの例をコンパイルして実行すると、Thread Sanitizer がデータ競合を報告します。Linux ではシンボル名がそのまま出力されないため、swift-demangle を通すとレポートが読みやすくなります。

➤ swiftc main.swift -g -sanitize=thread -o race
➤ ./race 2>&1 | swift-demangle
==================
WARNING: ThreadSanitizer: Swift access race (pid=96)
  Modifying access of Swift variable at 0x... by thread T2:
    #0 closure #1 (Swift.Int) -> () in main main.swift:41
    ...
  Previous modifying access of Swift variable at 0x... by thread T1:
    #0 closure #1 (Swift.Int) -> () in main main.swift:41
    ...
SUMMARY: ThreadSanitizer: Swift access race main.swift:41 in closure #1 ...
==================

レポートを理解する出発点は SUMMARY 行です。ここには次が示されます。

データ競合は、少なくとも 2 つのスレッドが(適切な同期なしに)同じメモリ位置へ並行アクセスし、そのうち少なくとも 1 つが書き込みである場合に起こります。Thread Sanitizer は、関与したスレッド(”Modifying access” / “Previous modifying access … by thread …“)と、衝突した 2 つのアクセスのスタックトレースを報告します。さらに、競合したスレッドがどこで生成されたか(”Thread … created by …“)も示します。この例では、いずれも concurrentPerform の呼び出しの中でメインスレッドから生成されています。

この例では 2 つのアクセスが同じソース文から生じていますが、常にそうとは限りません。両方のアクセスのトレースが分かることは、大規模なアプリで微妙な相互作用をデバッグする際に非常に役立ちます。

データ競合の修正

問題を理解したら、次は修正です。修正方法はコードの目的や状況に大きく依存します。一般的な指針として、環境や性能の制約が許す限り、低レベルの同期プリミティブよりも高レベルの抽象を優先するのがよいとされています。次の例では、シリアルキューを使って results.append の呼び出しを直列化することで、適切な同期を導入しデータ競合を取り除いています。

let serialQueue = DispatchQueue(label: "Results Queue")

DispatchQueue.concurrentPerform(iterations: 100) { index in
    let r = computePartialResult(chunk: index)
    serialQueue.sync {
        results.append(r)
    }
}

computePartialResult を含むクロージャの残りの部分は引き続き並列に実行されます。そのため、部分結果が results 配列に並ぶ順序は実行のたびに変わり得る点には注意してください。

Swift の目標の 1 つは「簡単なことは簡単に、難しいことも可能にする」ことです。効率的なマルチスレッドプログラムを書くことは、その「難しいこと」の 1 つです。Swift はデータ競合がない限りメモリ安全性を保証し、必要なときには開発者が追加の複雑さを引き受けられるようにします。Thread Sanitizer は、Swift の安全性と生産性をマルチスレッド環境にもたらすための、開発者の道具箱に加わったツールです。

関連リンク