Swift Digest
SE-0430 | Swift Evolution

sending parameter and result values

Proposal
SE-0430
Authors
Michael Gottesman, Holly Borla, John McCall
Review Manager
Becca Royal-Gordon
Status
Implemented (Swift 6.0)

01 何が問題だったのか

SE-0414 の region-based isolation により、non-Sendable な値でも「disconnected な region にある」とコンパイラが確認できれば isolation boundary を越えて transfer できるようになりました。しかし、この判定は関数の本体に対してローカルに行われるため、関数シグネチャ越しに transfer の capability を表現する手段がありませんでした。

結果として、関数の引数や戻り値として受け渡される non-Sendable な値は、SE-0414 の通常のルールでは呼び出し側と callee 側が同じ region にマージされるものとして扱われ、そのままではisolation boundaryを越えられません。

// Swift 6 モード

class NonSendable {}

@MainActor func main(ns: NonSendable) {}

func trySend(ns: NonSendable) async {
    // error: sending 'ns' can result in data races.
    // note: sending task-isolated 'ns' to main actor-isolated
    //       'main' could cause races ...
    await main(ns: ns)
}

SE-0414 はこの問題をアクターイニシャライザに限って特別扱いし、「イニシャライザの引数はアクターの region に送り込まれる」という暗黙のルールで回避していました。しかしこれはイニシャライザ専用の特例で、一般の関数・メソッドでは「この引数は呼び出し元から必ず disconnected な状態で渡してもらう」と宣言する方法がありません。

このことはConcurrencyライブラリ内の API にも影響します。たとえば CheckedContinuation.resume(returning:) は、戻り値を別のisolation domainへ引き渡す性質を持つにもかかわらず、引数に Sendable 制約を課していなかったため、次のようなコードが警告なく通ってしまう穴がありました。

@MainActor var mainActorState: NonSendable?

nonisolated func test() async {
    let ns = await withCheckedContinuation { continuation in
        Task { @MainActor in
            let ns = NonSendable()
            // メインアクターから nonisolated へ non-Sendable が渡ってしまう
            continuation.resume(returning: ns)

            // さらに同じ値をメインアクター状態に保存
            mainActorState = ns
        }
    }

    // 'ns' と 'mainActorState' は同じ non-Sendable 値
    // 並行アクセスが起こり得る
    ns.mutate()
}

かといって resume(returning:) の引数に Sendable を要求するのは強すぎる制約で、「disconnected な region にある値を渡し、渡したあと使わない」という安全なパターンまで禁止してしまいます。必要なのは、関数シグネチャで「この引数・戻り値は isolation boundary を越えられるよう、disconnected であることを保証する」と表明する仕組みでした。

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

関数の引数や戻り値に付けられる新しい修飾子 sending を導入します。sending は「この位置の値は関数境界時点で disconnected な region にある」という契約をシグネチャで表明するもので、その保証のもとで値は isolation domain を越えて transfer したり、callee 側でアクターの region にマージしたりできるようになります。

sending パラメータ

sending を付けたパラメータは、呼び出し側で「引数が disconnected な region にある」ことが求められます。条件を満たしてさえいれば、callee が何をするか(別ドメインへ transfer するのか、他の引数の region にマージするのか)は呼び出し側にとってブラックボックスで構いません。呼び出しの時点で値は caller の region から切り離され、以降 caller は触れなくなります。

@MainActor
func acceptSend(_: sending NonSendable) {}

func sendToMain() async {
    let ns = NonSendable()

    // OK: ns は生成直後で disconnected なので sending 引数として渡せる
    await acceptSend(ns)

    // error: sending 'ns' may cause a race
    // note: access here could race
    print(ns)
}

これにより、SE-0414 がアクターイニシャライザだけに特別扱いで与えていた「引数を region に送り込める」性質を、任意の関数・メソッドがシグネチャで宣言できるようになります。

sending 戻り値

戻り値に付けた sending は、callee 側に「戻り値が disconnected な region にある」ことを要求します。アクターに属する値をそのまま返すことはできず、返せるのは新しく作った値や、関数内で disconnected なまま保たれている値に限られます。

@MainActor
struct S {
    let ns: NonSendable

    func getNonSendableInvalid() -> sending NonSendable {
        // error: main actor-isolated 'self.ns' is returned as a 'sending' result.
        return ns
    }

    func getNonSendable() -> sending NonSendable {
        return NonSendable() // OK
    }
}

呼び出し側は、sending な戻り値を disconnected とみなしてよく、そのままisolation boundary越しに渡せます。

@MainActor func onMain(_: NonSendable) { /* ... */ }

nonisolated func f(s: S) async {
    let ns = s.getNonSendable() // disconnected な戻り値
    await onMain(ns)            // そのままメインアクターへ transfer できる
}

サブタイピング

sending TT のサブタイプです。sending はパラメータ位置では反変、戻り値位置では共変になります。パラメータについては「sending を要求しない関数に disconnected な値を渡すのは常に安全」、戻り値については「sending を返す関数は、呼び出し側から見て sending でない戻り値としても扱える」と考えると分かりやすい関係です。

func sendingParameterConversions(
    f1: (sending NonSendable) -> Void,
    f2: (NonSendable) -> Void
) {
    let _: (sending NonSendable) -> Void = f1 // OK
    let _: (sending NonSendable) -> Void = f2 // OK
    let _: (NonSendable) -> Void = f1          // error
}

func sendingResultConversions(
    f1: () -> sending NonSendable,
    f2: () -> NonSendable
) {
    let _: () -> sending NonSendable = f1 // OK
    let _: () -> sending NonSendable = f2 // error
    let _: () -> NonSendable = f1          // OK
}

プロトコル適合

プロトコル要件に sending を付けることもできます。サブタイピングの方向に従い、sending パラメータを持つ要件は非 sending パラメータの実装で満たしてよく、一方で sending 戻り値を持つ要件は非 sending 戻り値の実装では満たせません(非 sending 戻り値を持つ要件を sending 戻り値の実装で満たすことはできます)。

protocol P1 {
    func requirement(_: sending NonSendable)
}

struct X1: P1 { func requirement(_: sending NonSendable) {} } // OK
struct X2: P1 { func requirement(_: NonSendable) {} }         // OK

protocol P2 {
    func requirement() -> sending NonSendable
}

struct Y1: P2 {
    func requirement() -> sending NonSendable { NonSendable() } // OK
}
struct Y2: P2 {
    let ns: NonSendable
    func requirement() -> NonSendable { ns } // error
}

inout sending

sendinginout とも組み合わせられます。inout sending では、関数に入るときと関数から返るときの両方で引数が disconnected な region にあることが要求されます。関数の中では一時的にアクターの region にマージしたり、さらに別のドメインへ transfer したりしてもかまいませんが、関数を抜けるまでに disconnected な値を再代入しておく必要があります。

所有権規約

sending を付けた引数は、呼び出しのあと caller からは使えなくなります。sending はデフォルトで callee の consuming を含意し、callee の中で引数を再代入することもできます(ただし consuming のような no-implicit-copying セマンティクスは持ちません)。所有権規約を明示したい場合は、次のように consuming / borrowing と組み合わせて書けます。

func sendingConsuming(_ x: consuming sending T) { /* ... */ }

Concurrencyライブラリでの採用

この sending を活用して、Concurrencyライブラリのいくつかの API が引数や戻り値に sending を採用します。これにより、Sendable を要求するほどではないが region の保証がほしいインタフェースを、型レベルで表現できるようになります。

  • CheckedContinuation.resume(returning:)
  • UnsafeContinuation.resume(returning:)
  • Async{Throwing}Stream.Continuation.yield(_:) / yield(with:)
  • Task 生成系 API

特に UnsafeContinuation.resume(returning:) については、当初「unsafe な型だからチェック対象外でよい」という案もありましたが、「unsafe であることのスコープが広がりすぎる」「CheckedContinuation と API の形が揃わない」といった理由で、最終的に sending を要求する形に統一されました。一般的な unsafe opt-out が必要な場面には nonisolated(unsafe) を使うのが推奨されます。

言語モードごとの挙動

sending 関連の診断は、Swift 5 モードの最小限の並行性検査では抑制され、strict concurrency では警告、Swift 6 モードではエラーになります。この段階的な扱いにより、既存の CheckedContinuation のような API にも破壊を起こさずに sending を導入できます。

Future Directions

本提案のみでは表現しきれないパターンに向けて、Disconnected 型のような追加機構が将来の方向性として示されています(いずれも本提案には含まれず、speculative なアイデアで、実現を約束するものではありません)。

  • Disconnected: sending はあくまで関数境界時点での契約で、stored property・コレクション・関数呼び出しを経由すると「disconnected である」という情報は失われます。これを型で保持できる Disconnected 型を Concurrency ライブラリに追加する案です。~Copyable でコピーを抑制しつつ Sendable に適合し、構築時に disconnected であることを要求し、一度包んだら他の region とマージできないようにするもので、AsyncSequence がバッファした non-Sendable 要素を sending として返すようなパターンを安全に書けるようにすることを目指します。