Swiftの並行処理で初心者が避けたいバグ発生の落とし穴とは?現場で役立つApple非公開の注意点

Published on: | Last updated:

Swift 6のStrict Concurrencyは、データレースをコンパイル時エラーに寄せて潰す仕組みで、Actor・Sendable・@MainActorを揃えると共有可変状態の事故率が落ちる。

  • Strict Concurrencyの警告を「放置できない形」にする
  • 共有の可変状態はActorに隔離する
  • 境界をまたぐ型はSendableで縛る
  • UI更新は@MainActorで固定する
  • 並列はasync letかTaskGroupで構造化する
図1: 全体の流れを一枚で把握する
図1: 全体の流れを一枚で把握する

結論だけ言うとデータレースは設計の負債

データレースは、複数タスクが同じ共有可変データを同期なしで触って状態が壊れる現象で、クラッシュ・破損・再現不能を量産する。

ユーザーから「たまに落ちる」って言われるやつ。あれ。心臓に悪い。

GCDとOperationQueueでやってきた時代の癖が残ってると、地味に踏む。ロック。専用キュー。取り決め。守ったつもりでも、どこか一箇所で素通りする。人間だし。

で、タイミング依存。端末性能。バックグラウンド復帰。通知。ネットワーク遅延。条件がズレると出たり出なかったり。

再現しないクラッシュは、バグじゃなくて設計の借金の請求書。

iOSの現場だと、Thread Sanitizerをオンにして「あ、出た」までが長い。出た時にはログが荒れてる。気分も荒れる。

台湾の湿気みたいに、じわっと効いてくるんだよね。台北の梅雨。プロジェクトの気分も同じ。ベタつく。

Swift 6のStrict Concurrencyが効く理由

Swift 6のStrict Concurrencyは、並行実行で危ないアクセスをコンパイル時にエラー化して、実行してからの事故対応を減らす。

これが一番デカい。実行時の警告とか、クラッシュログの考古学から距離を取れる。

Strictにすると、今まで「まあ動くし」で通ってたところが止まる。ビルドが落ちる。苛つく。けど、止まってくれるのは助かる。

用語もちゃんと押さえる。

  • Strict Concurrency(厳格な並行性チェック):安全でない共有アクセスをコンパイル段階で弾く設定
  • Data Race(データ競合):複数の実行単位が同一の可変状態を無秩序に読み書きして壊す現象
  • Thread Sanitizer(競合検出ツール):実行時に競合っぽい挙動を検出するXcodeの計測機能

Strictは万能じゃない。設計の悪さを魔法で直さない。ビルドを落として「ここ危ない」って指差すだけ。

それで十分。指差されないと直らない箇所が、現場には多すぎる。

Actorは共有可変状態の金庫

Actorは、内部の可変プロパティをactor isolationで隔離して、外部からの呼び出しをawait経由にして直列化する仕組み。

ロックを自分で持つのをやめる。キューを暗黙の契約にするのもやめる。Actorの「一回に一件」へ寄せる。

この感覚、最初だけ違和感ある。慣れると戻れない。戻ると怖い。

actor Counter {
  private var value = 0

  func increment() {
    value += 1
    print("Counter is now \(value)")
  }

  func getValue() -> Int {
    value
  }
}

let counter = Counter()

Task {
  await counter.increment()
}

Task {
  await counter.increment()
}

Task {
  let currentValue = await counter.getValue()
  print("Current value is \(currentValue)")
}

この例の肝は、valueがactorの外から勝手に触れないこと。触ろうとするとコンパイラが怒る。怒られた方がいい。

Actorに寄せる時に揉めるのは、だいたいキャッシュとマネージャとシングルトン。あと「なんとなくグローバル」なやつ。

台湾だと、MRTの改札みたいなもので、通れる場所が決まってる方が安全。勝手口が多いと荒れる。人もコードも。

図2: Actorの内外で何が起きるか
図2: Actorの内外で何が起きるか

Sendableで境界を締める

Sendableは、型が並行コンテキストやactor境界を安全に越えられることを示す制約で、Swift 6は違反をエラーとして表面化させる。

structとenumはわりと素直。中身もSendableなら通ることが多い。

classはだいたい揉める。参照型。どこからでも書き換えられる前提が残る。ここで地獄を見る人、多い。

対処パターンは限られてる。

  • 不変に寄せる。let中心。内部状態を閉じる
  • そのクラス自体をActorで包む。責務ごと隔離
  • @unchecked Sendableで逃げる。最後の手段。責任は自分持ち

@unchecked Sendableは、書くと一瞬ラク。後から効く。未来の自分に刺さる。

一回やったことある。忙しい時。ビルド通すために。で、半年後に同じ箇所が燃える。あるある。

図3: どの並行パターンを選ぶか
図3: どの並行パターンを選ぶか

現場の置き換えどころ async await TaskGroup MainActor

Swiftの構造化並行性は、async/awaitとasync let・TaskGroupで子タスクの寿命を束ねて、キャンセルと例外伝播を管理しやすくする。

コールバック地獄。あれは読めない。書いた本人すら数週間で読めない。閉包のネストが深いほど、状態の流れが目で追えない。

async/awaitに寄せると、直線になる。目が楽。レビューも楽。バグの混入率が落ちる。

並列の話。

  • async let:固定本数の並列に強い。軽い。読みやすい
  • TaskGroup:件数が動く並列に強い。収集とエラーハンドリングがやりやすい

で、UI。ここは迷わない。

@MainActor(メインスレッド拘束のグローバルActor)を付けて、UIKit/SwiftUI更新をメインに固定する。曖昧にしない。曖昧にすると、落ちる。

台湾の端末事情も思い出す。古いiPhoneがまだ現役で、スケジューリングが荒れる瞬間がある。最新機種だけ見てると見落とす。電池も熱も絡む。

「メインでやる」って決めた処理は、コードにも刻む。口約束にしない。

ツールの話も置いとく。XcodeのThread Sanitizerは、再現性が低い競合の炙り出しに使う価値がある。万能じゃない。けど、何も無しよりマシ。

Apple Developer DocumentationのConcurrencyセクションも、結局そこに戻る。言い回しは硬い。けど仕様はそこ。

迷信を3つだけ潰す

規則:この章は「よくある勘違い」3つを快問快答で切る。迷ったらここに戻る。

  • Q:Actorを使えば全部スレッドセーフ?

    A:Actorの中の可変状態は守れる。Actorの外で共有する参照やグローバル状態は別物。境界設計が残る。

  • Q:Sendableエラーは@unchecked Sendableで黙らせればOK?

    A:黙るだけ。安全性は増えない。不変化かActor隔離で構造を直す方が後で安い。

  • Q:GCDでキュー分けしてたからStrict Concurrencyは不要?

    A:キュー運用は人間の規律依存で破れる。Strictは破れた瞬間をビルドで止める。役割が違う。

図4: 結局どこを直すかの俯瞰
図4: 結局どこを直すかの俯瞰
feature_list: 事故りやすい箇所と手当ての対応表
症状 だいたいの根っこ Swift Concurrency側の手当て チェック手段
たまにクラッシュ。再現しない 共有可変状態を複数タスクが触る Actorに隔離。外部アクセスはawaitに統一 Thread Sanitizer。クラッシュログのスタック傾向
UIがたまに固まる。更新が遅れる メインスレッド外からUI更新 @MainActorで固定。UI更新関数に付与 Main Thread Checker。実機でスクロール負荷
コールバックがネストして読めない completion handler中心の設計 async/awaitへ移行。エラーはthrowで戻す レビューでネスト深度を見る。テスト容易性
並列処理が増えて破綻する Detached Task乱用。寿命が追えない async letかTaskGroupで構造化。キャンセル伝播 キャンセル時の挙動テスト。ログで子タスク追跡
Sendable違反でビルドが止まる 参照型を境界越しに渡している 不変化。Actorで包む。@uncheckedは最終手段 コンパイラエラーの発生点。型設計の棚卸し

個人的に一番嫌いなのは、データが「壊れる」系。落ちるより嫌。落ちるなら気づく。壊れると気づかない。サイレントに汚れる。

Strict ConcurrencyとActorって、結局そこを狙ってる。壊れる前に止める。壊れる場所を狭める。境界を明確にする。

ここまでやっても、全部が解決するわけじゃない。非同期I/O。外部SDK。古い設計。混ざる。

でも、地雷原が「地図付き」になる。これが違う。

最後に、当時の自分が最初にやった小さい動作を置いとく。プロジェクトを開いたら、Strict Concurrencyを有効にして、ビルドエラーの一覧をスクショして、赤い行を上から順にActor候補へマーカーで塗る。黙々と。低速で。逃げない。

Related to this topic:

Comments