Swift Digest
SE-0414 | Swift Evolution

Region based Isolation

Proposal
SE-0414
Authors
Michael Gottesman, Joshua Turcotti
Review Manager
Holly Borla
Status
Implemented (Swift 6.0)

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 のコンストラクタ引数は StringDouble という Sendable な値のみで、外部の non-Sendable な状態を引き込んでいない。
  • client はこの関数の中で生成されたばかりで、他の場所から参照が漏れていない。
  • addClient に渡したあと、client は一度も使われていない。

つまり「clientClientStore.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 制約が強化されたため、既存コードで新しく警告・エラーが出るケースがあります。

今後の展望

本提案のみでは解決しきれないケースに向けて、関連する拡張案が Future Directions として挙げられています(いずれもこの提案には含まれず、後続の提案で議論・実装されます)。

  • transferring パラメータ: 「この引数は呼び出し元から必ず transfer されてくる」ことを関数シグネチャで宣言できる修飾子。これがあれば、同じ isolation domain の caller からでも transfer が強制され、同期イニシャライザに non-Sendable 値を渡す道も開ける。
  • @returnsIsolated 戻り値: 戻り値をアクター region ではなく、新しい disconnected region として返す修飾子。アクター内部で作った値を呼び出し側に「disconnected な状態で」返せるようになる。
  • disconnected field / disconnect 演算子: アクター内部に、本体とは別の region に属するフィールドを持てるようにし、そこから値を「外に出す」操作を提供する案。