Swift Digest
SE-0431 | Swift Evolution

@isolated(any) Function Types

Proposal
SE-0431
Authors
John McCall
Review Manager
Doug Gregor
Status
Implemented (Swift 6.0)

01 何が問題だったのか

Swiftでは、関数の actor isolation は「その関数をどこで動かしてよいか」を決める重要な情報です。関数の宣言に対してはコンパイラが正確に isolation を追跡できますが、関数をとして受け渡す場面では、既存の関数型では表現しきれないケースがありました。

関数の isolation の取り方は次の3通りがあります。

  • nonisolated
  • グローバルアクターに isolate(@MainActor など)
  • isolated パラメータ、またはキャプチャした値に isolate

最初の2つは () -> Int@MainActor () -> Int のように関数型で表現できますし、3つ目も (isolated MyActor) -> Int のように「パラメータ」に isolate されているケースなら型で表せます。問題は、キャプチャした値に isolate されたクロージャや、アクターメソッドの部分適用myActor.methodName)のように、「この関数値が握っている特定のアクター参照」に isolate されているケースです。これは値依存の情報のため、既存の関数型では表現できません。

actor WorldModelObject {
  var position: Point3D

  func linearMove(to finalPosition: Point3D, over time: Duration) {
    let originalPosition = self.position
    let motion = finalPosition - originalPosition

    // このクロージャはキャプチャした self に isolate されているが、
    // その事実を関数型で表現する手段が無い
    gradually(over: time) { [isolated self] progressProportion in
      self.position = originalPosition + progressProportion * motion
    }
  }
}

このため、任意の isolation を持つ関数を受け取るAPIは、isolation を完全に型消去して受け取るしかありませんでした。具体的には、受け手側の関数型を non-isolated にして受け取ることになるのですが、これには3つの大きな欠点があります。

  • 受け手の関数型を async にしなければならない(内部で適切な isolation に切り替えるため)
  • 引数や戻り値が isolation boundary を越えるため、non-Sendable な値を扱う自由度が下がる
  • 元の関数が実際にはどのアクターに isolate されていたかを受け手が復元できない

特に最後の点は実害が大きく、たとえば Task イニシャライザは渡されたクロージャの isolation を知ることができないため、いったんグローバルな並行エグゼキュータ上でタスクを開始し、クロージャに入った瞬間に本来の isolation へ切り替える、という挙動にならざるを得ませんでした。これは余計な同期や再サスペンドを生むうえ、「Task を作った順序」と「アクター上でタスクが実際にエンキューされる順序」が一致しないという意味でも問題です。

必要だったのは、「isolation は任意だが、実行時にその isolation を取り出せる」ことを表現できる新しい関数型でした。

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

関数型に付けられる新しい属性 @isolated(any) を導入します。@isolated(any) な関数値は、元になった関数の isolation を動的に保持し、.isolation プロパティを通じて取り出せます。

func gradually(over: Duration, operation: @isolated(any) (Double) -> ())

この属性は型属性で、関数型にしか付けられません。また isolation の指定の一種なので、グローバルアクター属性や isolated パラメータといった他の isolation 指定と併用することはできません。抽象的な isolation 指定なので、具体的な関数宣言やクロージャに直接付けることもできません(関数エンティティ自体は実際の isolation を持つ必要があるため)。

呼び出しと isolation プロパティ

@isolated(any) な関数を任意のコンテキストから呼び出すと、常に isolation boundary を越える扱いになるため、同期関数であっても await が必要です。

await operation(timePassed / overallDuration)

一方、関数値が握っている isolation は、特別な isolation プロパティ(型は (any Actor)?)で取り出せます。

func traverse(operation: @isolated(any) (Node) -> ()) {
  let isolation = operation.isolation
}

isolation の値は、関数の動的 isolation によって次のように決まります。

  • nonisolated な関数なら nil
  • グローバルアクター G に isolate されているなら G.shared
  • 具体的なアクター参照に isolate されているなら、そのアクター参照

isolation チェッカーはこのプロパティの値が関数の isolation と一致することを知っているため、operation.isolation と同じ isolation を持つコンテキストから operation を呼ぶ場合、その呼び出しは isolation boundary を越えない扱いになります(この仕組みは SE-0420 の generalized isolation checking を fn.isolation の派生式にも広げるものです)。

変換規則

@isolated(any) な関数を @isolated(any) 型に変換するときは、元の関数の isolation がそのまま動的 isolation として保持されます。ただし isolated パラメータを持つ関数型からの変換は不可です(値依存の isolation をその場では取り出せないため)。

逆に、@isolated(any) から非 @isolated(any) への変換では、変換先は async な関数型でなければなりません。変換先に isolation 指定があっても無視され、関数は元の動的 isolation 上で動作します。この場合、引数と戻り値は isolation boundary を越えるため sendable でなければなりません。

実行時の挙動

@isolated(any) な関数値を呼ぶと、その isolation を持つ関数を直接呼んだ場合と同じ挙動になります。

  • async な関数なら、動的 isolation の上で実行されます(nonisolated なら SE-0338 に従って呼び出し元の isolation から抜けます)
  • 同期関数なら、動的にアクターに isolate されている場合のみ切り替えが起こり、動的に nonisolated なら現在のコンテキスト(isolate されていても)でそのまま同期的に動きます

Sendability の扱い

以前の設計では @isolated(any)@Sendable を含意する案もありましたが、最終的には含意しない形になりました。region-based isolation により、non-Sendable な値でも別のコンテキストに transfer できれば安全に受け渡せるため、「isolation を動的に保持する」ことと「複数コンテキストから並行に使える」ことは別問題だからです。したがって、non-Sendable だが transfer 可能な @isolated(any) 関数というものも意味を持ちます。

実際に呼び出し時の sendability 要件は次のようになります。引数と戻り値については通常のクロスisolation呼び出しと同じ制約ですが、関数値自体は、関数が async でかつ呼び出し元が静的に nonisolated でないと判定される場合にのみ sendable が必要です。

タスク生成API での採用

標準ライブラリのタスク生成APIはすべて @isolated(any) を受け取るように更新されます。

  • Task.init / Task.detached
  • TaskGroup.addTask / addTaskUnlessCancelled
  • ThrowingTaskGroup / DiscardingTaskGroup / ThrowingDiscardingTaskGroup の同等API

これにより、これらのAPIは渡されたクロージャの動的 isolation を見て、新しいタスクを同期的に正しいエグゼキュータへエンキューできるようになります。結果として、従来のような「いったんグローバルエグゼキュータで開始してから本来の isolation に飛ぶ」という往復が不要になります。

Swift はタスク実行の最適化として「不要な isolation 切り替えの省略」を引き続き許容しており、特に Task {} のように暗黙に呼び出し元の isolation を継承しただけのクロージャについては、最初の実行位置を柔軟に選べます。これは、@MainActor なコンテキストで Task {} を使っただけで本来の非同期処理(例: ダウンロード)がメインアクターのキュー処理を待たされる、という事態を避けるための例外です。

一方、Task { @MainActor in ... } のように明示的に isolation を指定したクロージャについては、その isolation 上で実際に開始することが保証されます。このため、次のコードでは2つのタスクが作成順のままメインアクター上で開始されることが保証されます。

func process() async {
  Task { @MainActor in
    // ...
  }

  // 何らかの処理

  Task { @MainActor in
    // ...
  }
}

この保証は、FIFO なキューのパイプラインで順序を維持したいパターンで重要になります。

採用の指針

@isolated(any) は、次のように「並行に動く可能性があり、かつ isolation が事前に決まっていない関数」を受け取るAPIに向いています。

  • タスク生成をラップする関数
  • 並列 map のように、関数を複数回並行に呼ぶアルゴリズム

逆に、次のような場合には向きません。

  • 呼び出し元の isolation を維持したい(非並列な map のような)アルゴリズム。こちらは非 Sendable な関数を受け取るべきです
  • 特定の isolation(@MainActor など)で呼ぶことが決まっているAPI

バックデプロイ

基本機能はコード生成で完結するため、追加のランタイムサポートは不要です。ただし @isolated(any) な関数型をジェネリック引数として使う(例: [@isolated(any) () -> ()])には新しいランタイムが必要です。ABI 安定なプラットフォームにバックデプロイするコードでは、代わりに struct で包む回避策が使えます。標準ライブラリのタスク生成APIについては、必要なランタイム機能は Concurrency ランタイムの初版から存在しているため、バックデプロイ上の問題はありません。

Future Directions

将来の方向性として、次のようなアイデアが挙げられています(いずれも本提案には含まれず、speculative な見通しで、実現を約束するものではありません)。

  • assumeIsolated との連携改善: @isolated(any) な関数について「今のコンテキストは既にその isolation と一致している」と表明して、境界を越えずに呼び出せるようにする拡張です。SE-0392 の assumeIsolated(any Actor)? に拡張しつつ、isolated パラメータとの値同一性を isolation チェッカーに理解させる必要があり、まとまった設計が要ります。
  • 静的に isolation を保持する関数型 @isolated(to:): @isolated(any) が isolation の「existential 消去」だとすると、そのジェネリック版に相当するのが値依存な @isolated(to: someActor) のような関数型です。値依存型は型システムを大きく複雑化するため導入のハードルは高く、多くのユースケースには @isolated(any) のほうが便利なため、当面は不要と見られています。