Swift Digest
SE-0337 | Swift Evolution

Incremental migration to concurrency checking

Proposal
SE-0337
Authors
Doug Gregor, Becca Royal-Gordon
Review Manager
Ben Cohen
Status
Implemented (Swift 5.6)

01 何が問題だったのか

Swift 5.5 で Sendable プロトコル(SE-0302)やグローバルアクター(SE-0316)が入り、データ競合をコンパイル時に防ぐ仕組みは一通り揃いました。しかし、実際にこの仕組みをそのまま全面適用してしまうと、既存のライブラリや C / Objective-C の膨大な資産との相互運用が成り立たなくなります。そのため、Swift 5.5 時点では Sendable チェックも @MainActor の強制もフル稼働しておらず、ゆるい状態にとどめられていました。

ここから Swift 6 に向けて段階的にデータ競合安全性へ移行するには、少なくとも次の2つの局面で問題が生じます。

ライブラリが後から concurrency 注釈を付けるとき

UIKit のメソッドのように「ずっとメインスレッドから呼ぶべき」だったけれどコンパイラに伝える手段がなかった API は山ほどあります。これらに @MainActor@Sendable を後付けしたいのですが、素朴に付けるとそのライブラリを使っていた既存コードは一斉にコンパイルエラーになります。たとえば、

@MainActor func doSomethingThenFollowUp(_ body: @Sendable () -> Void) {
  Task.detached { body() }
}

class MyButton {
  var clickedCount = 0
  func onClicked() { // システムから常にメインスレッドで呼ばれる
    doSomethingThenFollowUp { // error: @MainActor 関数をメインアクター外から呼んでいる
      clickedCount += 1 // error: non-Sendable な self を @Sendable クロージャがキャプチャ
    }
  }
}

のようになり、呼び出し側が actor isolation を証明できるように書き直されるまでビルドが通らなくなってしまいます。さらに @Sendable やジェネリックパラメータへの Sendable 制約は関数名のマングリングに埋め込まれるため、機械的に後付けすると ABI まで壊れます。

依存先が未対応のまま自分のモジュールで Sendable チェックを入れたいとき

すべてのライブラリが concurrency 対応を終えるのを待ってから自分のコードを更新する、というのは現実的ではありません。依存先のモジュールがまだ Sendable 適合を宣言していない段階でも、自分のモジュールだけ先に Sendable チェックを始めたい場面があります。ところが現状ではエラーを黙らせる手段が乏しく、型ごとに個別の回避策を書くのは非常に煩雑です。

また、後で依存先が Sendable 関連の注釈を入れ、そこで自分のコードの誤りが発覚したときの振る舞いも問題になります。黙ってエラーを出し続けるとビルドが止まり、依存先のアップデートをこちらの都合で遅らせるようお願いすることになりかねません。かといってエラーを完全に握りつぶせば、実在するデータ競合バグが見えなくなります。依存先が正式な Sendable 情報を公開した瞬間に、こちらのバグは「警告として可視化はするが、ビルドは通す」くらいの中間の扱いが欲しいところです。

本Proposalは、これらを解決するために @preconcurrency 属性(宣言用/import 用)と、scope 単位の concurrency checking モードを導入し、Swift 6 に向けた段階的な移行の道筋を言語機能として整備するものです。

02 どのように解決されるのか

段階的な移行を支えるため、次の3つの仕組みを組み合わせて導入します。

  • scope 単位の concurrency checking mode(strict / minimal)
  • 宣言に付ける @preconcurrency 属性
  • import に付ける @preconcurrency import

想定する移行のワークフローはおおよそ次の通りです。async/await やアクターなどの concurrency 機能を使い始める、Swift 6 モードに切り替える、あるいは -warn-concurrency フラグを付けることで、まず strict なチェックを有効にします。そこで依存先の型について診断が出たら、fix-it の案内に従って @preconcurrency import を付けて一旦黙らせます。後日、依存先が Sendable 適合などの注釈を入れて初めてバグが明らかになった場合は、警告として提示されるのでそれを直します。すべて解消すると「@preconcurrency import はもう不要」という警告が出るので、外します。Swift 6 モードではそれ以降、Sendable 違反はエラーとしてビルドを止めます。

concurrency checking mode

Swift のすべての scope は strictminimal のどちらかの concurrency checking mode に属します。strict では Sendable 適合やグローバルアクター注釈の欠落が診断され、Swift 6 では基本的にエラー、Swift 5 系や @preconcurrency import 経由で見えている型については警告になります。minimal では同じ違反も警告どまりで、@preconcurrency の付いた宣言に対しては多くの診断が抑制されます。

トップレベルの scope は、Swift 6 モード以降、-warn-concurrency フラグが付いているとき、またはパース対象がモジュールインターフェイスのときに strict になり、それ以外は minimal です。親が minimal でも、子の scope が次のいずれかに該当すれば strict に切り替わります。

  • 明示的なグローバルアクター属性を持つクロージャ
  • 型が async または @Sendable なクロージャ/autoclosure
  • 明示的な nonisolated やグローバルアクター属性が付いた宣言
  • async または @Sendable が付いた関数・メソッド・イニシャライザ・アクセサ・変数・subscript
  • actor 宣言

インポートされた C の宣言はすべて minimal に属します。

宣言に付ける @preconcurrency

@preconcurrencyenum、enum case、structclassactorprotocolvarletsubscriptinitfunc に付けられます。これは「この宣言は concurrency 対応より前から存在していて、@Sendable やグローバルアクター、Sendable 制約の追加で source / ABI break を起こしうるので、コンパイラ側で吸収してほしい」という意思表示です。付けると次のように扱われます。

  • マングル名は指定された concurrency 機能を含まない形で生成される(ABI が変わらない)。
  • 呼び出し側の scope が minimal なら、これらの機能に関する不一致の診断は抑制される。
  • ABI チェッカーのダイジェストからも当該機能が取り除かれる。

先ほどの doSomethingThenFollowUp@preconcurrency を付けると、minimal な scope からは @MainActor@Sendable も見えない旧来の型として振る舞い、strict な scope でだけ本来の型が見えます。

func minimal() {
  let fn = doSomethingThenFollowUp // 型は (() -> Void) -> Void
}

func strict() async {
  let fn = doSomethingThenFollowUp // 型は @MainActor (@Sendable () -> Void) -> Void
}

Objective-C の宣言はすべて暗黙的に @preconcurrency が付いたものとして取り込まれます。

Sendable 適合の3つの状態

診断の強さを決めるため、型は次のいずれかの状態に分類されます。

  • explicitly SendableSendable 適合が(SE-0302 の推論ルールも含めて)宣言されている。
  • explicitly non-SendableSendable 適合が宣言されているが unavailable だったり制約を満たしていない、あるいは型自体が strict な scope で宣言されている(=Swift 6 モード/-warn-concurrency ビルドのモジュール内の型はすべてこちら)。
  • implicitly non-SendableSendable 適合がまったく宣言されていない。

Sendable 適合を明示的に打ち消したいときは、unavailable な適合を書きます。これは暗黙の Sendable 適合を抑止するのに使えます。

@available(*, unavailable)
extension Point: Sendable {}

Sendable を継承するプロトコルと @preconcurrency

ErrorCodingKey のように「実質的にどの実装も Sendable であってほしい」プロトコルがあります。これらに Sendable を継承させると、既存の適合型が一斉に Sendable 要件違反になります。そこで、@preconcurrency を付けた上で Sendable を継承するプロトコルについては、経由して来る Sendable 要件の違反を Swift 6 未満では警告に格下げするルールを一般化して提供します。標準ライブラリの ErrorCodingKey はこの新ルールで書き直されます。

@preconcurrency protocol Error: Sendable { ... }
@preconcurrency protocol CodingKey: Sendable { ... }

import に付ける @preconcurrency

@preconcurrency import SomeModule と書くと、そのモジュール由来の型に関する concurrency 違反の厳しさが一段下がります。依存先がまだ concurrency 対応を終えていない間の緊急避難として使う想定です。具体的には、

  • implicitly non-Sendable な型が Sendable を要求される位置で使われたとき、@preconcurrency import 経由で見えていれば Swift 6 未満では診断が抑制、Swift 6 以降では警告に格下げされます。経由していなければ通常通り診断され、さらに「@preconcurrency import を使うと黙らせられる」という案内が出ます。
  • explicitly non-Sendable な型が Sendable を要求される位置で使われたとき、@preconcurrency import 経由なら Swift 6 でも警告どまりになります。経由していなければ通常通り診断されます。
  • 付けた @preconcurrency が実際には不要だった場合、「外してよい」という警告が出ます。

これにより、依存先が Sendable を表明していない段階ではビルドを止めずに済み、依存先が注釈を追加して自分のコードの誤りが発覚した後は、ビルドを止めずに警告として気付ける、という挙動が得られます。

なお、@preconcurrency は nominal 宣言側ではモジュールインターフェイスに出力される必要があります(API の resilience を保ったまま concurrency 注釈を追加するための機能であるため)。一方、import 側の @preconcurrency はモジュールインターフェイスに出力する必要はありません。モジュールインターフェイスは strict でパースされ、そこでは該当する違反が警告になるため、インターフェイス側で吸収できるからです。