Sendable and @Sendable closures
01 何が問題だったのか
Swift の並行性モデルでは、アクターや構造化された並行性のタスクは「シングルスレッドの島」であり、それぞれが独自の可変状態を抱えています。パフォーマンスと安全性の両方を実現するには、これらの島の間で値をやり取りするときに、共有された可変状態がデータ競合を引き起こさないことをコンパイラが保証する必要があります。
問題は、「どの型ならisolation boundaryを越えて安全に受け渡せるか」を型システムで表現する仕組みが無かったことです。Swift には大きく分けて次のような型があります。
IntやStringのような値セマンティクスを持つ値型。コピーして渡しても参照を共有しないため、基本的に安全です。structやenum、タプルによる値型の合成。中身がすべて安全なら全体も安全です。- 不変クラスや、内部でロック/アトミック操作で同期を取っている「スレッドセーフ」なクラス。参照を渡しても安全に使えます。
- 可変状態を持つ一般的なクラスや、
NSMutableStringのような外部フレームワークの型。参照を共有するとデータ競合の温床になります。 - クロージャや関数値。キャプチャの仕方次第で安全にも危険にもなります。キャプチャが空、あるいは値キャプチャで中身が安全なら渡せても、参照キャプチャで可変ローカルを抱え込んでいると危険です。
こうした性質の違いを型で区別できないと、次のようなコードをコンパイラが区別できません。
actor MyContactList {
func filteredElements(_ fn: (ContactElement) -> Bool) async -> [ContactElement] { ... }
}
// キャプチャなし・String のキャプチャは本来安全
list = await contactList.filteredElements { $0.firstName != "Max" }
// ローカル変数を参照で捕まえてしまうクロージャは危険
var counter = 0
list = await contactList.filteredElements {
counter += 1 // isolation boundary を越えて可変状態を共有してしまう
return $0.firstName == "Max"
}
さらに、スローされるエラーにも同じ問題があります。throws は実質的に「任意の Error 適合型を返す」のと同じですが、Error に何の制約もないと、アクター内部の可変状態を握ったエラー値がisolation boundaryを越えて伝播してしまう余地があります。
class MutableStorage { var counter: Int = 0 }
struct ProblematicError: Error { var storage: MutableStorage }
actor MyActor {
var storage = MutableStorage()
func doSomethingRisky() throws -> String {
throw ProblematicError(storage: storage) // アクターの可変状態を外へ持ち出してしまう
}
}
既存のコードベースとの互換性も悩ましいポイントです。Swift の並行性モデルを前提にしていない既存ライブラリや Objective-C フレームワークが大量に存在するため、「安全に越境できる型」を表現する仕組みは、ライブラリ作者が自分の型を追認させられたり、利用者がレガシーな型を後付けで扱えたりといった拡張性も備えている必要があります。
02 どのように解決されるのか
標準ライブラリに Sendable プロトコルを導入し、「isolation boundaryを越えて安全に渡せる型」を型システムで表現できるようにします。あわせて、関数型に付ける @Sendable 属性も導入し、クロージャや関数についても同じ判定ができるようにします。
アクターのメソッド呼び出しや構造化された並行性のタスクを跨ぐやり取りでは、引数・戻り値の型が Sendable に適合していることがコンパイラによって要求されます。適合していない non-Sendable な値を渡そうとするとコンパイルエラーになります。
actor SomeActor {
func doThing(string: NSMutableString) async { ... }
}
func f(a: SomeActor, myString: NSMutableString) async {
// error: 'NSMutableString' may not be passed across actors;
// it does not conform to 'Sendable'
await a.doThing(string: myString)
}
Sendable プロトコル
Sendable は「マーカープロトコル」と呼ばれる、要件を持たずコンパイル時にだけ意味を持つプロトコルです。ランタイムには影響せず、x as? Sendable のような動的チェックにも使えません。純粋に「この型は越境して渡して安全」という静的な目印として機能します。
どの型が Sendable になれるのかは、種類ごとにルールが決まっています。
- 値型(
struct/enum): すべての stored property(enumは associated value)がSendableなら適合できます。Sendableでないメンバーがあると適合宣言はエラーになります。 - タプル: 要素がすべて
Sendableなら自動的にSendableです。 - メタタイプ:
Int.Typeなど、メタタイプはイミュータブルなので常にSendableです。 - クラス: 原則として
finalで、stored property がすべてletかつSendableなクラスだけがSendableに適合できます。継承はNSObjectを除いて不可です。 - アクター: 自身のメールボックスで同期を取っているため、暗黙に
Sendableに適合します。 - 関数型:
@Sendableが付いた関数型は暗黙にSendableに適合します(後述)。
struct MyPerson: Sendable { var name: String; var age: Int } // OK
// error: MyNSPerson cannot conform to Sendable due to NSMutableString member.
// note: add '@unchecked' if you know what you're doing.
struct MyNSPerson: Sendable {
var name: NSMutableString
var age: Int
}
final class MyClass: Sendable { // OK
let state: String
init(state: String) { self.state = state }
}
struct / enum / 対象クラスの Sendable 適合は、その型を定義したのと同じソースファイル内 でしか宣言できません。別ファイルや別モジュールから extension で適合させると、private な stored property が見えないまま誤った判断をされる危険があるためです。
@unchecked による明示的な「自己責任」適合
内部的にロックやアトミックで同期している、あるいは外部フレームワークの事情で検査が通らないといった場合には、@unchecked Sendable を使って検査をバイパスできます。この場合は「本当に安全であること」を宣言した人が保証する形になります。
// コンパイラのチェックを通さずに Sendable として扱う
extension MySneakyNSPerson: @unchecked Sendable {}
@unchecked は、別ファイル・別モジュールで後付け適合させるときにも使えます。
暗黙の Sendable 適合(struct / enum)
Sendable になれる条件を満たす型にいちいち : Sendable と書くのは冗長なため、次の struct / enum については 暗黙に Sendable 適合 が与えられます。
- 非 public、かつ
@usableFromInlineが付いていないstruct/enum @frozenが付いている public なstruct/enum
struct MyPerson2 { // 自動的に Sendable に適合
var name: String; var age: Int
}
class NotConcurrent {} // Sendable ではない
struct MyPerson3 { // nc が non-Sendable なので Sendable にはならない
var nc: NotConcurrent
}
public な非 @frozen 型に暗黙適合を与えないのは、後から Sendable でないプロパティを足したときに API 契約を破ってしまうのを避けるためです。Hashable などが明示的な宣言を要求するのに対し、Sendable にコード生成や ABI 影響が無いからこそ許される、限定的なルールです。
ジェネリックな場合は、すべてのジェネリック引数が Sendable であることがコンパイラに分かっていれば暗黙適合します。条件付き適合(where T: Sendable)の自動導出は行われないので、必要なら明示的に書きます。
struct X<T: Sendable> { // 暗黙に Sendable
var value: T
}
struct Y<T> { // T に制約が無いので Sendable にはならない
var value: T
}
// 条件付き適合は明示的に
struct MyPair<T> { var a, b: T }
extension MyPair: Sendable where T: Sendable {}
標準ライブラリ型の Sendable 適合
標準ライブラリは Int や String などをはじめ、ほとんどの型を Sendable に適合させます。ジェネリック型は「要素が Sendable なら全体も Sendable」という条件付き適合で表現されます。
extension Int: Sendable {}
extension String: Sendable {}
extension Optional: Sendable where Wrapped: Sendable {}
extension Array: Sendable where Element: Sendable {}
extension Set: Sendable where Element: Sendable {}
extension Dictionary: Sendable where Key: Sendable, Value: Sendable {}
数値型や Range、コレクションはもちろん、KeyPath も Sendable です。Copy on Write により、Array や Dictionary はコピー的に渡しても内部バッファを積極的にコピーするわけではなく、この越境は実行コスト的にも軽量です。
例外として、次の型は上記ルールから外れます。
ManagedBuffer: 可変参照セマンティクスを提供するためのクラスなので、Sendableには適合させません(@uncheckedでも不可)。Unsafe(Mutable)(Buffer)Pointer: 本質的に unsafe な存在として、無条件にSendableに適合します。要素が non-Sendableでも構いません。unsafe API の中で一点だけ厳格にするのは一貫性がないため、呼び出し側の責任で使う前提になっています。- lazy アルゴリズムの結果型(
array.lazy.map { ... }の戻り値など): 内部で non-@Sendableクロージャを抱えるため、Sendableには適合しません。
Error と CodingKey は Sendable を継承
アクター内部で作られたエラーがisolation boundaryを越えて伝播してしまう問題を塞ぐため、Error プロトコル自身が Sendable を継承するように変更されます。
protocol Error: Sendable { ... }
これにより、前節の ProblematicError は MutableStorage という non-Sendable なメンバーを持つために Sendable 検査に引っかかり、コンパイルエラーになります。同様の理由で CodingKey も Sendable を継承します(EncodingError / DecodingError が CodingKey を保持するため)。
マーカープロトコルは ABI に影響しないので、既存の Error 適合型の ABI を壊すことはありません。ただし、ProblematicError のように「今までは通っていたのにこれからは通らない」コードが生じるため、Swift 6 より前では 警告 に落として段階的に移行できるようになっています。
C / Objective-C 連携
インポートされた C API についても、次の範囲で暗黙の Sendable 適合が与えられます。
- C の
enum型は常にSendable。 - C の
struct型は、すべての stored property がSendableならSendable。 - C の関数ポインタは値をキャプチャできないため
Sendable。
@Sendable クロージャ
関数型はプロトコルに適合させられないため、Sendable な関数値を表すには新しい属性 @Sendable を導入します。@Sendable が付いた関数型は自動的に Sendable プロトコルに適合します。
@Sendable 関数値(特にクロージャ)には次のような規則が課されます。
- クロージャは 値キャプチャのみ が許される。
letで導入された値は暗黙に値キャプチャになりますが、それ以外は明示的なキャプチャリストで値キャプチャを指示する必要があります。 - キャプチャされたすべての値の型が
Sendableに適合していなければならない。 - プロパティアクセサ(getter / setter など)は、このバージョンでは
@Sendableシステムに参加できません。
let prefix: String = "pre-"
var suffix: String = "-suf"
// suffix は var なので、@Sendable なクロージャにするにはキャプチャリストで値キャプチャ
strings.parallelMap { [suffix] in prefix + $0 + suffix }
@Sendable は @escaping と直交する独立の属性で、振る舞い方も似ています。@Sendable 関数は非 @Sendable 関数の部分型(subtype)で、暗黙変換が効きます。クロージャ式に対しては @escaping と同様に文脈から推論されます。
actor MyContactList {
func filteredElements(_ fn: @Sendable (ContactElement) -> Bool) async -> [ContactElement] { ... }
}
// キャプチャなし → OK
list = await contactList.filteredElements { $0.firstName != "Max" }
// searchName は String(Sendable)で値キャプチャ → OK
list = await contactList.filteredElements { $0.firstName == searchName }
// 既存の @Sendable 互換関数をそのまま渡すのも OK
list = await contactList.filteredElements(dynamicPredicate)
// Error: NSMutableString は Sendable ではない
list = await contactList.filteredElements { $0.firstName == nsMutableName }
// Error: var を参照キャプチャしようとしている
var someLocalInt = 1
list = await contactList.filteredElements {
someLocalInt += 1
return $0.firstName == searchName
}
@Sendable の推論ルール
クロージャ式が @Sendable かどうかは、次のいずれかで決まります。
@Sendable関数型を期待するコンテキストで使われている(例:parallelMapの引数やTask.runDetachedの引数)- クロージャの
in仕様で@Sendableと明示されている
@escaping とは異なり、文脈のないクロージャは既定で非 @Sendable です。
// 既定では @escaping だが @Sendable ではない
let fn = { (x: Int, y: Int) -> Int in x + y }
ネストされた関数も同様にキャプチャを持つため、@Sendable 宣言でオプトインできます。@Sendable な関数は非 @Sendable 関数型に暗黙変換できますが、逆はできません。
func globalFunction(arr: [Int]) {
var state = 42
// Error: @Sendable クロージャから可変ローカルは参照キャプチャできない
arr.parallelForEach { state += $0 }
// 非 @Sendable ネスト関数: state を参照でキャプチャできる
func mutateLocalState1(value: Int) { state += value }
// Error: 非 @Sendable 関数を @Sendable 引数に渡せない
arr.parallelForEach(mutateLocalState1)
@Sendable
func mutateLocalState2(value: Int) {
// Error: @Sendable 関数の中では state は let としてキャプチャされる
state += value
}
}
アクター境界の同期/非同期の判定
アクターのメソッドを同じアクター内部から呼ぶときは境界を越えないので await 不要ですが、@Sendable クロージャの中から self を経由して呼ぶときは境界越えと見なされ await が必要です。
extension SomeActor {
public func thing(arr: [Int]) {
arr.forEach { self.oneSyncFunction(x: $0) } // 同期でOK
arr.parallelMap { self.oneSyncFunction(x: $0) } // Error: await が必要
}
}
これは @Sendable クロージャが「別の isolation domain で動く可能性がある」ことを型として表現しているからこその挙動です。
KeyPath 式のキャプチャ
KeyPath 自体は Sendable に適合しますが、そのためには KeyPath リテラルが Sendable な値しかキャプチャできないという制約が必要です。subscript 引数を介してキャプチャされる値はこの制約に従うため、non-Sendable な型をキーとする添字アクセスを含む KeyPath はコンパイルエラーになります。
class SomeClass: Hashable { var value: Int = 0 }
class SomeContainer { var dict: [SomeClass: String] = [:] }
let sc = SomeClass()
// error: capture of 'sc' in key path requires 'SomeClass' to conform to 'Sendable'
let keyPath = \SomeContainer.dict[sc]
Swift 5 と Swift 6 の違い
Sendable と @Sendable を導入しても、使われない限り既存のコードに影響はありません。とはいえ、Error / CodingKey の Sendable 継承や KeyPath まわりの制約強化は、一部の既存コードで新たな違反を生み出します。これらのチェックは Swift 6 モードでエラー として扱われ、Swift 5 以前では警告 に落とされます。後者では、型や API を徐々に Sendable に整えていく段階的な移行が可能です。