Swift Digest
SE-0420 | Swift Evolution

actor isolationの継承

Inheritance of actor isolation

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

このダイジェストはClaude Opus 4.7 / 4.8によって生成されたものです(License)。原文はこちら

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 な関数に切り出す、といった使い分けが向いています。

03 今後の見通し

本提案の延長線上では、次のような拡張も議論されています。あくまで将来の方向性として示されているもので、実現を約束するものではありません。

isolation 継承のためのシンタックスシュガー

isolated (any Actor)? = #isolation というパラメータを毎回書くのは冗長です。また、computed property のアクセサのようにそもそも引数を追加できない箇所では、この方法を使えません。

そこで、関数やアクセサに付けるだけで呼び出し元の isolation を継承させる属性を導入する案が示されています。意味的には #isolation を実引数とする isolated パラメータを受け取るのと同等になりますが、引数を追加できない宣言にも使え、書き方も簡潔になります。さらに、内部実装としてはアクター参照ではなく UnownedSerialExecutor? を直接受け渡せるため、エグゼキュータを取り出すための動的ディスパッチを省略でき、実行時にも効率的になり得ます。

isolation を持つ関数型

本提案は isolation を関数の へ伝える話でしたが、関数の へ伝える方向の拡張も考えられます。現状、Swiftの型システムで関数の isolation を表現できるのはグローバルアクターの場合(@MainActor () -> () など)に限られ、それ以外の isolation は関数値の型からは消去されてしまいます。

将来的な案として、isolation を静的には型消去しつつ、関数値の中に (any Actor)? 相当の値として持たせ、実行時に取り出せるようにする @isolated () -> () のような関数型が検討されています。これがあれば、たとえば Task イニシャライザにそうした関数を渡したときに、その関数がもともと持っている isolation 上で Task を直ちに開始させる、といった使い方ができます。

このような関数型は本提案の isolated パラメータとも自然に組み合わせられ、コレクションの要素を順番に変換する sequentialMap のように、「呼び出し元」ではなく「渡されたクロージャ」の isolation 上で動かしたい高階関数を素直に表現できるようになります。