この記事の要点
- Swift 5.1 から、データ競合を実行時に検出するバグ発見ツール Thread Sanitizer が Linux でも利用できるようになりました。Swift は単一スレッド環境ではメモリ安全性を保証しますが、適切な同期のないマルチスレッドのアクセスはデータ競合を引き起こし、予期しない挙動やメモリ破壊を招きます。
- Thread Sanitizer は、コンパイル時にコードを計測(instrument)しておき、実行中に実際にデータ競合が起きた箇所を診断します。
-sanitize=threadフラグと Debug ビルド(デバッグ情報あり、最適化なし)を組み合わせて使います。 swiftcから直接使うほか、Swift Package Manager のswift build/swift testでも有効化できます。テストで検出するには、テストが実際にマルチスレッドのコードを実行している必要があります。- 検出されたデータ競合は、衝突した 2 つのアクセスのスタックトレースとともに報告されます。Linux では Swift のシンボル名がそのまま出力されないため、
swift-demangleを通すと読みやすくなります。
背景: データ競合とは何か
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 行です。ここには次が示されます。
- 検出されたバグの種類(この例では “Swift access race”)
- 発生したソース位置(
main.swift:41、つまりresults.append(r)) - それを囲む関数(この例ではコンパイラが生成したクロージャ)
データ競合は、少なくとも 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 の安全性と生産性をマルチスレッド環境にもたらすための、開発者の道具箱に加わったツールです。
関連リンク
- Swift 5 リリース — Thread Sanitizer が Linux 対応した Swift 5.1 のひとつ前のメジャーリリースの公式告知
- Thread Sanitizer(Apple Developer Documentation)
- Swift のメモリ安全性(The Swift Programming Language)