iOS/macOS アプリ開発において、Swift Concurrency は今や欠かせない技術です。本記事では、基本構文から実務で陥りやすい落とし穴まで、要点をまとめて紹介します。
基本構文 ― async / await
非同期関数は async を宣言し、呼び出し側は await で中断点を明示します。例えば「ユーザー情報を取得する関数」を async throws で宣言しておくと、呼び出し側は try await をつけて呼ぶだけで、非同期処理の完了を待って結果を受け取れます。
新しい非同期タスクを起動するには Task を使います。Task { ... } でブロックを渡すとバックグラウンドでタスクが開始され、.cancel() でキャンセルも明示的に行えます。タスク内では Task.checkCancellation() でキャンセルされていないかを確認できます。
並列処理 ― async let と TaskGroup
async let(コンパイル時に個数が確定している場合)は、独立した処理を並列で起動し、後でまとめて await します。例えば「ユーザー情報」と「設定情報」を async let でそれぞれ起動し、両方をまとめて await すると、順番に await するより大幅に速くなります。
TaskGroup(動的な個数で並列化する場合)は、ループや動的な個数のタスクに使います。withThrowingTaskGroup でグループを作り、ループの中で group.addTask { ... } を呼んでタスクを追加し、for try await で結果を集めます。エラーが発生しない場合は withTaskGroup(non-throwing)版を使います。
使い分けの指針としては、タスク数がコンパイル時に確定しているなら async let(記述量が少なく、2〜3個の並列fetchが典型例)、実行時に動的に決まるならTaskGroup(記述量はやや多いが、ループでN件処理する場合に向く)、という使い分けになります。
Actor ― データ競合を防ぐ
actor は内部状態へのアクセスを直列化し、データ競合を防ぎます。外部からのアクセスには await が必須です。例えば「カウンター」をactorとして定義すると、外部から increment() を呼ぶ際は await counter.increment() のように書く必要があります。
@MainActor はUI更新に使います。UI更新はメインスレッドで行う必要があります。@MainActor を使うと、型全体またはメソッド単位でメインスレッドへの実行を保証できます。ViewModelクラス全体に @MainActor を付与すれば、@Published プロパティの変更は自動的にメインスレッドで行われます。非同期コンテキストから呼ぶ場合は await MainActor.run { ... } という書き方もできます。
Sendable は、Actor をまたいで安全に渡せる型が準拠する必要があるプロトコルです。値型(struct)やimmutableな型は自動で準拠します。クロージャに @Sendable を付けることで、並行して安全に実行できることを示せます。Swift 6 では違反がコンパイルエラーになります。
AsyncSequence / AsyncStream
AsyncSequenceでは for await を使って非同期シーケンスを逐次処理できます。テキストの行を1行ずつ処理したり、URLSessionのレスポンスをバイト列として逐次受け取ったりする際に使います。
AsyncStreamは、コールバックベースの既存APIをAsyncSequenceに橋渡しする定番パターンです。例えばタイマーのコールバックを AsyncStream でラップすることで、for await value in stream という形で値を受け取れるようになります。
@MainActor を ViewModel に付与する理由
Swift Concurrency を使う上で、実務で最も重要なポイントの一つです。
問題の背景として、ObservableObjectの@Publishedプロパティを変更すると、内部的にobjectWillChange.send()が呼ばれます。SwiftUIはこれを受け取って画面を再描画しますが、UIの更新はメインスレッドでなければなりません。Swift Concurrencyではawaitの再開後がどのスレッドで動くか、明示しないと保証されません。これが原因でランタイム警告やクラッシュが発生することがあります。
ViewModelクラスに@MainActorを付けずにTask内で@Publishedプロパティを更新すると、どのスレッドで代入が行われるか保証されず危険です。
@MainActor を付与すると何が変わるかというと、@MainActorをクラスに付与すると、そのクラスの全プロパティ・全メソッドへのアクセスがメインスレッド上に固定されます。awaitの再開後も自動的にメインスレッドに戻るため、手動でのDispatchQueue.main.asyncが不要になります。
重い処理はどう書くかについては、クラス全体が@MainActorに固定されると、重い計算もメインスレッドで動いてしまうのでは?という疑問が生じます。解決策はTask.detachedまたはnonisolatedです。重い計算はTask.detached(priority: .userInitiated)でバックグラウンドに逃がし、結果だけをメインスレッドで受け取って代入します。スレッドに依存しない純粋な計算にはnonisolatedを付けて、メソッド単位でMainActorの制約を外すこともできます。
Swift 6(厳格な並行性チェック)では、@MainActorなしで@Publishedを変更するコードはコンパイルエラーになります。今のうちに習慣化しておくと、Swift 6移行コストが大幅に下がります。
まとめると、@MainActorなしでは@Publishedの安全性を手動管理する必要があり、DispatchQueue.main.asyncを各所に書く必要があり、Swift 6対応で修正が多発します。@MainActorありでは安全性は自動で保証され、DispatchQueue.main.asyncは不要になり、Swift 6対応もほぼそのまま通ります。重い処理はTask.detachedやnonisolatedで逃がします。
ViewModelは「UIに状態を渡す層」なので、@MainActorで固定するのが責務と一致しており、自然な設計になります。
よくある落とし穴
Task リークについて、Task { } はスコープ外でも生き続けます。ViewModelのdeinitで.cancel()を呼ぶか、SwiftUIの.task { }モディファイア(Viewのライフサイクルで自動キャンセル)を活用しましょう。
逐次 await による遅延にも注意が必要です。独立した処理を順番にawaitすると直列実行になります(合計時間 = A + B)。async letを使って並列実行にすると、合計時間はmax(A, B)に近づき高速化します。
Actor reentrancyにも気をつけましょう。Actor内でもawaitをまたぐと、その間に他のタスクが状態を変更する可能性があります。例えばキャッシュのload()処理で、awaitをまたいだ後にデータがnilのままかどうかを再確認するなど、awaitの前後で状態を再確認するようにしましょう。
Swift 6 移行では、Sendableの警告がエラーに昇格します。nonisolatedや@unchecked Sendableは一時的な逃げ道ですが、根本的な対応を目指しましょう。
コンテキスト早見表
・UIKit / View更新 → @MainActor または await MainActor.run { }
・ViewModel → @MainActor class VM: ObservableObject
・重い処理 → Task.detached または nonisolated func
・iOS 14以下のサポート → withCheckedContinuation でコールバックをラップ
・Combineとの共存 → publisher.values で AsyncSequence に変換
・SwiftUIのタスク管理 → .task { } モディファイアで自動キャンセル
おわりに
Swift Concurrencyは「安全に非同期処理を書く」ための仕組みが言語レベルで組み込まれています。特に@MainActorをViewModelに付与するパターンは、Swift 6への移行を見据えても今すぐ取り入れる価値があります。
段階的に導入するなら、まずasync/awaitと@MainActor ViewModelから始め、慣れてきたらTaskGroupやAsyncStreamへと広げていくのがおすすめです。