01 何が問題だったのか
SE-0414 で導入された region ベースの isolation は、制御フローを追跡する診断によって、non-Sendable な値が disconnected な region にあるかどうかを判定し、そうした値を isolation boundary を越えて安全に transfer できるようにしました。さらに SE-0430 の sending は、関数の引数や戻り値に「この位置の値は関数境界時点で disconnected な region にある」という契約を付け、任意の関数境界で disconnected な値をやり取りできるようにしました。
しかし sending は 関数境界の性質 であって、型の性質ではありません。そのため、値を stored property やコレクション、ジェネリックなコンテナに格納すると、その値が disconnected であるという情報は失われてしまいます。
たとえば、isolation boundary を越えて処理するために要素を貯めておくキューを考えます。次のような UniqueDeque に non-Sendable な値を貯めておき、取り出したものを別の isolation region へ送りたいとします。
struct UniqueDeque<Element: ~Copyable>: ~Copyable {
func append(_ element: consuming Element) { ... }
func popFirst() -> Element? { ... }
}
var deque = UniqueDeque<NonSendable>()
deque.append(NonSendable())
guard let element = deque.popFirst() else { return }
Task {
print(element) // Error: element は deque と同じ isolation region にあると見なされる
}
これを成立させるには、append の引数を consume し、popFirst の戻り値を sending として返すようにすればよさそうです。しかしそうすると、この型は「disconnected な値」しか格納できなくなり、non-Sendable ではあるが disconnected ではない 値を貯めておきたい、という別の重要な用途を壊してしまいます。
根本的な問題は、sending が関数境界の性質であり、型の性質ではないことです。UniqueDeque のようなジェネリックな型は、格納した要素を disconnected に保つかどうかを条件によって選ぶことができません。append と popFirst を sending にすれば、UniqueDeque は disconnected な値専用になってしまうのです。
02 どのように解決されるのか
値が disconnected な region にあるという性質を、格納しても失われないように型として保持するためのラッパー型 Disconnected<Value> を導入します。Mutex や Atomic などと同じ Synchronization モジュールに追加されます。
先ほどの UniqueDeque は、要素を Disconnected でくるむことで、格納した値を disconnected なまま取り出せるようになります。
var deque = UniqueDeque<Disconnected<NonSendable>>()
deque.append(Disconnected(NonSendable()))
guard var disconnected = deque.popFirst() else { return }
let element = disconnected.take()
Task {
print(element) // OK: element は disconnected な region にある
}
Disconnected でくるんだ値は disconnected な region に留まることが保証されます。take() は Disconnected を consume して、中の値を sending として返します。返された値は disconnected なので、そのまま isolation boundary を越えて transfer できます。
Sendable であることと安全性
Disconnected<Value> は、Value が Sendable かどうかにかかわらず、無条件に Sendable に適合します。中の値が常に disconnected な region にあることを保証しており、disconnected な region は安全に isolation boundary を越えて transfer できるためです。
この保証を崩さないため、値の出し入れはすべて sending 境界を経由する形に限定されています。値を取り出したり差し替えたりする操作はいずれも、ラッパーを consume するか、中の値を別の sending な値で置き換えるかのどちらかであり、ラッパー内部の記憶域への参照が transfer をまたいで生き残らないようになっています。また、すべてのメソッドが consuming か mutating であるため、コンパイラの排他性チェックによって、重複したアクセスや並行アクセスも禁止されます。中の値を consume も置換もせずに覗き見るアクセサはあえて用意されていません(そうしたアクセサはラッパー内部の値への参照を外へ漏らし、無条件の Sendable 適合と両立しないためです)。
値をくるむ
init(_:) は、disconnected な region にある値を要求します。生成直後でエイリアスを持たない値は、この条件をそのまま満たします。
final class Resource: ~Sendable {}
let wrapper = Disconnected(Resource())
値を取り出して transfer する
take() はラッパーを consume して中の値を返します。返り値は disconnected な region にあるので、同じ式の中でも、いったん保持してから後でも、isolation boundary を越えて transfer できます。
final class Resource: ~Sendable {}
// wrapper はキューなど Disconnected<Resource> を保持するコンテナから
// 取り出したもので、中の Resource は周囲の文脈から disconnected である
func process(wrapper: consuming Disconnected<Resource>) async {
let resource = wrapper.take()
await Task.detached {
use(resource) // OK: resource は disconnected な region にある
}.value
}
take() が返す値に disconnected の保証が付かなければ、キャプチャした resource は呼び出し元の region の一部と見なされ、detached task でのキャプチャは許されません。take() を呼んだあとのラッパーは consume 済みで、それ以上操作できません。
値をその場で入れ替える
swap(newValue:) は、保持している値を新しい値と 1 ステップで交換します。newValue は disconnected な region にあることが求められ、返される古い値も disconnected な region にあります。
final class Resource: ~Sendable {}
func swapResources(in wrapper: inout Disconnected<Resource>) async {
let old = wrapper.swap(newValue: Resource())
await Task.detached {
dispose(old) // OK: old は disconnected な region にある
}.value
}
取り出さずに中の値を変更する
withValue(body:) は、値を取り出さずに一時的なミュータブルアクセスが必要なときに使います。クロージャは値を inout sending Value として受け取ります。
var wrapper = Disconnected([Int]())
wrapper.withValue { array in
array.append(42)
}
inout sending は通常の inout より強い意味を持ちます。クロージャの中では値を別の isolation region へ transfer することもできます。ただし、クロージャが戻る時点でラッパーが disconnected な値を保持している状態を保たなければなりません。通常はその場での変更に使いますが、この緩い形のおかげで、値そのものを別の region へ送りたいコードとも組み合わせられます。body がエラーを throw した場合は、クロージャが最後に記憶域へ残した値をラッパーが保持したまま、エラーが呼び出し元へ伝播します。