Swift Digest
SE-0420 | Swift Evolution

Inheritance of actor isolation

Proposal
SE-0420
Authors
John McCall, Holly Borla, Doug Gregor
Review Manager
Xiaodi Wu
Status
Implemented (Swift 6.0)

01 何が問題だったのか

Swiftのすべての関数には actor isolation があり、特定のアクターに isolate されているか、nonisolated のいずれかになります。呼び出された関数が呼び出し元と同じ isolation で動けば、actor-isolated なデータに直接アクセスできるほか、non-Sendable な値をやり取りしても安全で、不要なサスペンドも避けられます。

同期関数の場合、nonisolated な関数は呼び出し元の isolation を動的に引き継ぐ挙動になっています。たとえばアクターのメソッドから nonisolated な同期関数を呼び、そこに actor-isolated な配列の map を渡すといった使い方ができ、途中でサスペンドも起こりません。

しかし async 関数では同じことができません。SE-0338 により、nonisolated な async 関数は呼び出し元の isolation を引き継がず、入口で isolation をリセットする挙動に固定されました。この結果、isolated なコンテキストから nonisolated な async 関数を呼ぶと isolation boundary を越える扱いになり、次の制約が課されます。

  • 戻り値が Sendable でなければならない
  • self 引数が Sendable でなければならない
  • そのほかの引数もすべて Sendable でなければならない

たとえば AsyncIteratorProtocol.next() は nonisolated な async 関数で、多くの具体的なイテレータ型(AsyncStream.Iterator など)は Sendable ではありません。@MainActor から AsyncStream<Int>.Iterator をイテレートしようとすると、イテレータそのものを actor-isolated なコンテキスト外に渡すことになり、次のような警告が出てしまいます。

@MainActor func iterate(over stream: AsyncStream<Int>) async {
  var iterator = stream.makeAsyncIterator()
  while let element = await iterator.next() {
    // warning: passing argument of non-sendable type
    // 'inout AsyncStream<Int>.Iterator' outside of main actor-isolated context
    // may introduce data races
  }
}

さらに、たとえ型がすべて Sendable で呼び出し自体が通っても、next() が内部で利用する要素生成クロージャが別のアクターに isolate されている場合、next() は一度ジェネリックなエグゼキュータに飛んでからすぐまたクロージャの isolation domain に飛び直すことになり、不要なサスペンドが発生します。

結局のところ、nonisolated な async 関数は isolated なコンテキストから呼ばれたときに表現力でも性能でも不利で、特に higher-order な API でこの制約が強く響きます。async 関数にも、呼び出し元の isolation を明示的に引き継ぐ手段が必要でした。

02 どのように解決されるのか

言語に次の2つの変更を加えます。

  • SE-0313 の isolated パラメータの型として、actor 型のオプショナル((any Actor)? など)を認められるようにします。これにより、実行時に nonisolated になり得ることも表現できます。
  • デフォルト引数として使える特殊な式 #isolation を導入します。呼び出し側のソース位置での静的な actor isolation に解決されるため、isolated パラメータと組み合わせれば、呼び出し元の isolation を暗黙に受け渡せます。

組み合わせると、「呼び出し元の isolation を引き継ぐ async 関数」は次のように書けます。

extension AsyncIteratorProtocol {
  func next(isolation: isolated (any Actor)? = #isolation) async -> Element? {
    ...
  }
}

actor isolation は値依存(どの型のアクターかだけでなく、どのインスタンスかまで含めて決まる)なので、ジェネリクスでは表現できません。そこで isolation を通常のパラメータとして受け取り、#isolation を経由して呼び出し元から実引数を暗黙供給する、という設計になっています。

一般化された isolated パラメータ

isolated パラメータには、次のいずれかの型を指定できるようになります。

  • Actor に適合する具象型、または Actor を含意するプロトコル型 T
  • そのオプショナル T?

オプショナルにした場合、実引数が nil かどうかで実行時の挙動が切り替わります。nil のときは nonisolated な関数と同じように振る舞い(async なら入口で isolation をリセット)、非 nil のときは、その unwrap 後のアクター参照に isolate された関数として振る舞います。

distributed actor はそのままでは Actor に適合しませんが、ローカルであることが分かっている場合は actor として扱えます。そのため、isolated な distributed actor から any Actor を取り出す asLocalActor プロパティが用意されており、isolated any Actorisolated (any Actor)? を受け取るAPIに渡せます。

呼び出しが isolation を共有しているかの判定

isolated パラメータに対して実引数を渡す呼び出しは、次のいずれかに該当する場合、呼び出し元と同じ isolation を共有する呼び出し(= isolation boundary を越えない呼び出し)として扱われます。

  • 呼び出し元が nonisolated で、パラメータ型がオプショナル、実引数が nil の場合
  • 呼び出し元が isolated パラメータ(アクターメソッドの暗黙の self を含む)を持ち、実引数がそれへの参照、その非オプショナルな派生、または distributed actor からの asLocalActor である場合
  • 呼び出し元がグローバルアクター T に isolate されており、実引数が T.shared の場合

「非オプショナルな派生」とは、param? / param! や、if let ref = param で得た ref のような、isolated パラメータから optional を外した let 束縛のことです。括弧、try / await などのエフェクト演算子、as による型変換、Optional への暗黙昇格といった、意味に影響しない差異は判定時に無視されます。

この規則に従えば、#isolation をデフォルト引数に使った呼び出しは常に「isolation を共有する呼び出し」になります。

具体例で見てみます。

// Sendable ではないクラス
class Counter {
  var count = 0
}

extension Counter {
  // isolated (any Actor)? を受け取り、呼び出し元の isolation を引き継ぐ
  func incrementAndSleep(isolation: isolated (any Actor)?) async {
    count += 1
    await Task.sleep(nanoseconds: 1_000_000)
  }
}

actor MyActor {
  var counter = Counter()
}

extension MyActor {
  func testActor(other: MyActor) async {
    // OK: self を渡しているので isolation を共有
    await counter.incrementAndSleep(isolation: self)

    // NG: 別のアクター
    await counter.incrementAndSleep(isolation: other)

    // NG: MainActor.shared は self と無関係
    await counter.incrementAndSleep(isolation: MainActor.shared)

    // NG: nil は nonisolated を意味し、self と共有しない
    await counter.incrementAndSleep(isolation: nil)
  }
}

@MainActor func testMainActor(counter: Counter) async {
  // OK: MainActor.shared を渡している
  await counter.incrementAndSleep(isolation: MainActor.shared)

  // NG: nil は MainActor と共有しない
  await counter.incrementAndSleep(isolation: nil)
}

func testNonIsolated(counter: Counter) async {
  // OK: 呼び出し元が nonisolated で nil
  await counter.incrementAndSleep(isolation: nil)

  // NG: MainActor.shared と nonisolated は共有しない
  await counter.incrementAndSleep(isolation: MainActor.shared)
}

isolated パラメータを経由して呼び出し元と isolation を共有できるので、non-SendableCounter を actor-isolated なまま incrementAndSleep に渡せ、余計なサスペンドも挟まりません。

#isolation デフォルト引数

#isolation は任意の式位置で使える組み込み式で、呼び出し側の静的な actor isolation を表す式として展開されます。

  • nonisolated なコンテキストから呼び出された場合、パラメータはオプショナルでなければならず、実引数は nil として展開されます
  • グローバルアクター T に isolate されたコンテキストでは T.shared
  • isolated アクターパラメータを持つコンテキスト(アクターメソッドの暗黙の self を含む)ではそのパラメータへの参照
  • isolated な distributed actor パラメータ d を持つコンテキストでは d.asLocalActor
  • それ以外は、isolated パラメータまたはその非オプショナルな束縛をキャプチャしているクロージャ内でなければならず、そのキャプチャへの参照

#isolation の型は、#file#line と同様に文脈から決まり、文脈が無ければデフォルトで (any Actor)? になります。受け取り先は isolated パラメータである必要はなく、「呼び出し元の isolation を知りたい」ほかのAPIでも使えます。

next() の例に戻ると、次のように書くだけで、呼び出し側は普段どおり await iterator.next() と書け、そこから呼び出し元の isolation が自動的に引き継がれます。結果として @MainActor から AsyncStream<Int>.Iterator をイテレートしても警告は出なくなり、不要なエグゼキュータの往復も避けられます。

extension AsyncIteratorProtocol {
  func next(isolation: isolated (any Actor)? = #isolation) async -> Element? { ... }
}

クロージャからの isolation の継承

SE-0304 により、Task { ... } のように Task イニシャライザへ直接渡されたクロージャは、次の条件のいずれかを満たすとき呼び出し元の静的 isolation を継承します。

  • 呼び出し元が nonisolated
  • 呼び出し元がグローバルアクターに isolate されている
  • 呼び出し元が isolated パラメータ(アクターメソッドの暗黙の self を含む)を持ち、それをクロージャが強くキャプチャしている

本提案では3つ目の条件を拡張し、isolated パラメータの 非オプショナルな束縛 をキャプチャしている場合にも isolation が継承されるようにします。if let ref = isolation のように unwrap したあとの refTask のクロージャでキャプチャしても、元の isolated パラメータと同じ isolation を引き継げます。

採用時の指針

#isolation を既存パラメータのデフォルト引数として後から追加することは ABI 互換です(ただし新しいパラメータ自体を追加するのは ABI breaking です)。

ライブラリ関数を isolation 継承に切り替えるのは、事実上「どのような isolated コンテキストから呼ばれても動く」という約束になります。これは nonisolated にしておくのと大きくは変わらないので、適用にあまり慎重になりすぎる必要はありません。一方、重い計算や大量のアロケーションを伴う処理を isolation 継承にすると、アクターのロックを長く占有することになり、並行性が活きにくくなることがあります。通常はそういった処理は nonisolated のままにし、早期リターンが多い「fast path」の部分だけ isolation 継承にして、slow path は nonisolated な関数に切り出す、といった使い分けが向いています。

Future Directions

今後の方向性として、次のような拡張も議論されています(speculative な見通しで、実現を約束するものではありません)。

  • isolation 継承のためのシンタックスシュガー。isolated (any Actor)? = #isolation というパラメータを毎回書くのは冗長であり、またプロパティのアクセサのようにパラメータを追加できない箇所では使えないため、関数やアクセサに付けるだけで呼び出し元の isolation を継承させる属性が検討されています。内部的には UnownedSerialExecutor? を直接受け渡せるため、アクター参照経由よりも効率的になる可能性があります。
  • isolation を持つ関数型。現状では関数の isolation はグローバルアクターの場合しか型に現れず、それ以外は型消去されてしまいます。将来的には、動的に isolation を取り出せる @isolated () -> () のような関数型を導入し、Task イニシャライザに渡すとそのクロージャの isolation 上で直ちに開始させる、といった使い方が考えられます。