region based isolation
Region based Isolation
このダイジェストはClaude Opus 4.7 / 4.8によって生成されたものです(License)。原文はこちら↗。
01 何が問題だったのか
SE-0302 で導入された Sendable 検査は、「non-Sendable な値はisolation boundaryを越えて渡してはいけない」という単純なルールでした。このルールは安全側に倒されているため、実際にはデータ競合が起こり得ない自然なパターンまで広く禁止してしまい、利用者に強い不便を強いていました。
典型的なのは「新しく作った値をアクターに渡すだけ」というコードです。
// non-Sendable
class Client {
init(name: String, initialBalance: Double) { ... }
}
actor ClientStore {
static let shared = ClientStore()
var clients: [Client] = []
func addClient(_ c: Client) {
clients.append(c)
}
}
func openNewAccount(name: String, initialBalance: Double) async {
let client = Client(name: name, initialBalance: initialBalance)
await ClientStore.shared.addClient(client)
// error: non-Sendable 型 'Client' をisolation boundary越しに渡せない
}
このコードはよく見ると安全です。
clientのコンストラクタ引数はStringとDoubleというSendableな値のみで、外部の non-Sendableな状態を引き込んでいない。clientはこの関数の中で生成されたばかりで、他の場所から参照が漏れていない。addClientに渡したあと、clientは一度も使われていない。
つまり「client を ClientStore.shared に渡して、以降は触らない」という使い方であり、並行アクセスは発生しません。それでも Sendable 検査は「non-Sendable だからダメ」の一点張りで弾いてしまっていました。
結果として、利用者は @unchecked Sendable のような unsafe な抜け道を使うか、値を Sendable に無理やり作り変えるかを強いられ、並行性の安全性を謳いながら日常的なコードで安全な脱出口を塞いでしまうという状態になっていました。Sendable 検査がもう少し「どこから来てどこへ行くのか」を追ってくれれば解消できる偽陽性だったのです。
02 どのように解決されるのか
「isolation region」という新しい概念を導入し、コンパイラが制御フローを追いながら「その値をisolation boundaryを越えて渡しても安全か」を判定できるようにします。判定は関数ごとのシグネチャ単位ではなく、使用箇所ごとに行われます。
isolation region とは
isolation region は、non-Sendable な値の集合で、「互いに参照をたどり着ける可能性がある値」をひとまとめにしたものです。同じ region にいる値は実質的に一蓮托生で扱われ、違う region の値同士は互いに影響しないことがコンパイラによって保証されます。
let john = Client(name: "John", initialBalance: 0)
let joanna = Client(name: "Joanna", initialBalance: 0)
await ClientStore.shared.addClient(john)
// john と joanna は互いに参照を持たない → 別 region
await ClientStore.shared.addClient(joanna) // OK
一方、途中で john.friend = joanna のようにリンクを張ると、2つの値は同じ region にマージされます。その状態で john を渡すと joanna も一緒に渡したことになり、後から joanna を使うとエラーになります。
region は代入・プロパティアクセス・関数呼び出し・クロージャキャプチャ等で自然にマージされていきます。明示的な宣言や注釈は必要ありません。
「転送(transfer)」という考え方
non-Sendable な値(の region)をisolation boundaryを越えて渡すことを transfer と呼びます。ある値が transfer されたあと、呼び出し元の側ではその値(および同じ region にいる他の値)を使えなくなる ようコンパイラが監視します。これが今回の診断の本体です。
func openNewAccount(name: String, initialBalance: Double) async {
let client = Client(name: name, initialBalance: initialBalance)
await ClientStore.shared.addClient(client) // ここで transfer
// client.logToAuditStream() // Error! transfer 済みなので使えない
}
冒頭のモチベーション例は、addClient に渡したあと client を使っていないので、このルール下でそのまま通ります。
region の4種類
- Disconnected: 特定のアクターに属さない、独立した region。別のドメインへ transfer できる。
- Actor-isolated: 特定のアクター(インスタンス/グローバルアクター)に属する region。外に出せない。アクターのメソッドの引数も、呼び出し元がアクター状態を渡しうるのでこの扱いになる。
- Task-isolated: タスクに紐づく region。
nonisolated async関数の引数は、呼び出し元のタスクに属する扱いで、タスク外への transfer はできない。 - Invalid: 条件分岐で「どのアクターに属するか静的に決められない」状態になったもの。以降の使用はエラー。
async let・非同期アクターイニシャライザが書きやすくなる
region ベースの考え方により、これまで諦めていたいくつかのパターンが素直に書けるようになります。
非同期アクターイニシャライザに non-Sendable を渡す:
actor MyActor {
var x: NonSendable
init(_ arg: NonSendable) async {
self.x = arg
}
}
func makeActor() async -> MyActor {
let x = NonSendable()
let a = await MyActor(x) // OK(x はここで a に transfer される)
return a
// a を返したあと、呼び出し元で x を使おうとするとエラー
}
async let に disconnected な値を渡す:
actor MyActor {
func example() async {
let x = NonSendable()
async let value = nonIsolatedCallee(x)
// ここでは x は task-isolated 扱いなので使えない
let result = await value
// await 後、x は再び disconnected に戻る → また使える/再 transfer もOK
useValue(x)
}
}
「弱い」transfer
この transfer はメモリ所有権の移動ではなく、あくまで「以降、caller 側からは使ってはいけない」という静的なルールです。呼び出し元の ARC 的なライフタイムは従来と変わりません。所有権を動かさないので、既存関数の ABI や呼び出し規約を壊さずに済みます(所有権まで動かす「強い transfer」は将来の拡張に譲られました)。
弱い transfer の都合のよい帰結として、nonisolated async 関数のように「呼び出しの間だけ値を預かるが、永続的には抱え込まない」相手に渡した場合、呼び出しが終われば値は再び disconnected 扱いに戻ります。上の async let の例がまさにそのケースです。
non-Sendable なクロージャも扱える
non-Sendable クロージャの region は、キャプチャした値の region のマージになります。
- キャプチャがすべて disconnected → クロージャ自身も disconnected として transfer 可能。
- actor-isolated な値をキャプチャ → クロージャもそのアクター region に属する(= 外へ出せない)。
- 逆に、actor-isolated なクロージャに外部の non-
Sendable値をキャプチャさせると、その値はアクター region に取り込まれる(以降 caller からは使えない)。
これにより assumeIsolated のような「nonisolated 関数の中でisolation boundaryに入る」API でも、パラメータを無造作にアクター状態に紛れ込ませるようなコードが検出できるようになります。
段階的導入
region-based isolation は RegionBasedIsolation という upcoming feature flag でオプトインでき、Swift 6 モードではデフォルトで有効です。assumeIsolated などの既存APIに関してはシグネチャが追随して Sendable 制約が強化されたため、既存コードで新しく警告・エラーが出るケースがあります。
03 今後の見通し
本提案では「弱い」transfer のみが扱われ、所有権を伴う「強い」transfer や、アクター内部の値を外に取り出すしくみは将来の拡張に委ねられました。以下はいずれも本提案には含まれず、後続の提案で議論・実装される構想であり、実現を約束するものではありません。
transferring パラメータ
「この引数は呼び出し元から必ず transfer されてくる」ことを関数シグネチャ側で宣言できる修飾子です。本提案の transfer は呼び出し側で「以降使えない」とするだけのルールでしたが、transferring を付けたパラメータは、同じ isolation domain の caller からでも transfer が強制され、所有権ごと預けたのと同等に扱えます。
actor Actor {
func transfer<T>(_ t: transferring T) async {}
func method() async {
let a = NonSendable()
// 同じ isolation domain にある transfer に渡しても…
await transfer(a)
// 以降 a は使えない(Error!)
useValue(a)
}
}
これにより、同期のアクターイニシャライザに non-Sendable な値を渡してフィールドへ保存する、といった本提案では認められないパターンも安全に書けるようになります。
actor Actor {
var field: NonSendable
init(_ x: transferring NonSendable) {
self.field = x // OK
}
}
@returnsIsolated 戻り値
戻り値を、関数の引数や self の region と切り離し、新しい disconnected region として返すための修飾子です(構文は仮称)。本提案では non-Sendable な戻り値は引数の region とマージされる扱いでしたが、@returnsIsolated を付けると、呼び出し側はその戻り値を独立した region として受け取れます。アクター内部で新しく作った値を、アクター外へ「disconnected な状態で」返すといった用途に使えます。
actor Actor {
var field: NonSendableType
func getValue() -> @returnsIsolated NonSendableType {
let x = NonSendableType()
if await booleanValue {
return x // OK: x は disconnected
}
return field // Error: アクターの region に属する値は返せない
}
}
disconnected field と disconnect 演算子
アクター本体の region とは別の region に属するフィールド(disconnected field)を持てるようにし、そこに保持している値を disconnect 演算子で取り出して新しい disconnected region として外へ返す案です。@returnsIsolated と組み合わせれば、アクター内部に保持していた non-Sendable な値を安全にアクター外へ取り出せます。
actor MyActor {
disconnected var x: NonSendableType
func reinitField() -> @returnsIsolated NonSendableType {
let result = disconnect x
x = NonSendableType() // disconnect 後は別の値で再初期化が必要
return result
}
}
disconnect した後にすべての制御フローでフィールドを再初期化していないとエラーになり、また disconnected field への代入は disconnected region にある値(例えば transferring パラメータ)からしか行えないなど、region の整合性を保つための制約が課される予定です。