sending parameter and result values
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 T は T のサブタイプです。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
sending は inout とも組み合わせられます。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として返すようなパターンを安全に書けるようにすることを目指します。