@isolated(any) Function Types
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.detachedTaskGroup.addTask/addTaskUnlessCancelledThrowingTaskGroup/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)のほうが便利なため、当面は不要と見られています。