Swift6への移行 Strict Concurrency入門
こんにちは、AmaziaでマンガBANG iOSアプリ開発を担当しています、山崎です。
先日、Xcode 16の正式版がリリースされました。このバージョンには、Swift 6コンパイラが搭載されており、新たに「strict concurrency checking(厳密なSwift並行処理の安全性チェック)」機能が導入されています。
データ競合やスレッドセーフの問題をコンパイル時に検出することが可能になります。
本記事では、その導入であるWWDC24の「Swift6への移行」の内容を見ていき、このセッションの要旨と補足、取り上げられる技術を紹介したいと思います。
■ Migrate your app to Swift 6
このセッションでは、サンプルアプリ「CoffeeTracker」を例に、Swift 6モードを有効にする手順が解説されます。
プロジェクトは、すでにDispatchQueueでのスレッド管理から、Actorでの管理に移行済みの状態です。async/await、Actorへの移行については、Swift concurrency: Update a sample app が参考に出来ます。
Swift6の利点
全体的なデータ分離(Data isolation)を強制します。コンパイル時にタスク、アクター間でのミュータブルステートの共有の間違いが防げます。
Data-race safety (データ競合安全)
並列処理のコードのミスをコンパイルエラーとして検出できるようになる機能を指します。
Xcode16、Swift6モード
Xcode16のデフォルトは、Swift5モードになっており、最初からSwift6モードは有効になっていません。そのため既存のプロジェクトは、そのままビルドできます。
Strict Concurrencyのチェック機能自体は、これまでのXcode 15(Swift 5)でも搭載されており、不具合修正や機能向上が行われてきました。そして、Xcode 16(Swift 6モード)では、このチェックがコンパイラで強制されるようになります。
移行の手順
Swift 6モードをいきなり有効にするのではなく、まず診断モードを活用し、Data-raceをエラーではなく警告として扱うことから始めます。
これにより、徐々にSwift 6モードを有効にしていくことが可能になります。最初に、Strict Concurrency Checkingを「Complete(完全チェック)」に設定します。こうすることで、移行プロセスを段階的に進めながらSwift 6を導入できるようになります。
完全チェックの有効化はトップダウン方式で進める
まず、アプリ本体ターゲットの診断モードを有効にします。そこでの警告を把握し、それから問題を解決したいモジュールに移って診断モードを有効にする、という手順で進めます。
それから、メインアクターの追加で単純な解決が出来そうな、UIレイヤーのモジュール、問題の少ないモジュールから始めます。
サンプルアプリのTarget構成
アプリ本体
CoffeeKitターゲット(ビジネスロジックをまとめたフレームワーク)
WatchKit Extension ターゲット(UIレイヤー)
各ターゲットごとの作業内容
プロジェクト設定で、対象のターゲットを選択します。
1. Swift Concurrencyの完全なチェックの有効化
Build Settings > Strict Concurrency Checking項目を MinimalからCompleteに変更します。そして、表示される警告をすべて解決します。
有効範囲の単位はプロジェクトのターゲットごとです。Swift6モードでは、データ競合の可能性がある箇所は、通常すべて"エラー"として検出されますが、診断モードでは"警告"として扱えます。
2. Swift 6を有効にする
警告をすべて解決したあと、そのターゲットのSwift6モードを有効にします。
ここまでを各ターゲットで繰り返します。
3. 安全でないオプトアウトの取り消しなど
安全でないオプトアウトの取り消しとは、移行期間中にnonisolated、@preconcurrencyとした部分など、一時的な対処をした箇所の見直しを指します。
競合状態の検出
完全なチェックを有効にすると、競合状態(Race Condition)の可能性のあるコードが、そのモジュールごとにチェックされ検出できます。
例えば、class Recaffeinaterに実装したDelegateプロトコルの処理は、SwiftUIビューに@Publishedしている値を更新します。ビューを更新する処理のため、class 先頭に@MainActorを追加し、クラスをメインアクターに分離しました。これで、すべてのメソッドとプロパティのアクセスがメインスレッドで行うように指示されます。
@MainActor // 主にビューを更新する処理クラスのため追加
class Recaffeinater: ObservableObject {
@Published var recaffeinate: Bool = false
var minimumCaffeine: Double = 0.0
}
extension Recaffeinater: CaffeineThresholdDelegate {
public func caffeineLevel(at level: Double) { // ここに ⚠️ 警告が発生
if level < minimumCaffeine {
// 省略
}
}
}
ただし、こうするとプロトコルの実装メソッドで次のエラーが発生します。
Recaffeinater型はメインアクターの指定がありますが、このDelegateプロトコルには、その指定がないためです。
この CaffeineThresholdDelegate プロトコルのメソッドは、どのスレッドでコールされるか現時点では何の保証もありません。メインアクターで呼び出されることが保証されないプロトコルに、準拠させることはできません。
このような問題が起きます。この警告の解決は、後述のCase3にまとめています。
このサンプルプロジェクトのケースを確認します。
Case1: グローバル変数
グローバル変数として宣言されたloggerインスタンスが、varで宣言されている。
グローバル変数は共有されたミュータブルステートとなり、プログラム内のすべてのコードが、どのスレッドで実行されているかに関係なく、この変数を読み書きできる。
これにより、データ競合(Data-race)が発生する可能性が高くなる。
プランA(採用): 変数を読み取り専用にする(varからletに変更)
値を後から更新する必要がない場合、logger変数をletで宣言し、不変にする。loggerはSendable型であり、letで宣言されることで、複数のスレッドから同時にアクセスしてもデータ競合が発生しないため。
プランB: 変数をグローバルアクターに紐付ける(@MainActorを使用)
変数を可変(var)のままにしたい場合、UIレイヤーにあるlogger変数に@MainActor属性を追加し、すべてのログ操作をメインアクター上で実行する。
メインアクター以外のアクセスが必要な場合やアクター間の設計が複雑になる可能性がありそう。パフォーマンスにも影響があるかもしれない。
プランC: nonisolated(unsafe)キーワードを使用
コンパイラが認識できない外部のメカニズム、例えばDispatchQueueで全てのアクセス保護を管理している場合、logger変数をnonisolated(unsafe)として宣言できる。
単純にコンパイルエラーを抑制できるが、コンパイラによる安全保証がないため、データ競合のリスクは残る。
Case2: メインアクター分離されたステートへのアクセス
WKApplicationのshared()インスタンス(メインアクターに分離されたステート)へのアクセスの問題
アクターに分離されたステートの呼び出しは暗黙的に非同期
メインアクターでのみ動作するように設計・定義されているSwiftUIビューやWKApplicationDelegate、他のプロトコルで同様に発生する
プランA(採用): 関数をメインアクターに配置(@MainActorを付与)
このshared()を呼び出している関数自体を、@MainActorとして宣言しメインアクター上でのみ実行されるようにする。
@MainActor
func updateUI() {
let watchExtension = WKApplication.shared()
// ・・・
}
つまり、警告が出たのは、メインアクターがスレッドアクセスを分離しているにもかかわらず、メインアクターのコンテキスト(メインスレッド上での実行が保証されていない場所)以外から呼び出していたためです。
補足
この事例では、参照している関数への@MainActorを付与する方法が最も簡潔ですが、理解を深めるために、アクターに分離されたステートの呼び出しが暗黙的に非同期である点に焦点を当てたコード例を示します。以下のような解決方法も取ることが可能です。
関数がasyncのある場合
func updateUI() async {
// 非同期コンテキスト内なので、直接await MainActor.runを使える
await MainActor.run {
let watchExtension = WKApplication.shared()
// ・・・
}
}
関数がasyncでない場合
func updateUI() {
// 同期関数内なのでTask { @MainActor in }を使ってメインアクター上の非同期タスクを起動
Task { @MainActor in
let watchExtension = WKApplication.shared()
// ・・・
}
}
非同期コンテキストにあるかどうかが、これら2つを使い分けるポイントになります。
Case3: Delegateメソッド
Delegateメソッドのコールバックや、completion handlerのクロージャが、どのスレッドやアクターで実行されるかに関する保証が不明確な場合、データ競合が発生する可能性がある。
たいていのSDKのデリゲートや完了ハンドラのコールバックは、どのスレッドで動作するかドキュメントに記載されている。
UI関連のコールバックではメインアクター(main thread)での実行が保証される場合が多いが、HealthKitなどのバックエンドの処理では任意のスレッド(background queues)で実行されることがある。この場合、開発者は適切なキューやアクターに再ディスパッチするか、スレッドセーフな方法で処理するように求められる。
ドキュメントでしか確認できないルールであることや、コードの変更によりデータ競合が発生しやすい点に問題があった。Swift Concurrencyは、この問題へ取り組みであり、こうした保証があるかないかがその場で明示されるということをローカル推論という。
s前提
Delegateからのコールバックの保証がない場合には、メソッドをnonisolatedとして宣言し、このサンプルのRecaffeinater型の定義であるメインアクターの分離から外す。
RecaffeinaterはUIレイヤーで、Delegateプロトコルを実装をしているため、メインアクターのプロパティにアクセスがある。そのためメインアクター上でのタスクを開始したい。
プランA: コールバックメソッドをnonisolatedとして宣言した上で、メインアクターでのタスクを開始 Task { @MainActor in 処理 } する。
プランB(ライブラリ側を変更できる場合に採用): Delegateプロトコル定義を変更できる場合には、そこに@MainActorを付与する。後述の準拠側の@preconcurrecyやMainActor.assumeIsolatedは削除できるようになる。
プランC: MainActor.assumeIsolated { 処理 } の使用
Delegateプロトコル定義を変更できない場合で、メインスレッドでコールバックがわかっている場合には MainActor.assumeIsolated { 処理 } が使用できる。この方法は単にコンパイルエラーを抑制のためのマーキング、アサーションになる。動作時にメインアクター以外から処理された場合はトラップが発動しアプリが停止するリスクがある。
プランD(移行中の一時的な採用): @preconcurrencyの使用
Delegateプロトコル定義を変更できない場合で、プランCよりも簡単な方法がある。メインスレッドでコールバックがわかっている場合には、プロトコルの準拠部分に @preconcurrency を記述する。トラップが発動する動作はプランCと同様。
Case4: アクター間でのデータ共有
配列・モデルオブジェクトを、複数のアクター間で渡す際にデータ競合が発生する。
self.currentDrinksはメインアクターで管理されているが、saveメソッドはバックグラウンドで実行される別のアクター(CoffeeDataStore)上で動作している。
Drinkが参照型の場合、両方のアクターが共有されるミュータブルステートにアクセスできるため、データ競合が発生するリスクがある。
プランA(採用): Drink型をSendableに準拠させる
Drink型をSendableとしてマークすることで、メインアクターとバックグラウンドアクター(CoffeeDataStore)間で安全にデータを渡すことができる。Drinkは構造体であり、全てのプロパティが値型でイミュータブルなため、Sendableに準拠することができる。Drinkに関連する他の型(例: 列挙型)もSendableに準拠させる。Drink配列に含まれる型がSendableに準拠していない場合は、コンパイルエラーが発生する可能性があるため、すべての関連型がSendableであることを保証する。
プランB: nonisolated(unsafe)を使用する。
Drink型や関連する型をnonisolated(unsafe)としてマークし、Sendableに準拠しなくてもコンパイルを通す。この方法では、開発者が手動で安全性を保証する必要がある。
Sendableに準拠できない場合に、最終手段としてnonisolated(unsafe)を使用する。ただし、データ競合のリスクが残るため、推奨されない。
Case5: CoreLocation APIでの警告、メインアクター対応
古いCoreLocation APIでの、Delegate実装の警告。
これまでの例では、デリゲートが常にメインスレッドで呼び出されるか、または任意のスレッドで呼び出されるかどちらかだった。この古いAPIであるCLLocationDelegateには、どのスレッドで呼び出されるかという静的な保証がない。
CoreLocationManagerのドキュメントを見ると、このデリゲートがどのスレッドで呼び出されるかは、CLManagerを作成したスレッドによって決定されるということがわかる。これは動的に決定されるため、Swiftコンパイラの自動的な強制ができない。
前提
最新SDKのCoreLocationには、SwiftConcurrencyの考慮のある現在地をストリーミングする非同期シーケンスがある。
アプリの配信iOSバージョンを変えられず、古いAPIを使う場合。
プランA(採用): CoreLocationDelegateをメインスレッドに配置する。
コールバックを受け取るデリゲートクラスを作成する。アプリではメインアクターの要素にアクセスするため、この型にメインアクターを宣言を追加する。
CoreLocationManagerはこの型のイニシャライザで生成されているため、メインスレッドで動作するようになる。
CoreLocationDelegateをnonisolatedとしてマークし、メインスレッドへの分離を解除することで、任意のスレッドから呼び出し可能にする。メインスレッドで動作することが分かっているため、コールバックメソッドのなメインアクターで実行する必要があるコードをMainActor.assumeIsolatedでラップを使用し、警告を抑制できる。
まとめ
以上、いかがだったでしょうか。
これらの内容を参考に、手元の個人プロジェクトで移行を進めています。
まずは Swift5モードを使用して、完全チェックを有効にし、簡単に対応できることころから徐々に警告をなくしていくのが良さそうです。
先日、参加したiOSDC2024でも、Strict Concurrencyを適用した個人プロジェクトで「頻度は少ないが不安定な不具合が解消された」という声も聞きました。
コンパイル時点でスレッド管理の不安を解消できるということは、非常に良いことだと思います。Swift6の移行を進めていきたいですね。