Swift 6 並行処理:Actorと構造化並行処理でデータ競合を撲滅する

Programming tutorial - IT technology blog
Programming tutorial - IT technology blog

午前2時の本番環境クラッシュ

木曜日の夜のことです。チームが4時間前にマイナーリリースをデプロイしました。ロールアウト初期のダッシュボードは安定していましたが、夕方のトラフィックがピークの15,000アクティブセッションに達した瞬間、クラッシュ発生率のグラフが急上昇します。Crashlyticsには謎めいたEXC_BAD_ACCESS (SIGSEGV)レポートが次々と記録されます。あるスタックトレースはInMemoryImageCacheのディクショナリルックアップ内でのクラッシュを示し、別のトレースはSessionTracker内での予期せぬ解放を指しています。どれも一貫して再現しません。

ローカル環境では、すべてのユニットテストがパスします。シミュレータにUIテストを1時間負荷をかけ続けても、何も壊れません。これら非決定的なバグは、古典的なデータ競合(Data Race)です。同期処理を行わずに2つのスレッドが同一メモリ空間に同時にアクセスし、少なくとも一方が書き込みアクセスである場合に必ず発生します。実環境におけるメモリ逼迫やサーマルスロットリング下では、最悪のマイクロ秒のタイミングでスレッドのプリエンプションが直撃します。ポインタが破壊され、セッションが消失し、アプリが突然クラッシュします。

根本原因の分析:なぜGrand Central Dispatchは破綻したのか

10年以上にわたり、iOSおよびmacOSエンジニアは非同期処理のデフォルトツールとしてGrand Central Dispatch(GCD)を使用してきました。ネットワーク層の随所にDispatchQueue.global().asyncの呼び出しを散りばめ、ミュータブルな配列をコンカレントキューとバリアフラグで保護したり、クリティカルセクションをos_unfair_lockで囲んだりしてきました。

2009年にOS X Snow Leopardが登場した当時、GCDは画期的でした。しかし、現代のアプリケーションアーキテクチャにおいては、3つの致命的な欠陥を抱えています。

  • コンパイラによる安全性の完全な欠如: GCDは純粋なランタイムの抽象化にすぎません。キューの境界を越えてどのようなデータが渡されるかをコンパイラが検証することはできません。escapingクロージャがミュータブルな参照型をキャプチャしても、コンパイラは沈黙したままでした。スレッドセーフ性は、完全にコードレビューの警戒心だけに依存していたのです。
  • スレッドの爆発(Thread Explosion): ディスパッチキュー上でタスクがブロックされると、GCDは頻繁に新しいカーネルスレッドを割り当てます。40個のバックグラウンド操作が競合するロックやディスクI/Oに一斉に衝突すると、GCDはプールの上限である64スレッドまで立ち上げることがあります。各スレッドは少なくとも512 KBのスタック領域を消費し、コンテキストスイッチによってCPUリソースが急速に枯渇します。
  • 制約のないタスクのライフサイクル: 分離されたディスパッチブロックには自然な階層構造が存在しません。ユーザーがビューコントローラを閉じても、手動でキャンセレーショントークンを伝播させない限り、実行中のGCD処理はバックグラウンドでバッテリーを消費し続けます。

よく見かける以下のGCDパターンを考えてみてください。コードレビューは容易にパスしますが、高スループットな本番環境では頻繁にクラッシュを引き起こします。

// GCDを使用した危険な共有ステートパターン
final class UserSessionManager {
    static let shared = UserSessionManager()
    private var activeSessions: [String: Date] = [:]
    private let queue = DispatchQueue(label: "com.app.sessionQueue", attributes: .concurrent)

    func recordAccess(userId: String) {
        // バリアなしの非同期書き込みは並行ミューテーションを引き起こす
        queue.async(flags: .barrier) {
            self.activeSessions[userId] = Date()
        }
    }

    func lastAccess(for userId: String) -> Date? {
        // 他のスレッドが書き込み中に値をエスケープすると未定義動作が発生する
        var date: Date?
        queue.sync {
            date = self.activeSessions[userId]
        }
        return date
    }
}

ここにはデッドロックの危険も潜んでいます。すでにキューイングされているタスクの中から同じキューへ同期ディスパッチを行うと、アプリは即座にフリーズします。何十人ものコントリビューターを抱える開発組織において、手動による同期処理をスケールさせるのは不可能です。

Appleの並行処理における3つの世代の比較

Appleプラットフォームは、これまで3つの異なる世代を通じて非同期コードに取り組んできました。これらを比較すると、なぜSwift 6が実行時警告ではなく厳格なコンパイラ主導のオプトインモデルを採用したのかが明確になります。

1. Grand Central Dispatch & 手動ロック

キュー、ディスパッチグループ、セマフォは低レベルの実行プリミティブを提供します。しかし、エンジニアがスレッドアフィニティを頭の中で常に追跡することを強いられます。優先度の低いバックグラウンドキューがUIを処理するキューをブロックする優先順位の逆転(Priority Inversion)が日常的に発生し、120Hzスクロール時の目立つフレームドロップの原因となります。

2. Combine & リアクティブストリーム

Appleは非同期イベントパイプラインを統合するため、SwiftUIと並んでCombineを導入しました。debounceやcombineLatestといったオペレータによってデータ変換が合理化され、AnyCancellableによって後処理がシンプルになりました。それでも、Combineはデータ競合を解決しませんでした。receive(on: DispatchQueue.main)を呼び出すことで実行時にスレッドは切り替わりますが、クロージャがコンパイラの警告なしにスレッドをまたいで外部のミュータブルな状態をキャプチャ・変更できる点に変わりはありませんでした。

3. Swift 6 構造化並行処理とActor

Swift 6はデータ競合の安全性をコンパイル時の不変条件(Invariant)へと昇華させました。Swift 6モードでは、コンパイラがSendableの解析を通じてアイソレーション境界を越える処理を評価します。コードに並行なミュータブルアクセスが含まれている場合、コンパイル自体が通りません。ビルドを実行する前に、競合を修正できるのです。

解決策:Swift 6による完全なデータ競合安全性

データ競合を排除するには、3つのコアプリミティブが必要です。Sendableコントラクト、状態アイソレーションのためのActor、そして構造化されたTaskツリーです。

ステップ1:Swift 6言語モードを有効化する

ターゲットごとに段階的に完全なデータ競合安全性を有効化できます。Package.swiftで、Swift 6言語モードを宣言します。

// swift-tools-version: 6.0
import PackageDescription

let package = Package(
    name: "CoreNetworkEngine",
    platforms: [.iOS(.v17), .macOS(.v14)],
    products: [
        .library(name: "CoreNetworkEngine", targets: ["CoreNetworkEngine"])
    ],
    targets: [
        .target(
            name: "CoreNetworkEngine",
            swiftSettings: [
                .swiftLanguageMode(.v6)
            ]
        )
    ]
)

既存のXcodeプロジェクトの場合は、Build Settingsを開き、Swift Language Versionを検索してSwift 6を選択します。これにより、完全で厳格な並行性チェックが自動的に適用されます。

ステップ2:Actorで共有ステートを隔離する

actorは自動同期機能を備えた参照型です。ある瞬間において、内部のミュータブルな状態にアクセスできるタスクが1つだけであることを保証します。外部からの呼び出しはawaitで一時中断(yield)する必要があり、スレッドロックを保持することなくアクセスが直列化されます。

// Swiftの協調スレッドプールによって管理されるスレッドセーフなセッションキャッシュ
actor SessionStore {
    private var sessions: [String: Date] = [:]

    func updateSession(id: String, timestamp: Date = Date()) {
        sessions[id] = timestamp
    }

    func lastAccess(for id: String) -> Date? {
        sessions[id]
    }

    func pruneExpiredSessions(olderThan threshold: Date) {
        sessions = sessions.filter { $0.value >= threshold }
    }
}

Actorはロック、キュー、セマフォを不要にします。20個の並行タスクが同時にlastAccess(for:)を要求しても、Swiftの協調スレッドプールは20個の独立したOSスレッドを立ち上げることなくリクエストを処理します。

ステップ3:構造化並行処理を介して処理を実行する

親タスクが消失した場合、デタッチされたタスクは処理をリークさせる可能性があります。構造化並行処理(Structured Concurrency)は、並行処理をレキシカルスコープにバインドします。子タスクは親タスクの優先度、デッドライン、およびキャンセル状態を自動的に継承します。

struct UserProfile: Sendable, Decodable {
    let id: String
    let username: String
}

struct AccountStats: Sendable, Decodable {
    let totalTransactions: Int
    let loyaltyPoints: Int
}

struct DashboardData: Sendable {
    let profile: UserProfile
    let stats: AccountStats
}

final class DashboardRepository: Sendable {
    func fetchDashboard(userId: String) async throws -> DashboardData {
        // 2つの子タスクを並行してフォーク
        async let profileTask = fetchProfile(userId: userId)
        async let statsTask = fetchStats(userId: userId)

        // 両方のタスクを結合。リターン前に両方が成功する必要がある
        return try await DashboardData(profile: profileTask, stats: statsTask)
    }

    private func fetchProfile(userId: String) async throws -> UserProfile {
        let url = URL(string: "https://api.example.com/users/\(userId)")!
        let (data, _) = try await URLSession.shared.data(from: url)
        return try JSONDecoder().decode(UserProfile.self, from: data)
    }

    private func fetchStats(userId: String) async throws -> AccountStats {
        let url = URL(string: "https://api.example.com/users/\(userId)/stats")!
        let (data, _) = try await URLSession.shared.data(from: url)
        return try JSONDecoder().decode(AccountStats.self, from: data)
    }
}

仮にfetchProfileがHTTP 404で失敗したとします。親スコープはエラーをキャッチし、直ちにstatsTaskへキャンセルシグナルを送ります。使われない統計データのダウンロードのために無駄なネットワーク通信やバッテリー電力が浪費されることはありません。

ステップ4:@MainActorでUIステートを保護する

メインスレッド以外でUIKitやSwiftUIの状態を変更すると、レイアウトの破損や紫色のランタイム警告(Purple Warning)が発生します。Swift 6では、@MainActorアトリビュートを使用してメインスレッドのアイソレーションを強制します。

import SwiftUI

@MainActor
final class DashboardViewModel: ObservableObject {
    @Published private(set) var dashboard: DashboardData?
    @Published private(set) var errorMessage: String?
    @Published private(set) var isLoading = false

    private let repository = DashboardRepository()

    func loadData(for userId: String) async {
        isLoading = true
        errorMessage = nil
        defer { isLoading = false }

        do {
            self.dashboard = try await repository.fetchDashboard(userId: userId)
        } catch is CancellationError {
            // リクエスト完了前にViewが破棄されたため安全に無視
        } catch {
            self.errorMessage = error.localizedDescription
        }
    }
}

これにより、コンパイラはバックグラウンドスレッドからdashboardやisLoadingを直接設定することを禁止します。他のスレッドがメインアクターにディスパッチせずにこれらのプロパティを変更しようとすると、ビルドが拒否されます。

レガシーコードベースを安全に移行する

単一のプルリクエストでアプリケーション全体をいきなりSwift 6に切り替えてはいけません。AirbnbやLyftのような大規模アプリも、以下のチェックリストを用いて段階的に移行を行いました。

  1. モデルをSendableな値型に変換する: ミュータブルな参照型のクラスを、イミュータブルなstructモデルに置き換えます。値型は代入時にコピーされるため、並行処理の境界を越えてもリスクがありません。
  2. カスタムディスパッチキューをActorに置き換える: スレッドセーフ性のために内部シリアルキューやos_unfair_lockを使用しているクラスを特定し、actor型にリファクタリングします。
  3. コールバック(Completion Handlers)を非同期メソッドに置き換える: @escaping (Result<T, Error>) -> Voidコールバックを排除します。async/awaitにより、完了ハンドラの呼び出し漏れを防ぎ、構造化されたエラー伝播を保証できます。
  4. 早期に警告を有効化する: Swift 5.10の環境で、まずSWIFT_STRICT_CONCURRENCYをtargetedに設定し、その後completeに設定します。完全なSwift 6言語モードを有効化する前に、モジュール単位でコンパイラ警告を解消しておきましょう。
Share: