Inheritance of actor isolation
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 Actor や isolated (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-Sendable な Counter を 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 したあとの ref を Task のクロージャでキャプチャしても、元の 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 上で直ちに開始させる、といった使い方が考えられます。