Swift & Objective-C

[Swift上級 #3] SendableとSwift 6の並行処理エラー移行

Swift 6モードを有効にすると、Sendableを中心に並行処理エラーが大量に現れます。本稿では、値が隔離境界を越えてよいかというこのプロトコルの意味と、struct化・不変化・actorへの昇格という移行手順を整理します。

読了 8 分
[Swift上級 #3] SendableとSwift 6の並行処理エラー移行のカバー画像

Swift 6モードを試したチームなら、その瞬間を覚えているでしょう。正常に動いていたプロジェクトに、数十、数百の並行処理エラーが一気に現れる光景です。

エラーメッセージの主役は、ほとんどの場合ひとつです。Sendable。

Concurrency連載第3回では、この最後のパズルのピースとSwift 6のstrict concurrencyを整理します。

Sendableが問うこと — この値は境界を越えても安全ですか

actor編では、隔離を「直列実行コンテキスト」と整理しました。actor内、MainActor上、そしてどちらでもない協調プールです。

しかし値は、この境界を頻繁に行き来します。actorメソッドの引数になり、戻り値として返り、Taskクロージャにキャプチャされます。

ここに危険があります。隔離が守るのはactor自身の状態ですが、境界を越えた値が可変な参照型だったらどうでしょう。

同じインスタンスを2つの隔離コンテキストが同時に保持し、actorが防いだデータ競合が持ち込まれたオブジェクト経由で復活します。国境検査は厳重なのに、持ち込み品を検査していないようなものです。

Sendableがその検査基準です。要求メソッドを持たないマーカープロトコルで、意味はひとつです。

この型の値は、隔離境界を越えて同時に使っても安全である。

では、どの型が安全なのでしょうか。直感どおりです。

値型は渡るときにコピーされるため安全です(保存プロパティがすべてSendableなら)。Int、String、そしてSendableプロパティだけで構成されたstructとenumが該当します。多くの場合、コンパイラが自動的に認めます。

actorも安全です。自分自身を守る仕組みが組み込まれているからです。

不変クラス(finalかつletプロパティのみ)も安全です。変更できなければ競合も起きません。

危険なのはひとつだけ、可変状態を持つクラスです。値型優先編で整理した「共有+可変=危険」という式こそ、Sendable判定の基準です。

関数型には@Sendableという別の注釈があります。Task { }のクロージャが典型的な@Sendableで、この注釈付きクロージャはnon-Sendable値をキャプチャできません。

クロージャ編で見たキャプチャが隔離境界を越える密輸ルートになり得るため、言語がルート自体を検査するのです。

strict concurrency — 警告をエラーに、規律を検査に

これらの規則はSwift 6以前にも存在しましたが、デフォルトでは静かでした。

Swift 6言語モードの核心は、この検査をすべて有効にしてエラーへ昇格させること、いわゆるstrict concurrencyです。complete checkingでは、non-Sendable値が隔離境界を越えるすべての箇所がコンパイルエラーになります。

エラーが大量に出るのは、コードが突然悪くなったからではありません。もともと存在した潜在的な競合が、ようやく見えるようになっただけです。

これはオプショナル導入時に「nilかもしれないすべての場所」が明らかになったのと同じです。当時nilチェックが担ったように、今は並行処理の前提が型システムへ移行する過渡期なのです。

幸い、コンパイラも予想以上に賢くなっています。Swift 6に入った地域ベースの隔離分析(Swift Evolution提案SE-0414、region-based isolation)がその例です。

non-Sendable値でも、「送った側が二度と触らないと証明できれば」移動が許可されます。

所有権が移った値は競合を作れないからです。そのため、理論上はエラーになるはずのコードの多くが実際には通ります。

sendingパラメータの注釈も同じ流れです。要するに、「無条件に禁止」から「安全性が証明されれば許可」へ、より精密になっています。

struct・actor・final letクラスは通過し、可変クラスは拒否される検査図
危険なのは、可変状態を持つクラスだけです

実践的な移行 — エラー種別ごとの対処

大量に出るエラーは、ほとんどがいくつかのパターンに収束します。種別ごとの標準的な対処を整理します。

種別1。自作モデルがnon-Sendable。 最も多く、最も健全なエラーです。第一の対処はstructへの変更です。

参照アイデンティティが不要なデータモデルをclassで宣言していたなら、値型へ移すよい機会です。

classである必要があるなら、final + letで不変化してSendableを採用します。可変である必要があるなら、その状態の所有者が必要ということなので、actorへの昇格を検討します。

種別2。グローバル変数・static var。 「static varは実質的にグローバル状態」と警告した箇所が、すべてエラーになります。

本当にグローバルが必要なら、隔離を宣言します。UI関連なら@MainActorを付け、それ以外ならactorで包むか、不変のletに変更します。

種別3。デリゲート・コールバッククラスが境界にかかる。 UIKit時代のAPIと接する場所でよく発生します。

その型が実質的にメインスレッド専用なら、@MainActor宣言が正解である場合がほとんどです。「このクラスはもともとメインでしか使っていなかった」という暗黙の事実を明示に変えます。

種別4。本当に安全なのにコンパイラが知らない。 内部でロックにより保護されたクラスや、Cライブラリのラッパーなどです。

このときの抜け道が@unchecked Sendableです。「安全性は私が保証するので検査を無効にせよ」という宣言ですが、名前のuncheckedが示すとおりunsafe系の道具です。

哲学編第1回で見た明示的な抜け道の原則どおり、保証の根拠(どのロックが何を守るか)をコメントに残し、最小範囲で使うのが規律です。

移行を楽にするためuncheckedでエラーを埋め尽くすと、検査を切ったままSwift 6のバッジだけ付けたコードになります。

戦略としては、すべてを一度に有効にする必要はありません。言語モードはモジュール単位で選べます。

依存の少ないユーティリティやモデルなど末端モジュールからSwift 6モードへ上げ、アプリターゲットを最後に上げるボトムアップが定石です。

Xcodeのupcoming featureフラグで検査レベルだけ先に上げ、警告を観察する準備段階も有効です。

方向を読む — なぜここまで苦労させるのか

移行の痛みは現実にあるため、このコストを払う理由も正確に理解しておく必要があります。

Swift 6の約束は、コンパイルできればデータ競合がないということです。再現しない間欠的クラッシュや、リリース後にだけ起きるタイミングバグという分類全体が、コンパイル時に消滅します。

これはメモリ安全性(オプショナル、ARC — Automatic Reference Counting、自動参照カウント)が歩んだ道と同じです。並行処理の安全性が「うまく書けばよいもの」から「言語が保証するもの」へ移行します。

そしてこの方向は、哲学シリーズで見た軌跡の延長です。ミスの起こりやすい規律を型へ昇格し、コストのある場所では明示的な注釈(@unchecked、sending)を要求する。そして過渡期の摩擦は段階的採用(モジュール単位の言語モード)で吸収する。

Swift Evolution編で見た手順は、基本隔離オプションのような緩衝装置を追加し続け、今も摩擦を減らしています。今経験している警告は、その転換の中間地点です。

Swift 5から正常なSwift 6まで続く移行段階の標識イラスト
struct化→不変化→actor→MainActor、uncheckedは根拠を添えて最後に

まとめ

  • Sendableは「隔離境界を越えて同時に使っても安全な型」の標識です。値型・actor・不変クラスは安全で、可変クラスだけが危険です。
  • @Sendableクロージャはキャプチャを検査し、クロージャが競合の密輸ルートになるのを防ぎます。
  • Swift 6 strict concurrencyはこの検査をエラーに昇格させます。エラーの爆発はコードが悪くなったのではなく、潜在的な競合が明らかになったということです。
  • 対処の優先順位:struct化 → 不変化 → actorへの昇格 → 隔離宣言(@MainActor) → 最後に根拠を明記した@unchecked Sendable。
  • 移行は末端モジュールからボトムアップで進め、言語モードはモジュール単位で上げます。

次回はConcurrency連載の締めくくり、構造化並行処理です。Task、async let、TaskGroupが作るタスクツリーと、キャンセル(cancellation)がそのツリーを通じて伝播する仕組みを扱います。


参考資料

あわせて読みたい