Run nonisolated async functions on the caller’s actor by default
01 何が問題だったのか
SE-0338 によって、nonisolated な async 関数は「呼び出し元のアクターから外れて、汎用エグゼキュータ上で実行される」という挙動になっていました。これは、メインアクターの応答性を守るための設計であり、nonisolated な async 関数の内部処理によってアクターが長時間占有されてしまうのを防ぐ目的がありました。
しかしこの挙動には、実際にコードを書く上で深刻な問題がいくつもありました。
nonisolated の意味が sync と async でちぐはぐ
同じ nonisolated という修飾子でも、同期関数と非同期関数では実行セマンティクスが違います。同期の nonisolated 関数は呼び出し元のアクター上でそのまま動きますが、async の nonisolated 関数はアクターから外れて動きます。結果として、引数・戻り値に対する Sendable チェックも、同期版には適用されないのに非同期版には適用されるという奇妙な非対称が生まれていました。
class NotSendable {
func performSync() { ... }
func performAsync() async { ... }
}
actor MyActor {
let x: NotSendable
func call() async {
x.performSync() // OK
await x.performAsync() // エラー(xをisolation boundaryを越えて送ることになる)
}
}
どちらも nonisolated なメソッドなのに、一方は通って一方はデータ競合エラーになる。利用者にとって直感的ではありません。
「呼び出し元のアクター上で動く async 関数」を書くのが大変
上のエラーを避けたければ、isolated パラメータと #isolation をデフォルト引数として使う、という回りくどい書き方をする必要がありました。
class NotSendable {
func performAsync(
isolation: isolated (any Actor)? = #isolation
) async { ... }
}
この書き方は初見では思いつきませんし、毎回書くには冗長で、しかも高階関数として扱うとデフォルト引数が失われてしまいます。
間違った API を作りやすい
ライブラリ作者が素直に async メソッドを公開すると、呼び出し元が actor-isolated なときにだけデータ競合エラーが現れます。作者自身がアクターから呼んでテストしていないと気付けず、利用側では回避手段が unsafe なオプトアウトしかない、という状況が頻発していました。実際、標準の Concurrency ライブラリ自身も当初このミスを犯しており、多くの API が isolated パラメータを使う形に差し替えられています。
高階の async API が書きにくい
たとえば「リソースを取得して body に渡して処理してもらう with-style」のような高階 async API を書こうとすると、body クロージャが呼び出し元のアクター上で動くことを指定する手段がありません。async クロージャ引数はアクターから外れる扱いになるため、non-Sendable な値を渡すと Sendable チェックで弾かれます。実行時には non-@Sendable クロージャは結局呼び出し元のアクター上で動くため、この Sendable チェックの多くは偽陽性でした。
全体として、「async 関数はアクターから外れる」というデフォルトがデータ競合診断の偽陽性を大量に生み、async API の設計・利用・学習のすべてにおいて障害になっていました。
02 どのように解決されるのか
nonisolated な async 関数のデフォルト挙動を、「呼び出し元のアクター上で動く」に変更します。これにより、nonisolated の意味が sync と async で一貫し、偽陽性のデータ競合診断の多くが解消されます。
この変更は既存コードの挙動を変えるため、NonisolatedNonsendingByDefault という upcoming feature flag で段階的に有効化されます。また、移行期には挙動を明示するための新しい構文 2 つが、どの言語モードでも使えるようになります。
新しい構文
nonisolated(nonsending)
呼び出し元のアクター上で動くことを明示する書き方です。引数・戻り値はisolation boundaryを越えて「送られない」ため、non-Sendable な値も問題なく受け渡せます。
class NotSendable {
nonisolated(nonsending)
func performAsync() async { ... }
}
actor MyActor {
let x: NotSendable
func call() async {
await x.performAsync() // OK(selfアクター上でそのまま動く)
}
}
実装的には、暗黙のオプショナルな actor パラメータを関数が受け取る形になり、その actor のエグゼキュータ上で動作します。
@concurrent
常にアクターから外れ、汎用エグゼキュータ上で並行に動くことを明示する書き方です。これが従来(SE-0338)の nonisolated async のデフォルト挙動に対応します。
class NotSendable {}
@concurrent
func alwaysSwitch(ns: NotSendable) async { ... }
actor MyActor {
let ns: NotSendable = .init()
func call() async {
await alwaysSwitch(ns: ns) // エラー(isolation boundaryを越えるのでSendableでない値を渡せない)
let disconnected = NotSendable()
await alwaysSwitch(ns: disconnected) // OK(disconnectedな領域の値なら送れる)
}
}
@concurrent は nonisolated を含意します。グローバルアクターや isolated パラメータ、@isolated(any) との併用はエラーです。同期関数には付けられません(将来的に解禁される可能性はあります)。
デフォルトの切り替え
- upcoming feature flag 無効 のとき:
nonisolated asyncのデフォルトは@concurrent(= SE-0338 の挙動) NonisolatedNonsendingByDefaultを 有効 にすると:nonisolated asyncのデフォルトがnonisolated(nonsending)になる
したがって、最終的には (nonsending) を明示的に書く必要は基本的になくなります。@concurrent は「明示的にアクターから離れたい」ときに使う、より厳しいデータ競合要件を伴う注釈として残ります。
#isolation の扱い
nonisolated(nonsending) な async 関数の内部で #isolation を使うと、暗黙に渡されている呼び出し元の actor に展開されます。これにより、デフォルト引数 isolation: isolated (any Actor)? = #isolation を受け取る API に透過的に受け渡せます。
class NotSendable { ... }
func explicitIsolationInheritance(
ns: NotSendable,
isolation: isolated (any Actor)? = #isolation
) async { ... }
nonisolated(nonsending)
func printIsolation(ns: NotSendable) async {
await explicitIsolationInheritance(ns: ns) // OK
}
一方、@concurrent 関数内の #isolation は nil に展開されます。同期の nonisolated 関数内でも従来通り nil です。
Task の isolation inheritance
nonisolated な関数(sync、nonisolated(nonsending)、@concurrent のいずれ)内で生成される非構造化タスク(Task { ... })は、明示しない限り呼び出し元のアクターを継承しません。これは同期 nonisolated 関数と揃った挙動で、意図しないnon-Sendable 値のキャプチャを防ぎます。
class NotSendable { var value = 0 }
nonisolated(nonsending)
func createTask(ns: NotSendable) async {
Task {
// この Task は createTask とは別のアクター上で動く
ns.value += 1 // エラー
}
}
関数変換
関数型の変換もisolation boundaryをまたぐかどうかで扱いが変わります。同期 nonisolated と nonisolated(nonsending) は変換規則上も同じ扱いです。isolation boundaryを越える変換(例: actor-isolated → @concurrent)は Sendable な引数・戻り値型を要求し、変換先は async でなければなりません。
一方、nonisolated(nonsending) から actor-isolated な関数型への変換は境界をまたがないため、non-Sendable な値を含んでいても安全に変換できます。
class NotSendable {}
nonisolated(nonsending)
func performAsync(_ ns: NotSendable) async { ... }
@MainActor
func convert(ns: NotSendable) async {
// 'performAsync' は呼び出し時にmain actor上で動くのでOK
let runOnMain: @MainActor (NotSendable) async -> Void = performAsync
await runOnMain(ns)
}
エグゼキュータの切り替え
@concurrent関数: 汎用エグゼキュータへ切り替えて実行nonisolated(nonsending)関数: 呼び出し元から渡される暗黙の actor パラメータのエグゼキュータ上で実行
多くの呼び出しでは、すでにそのエグゼキュータ上で動いているため、関数開始時の切り替えは実質 no-op になります。
Objective-C からインポートされた async 関数
SE-0297 のインポート規則でコンプリーションハンドラから async 関数として自動生成されるものは、upcoming feature flag の有無にかかわらず nonisolated(nonsending) としてインポートされます。元々コンプリーションハンドラは呼び出し元のコンテキストで呼ばれるのが普通であり、この扱いのほうが実態に即しているためです。これにより、メインアクターから Objective-C の async メソッドを呼ぶ場面で発生していた大量の偽陽性が解消されます。
動的な actor isolation API
nonisolated な async 関数も実行時には特定のアクター上で動きうるようになったため、assertIsolated / assumeIsolated / preconditionIsolated(Actor と MainActor のもの)の noasync 制限が解除され、async コンテキストから使えるようになります。
この提案全体によって、nonisolated async 関数は sync 版と同じく「呼び出し元のアクター上で動く」のが基本となり、必要なときだけ @concurrent で明示的に並行実行を選ぶ、という直感的なモデルに整理されます。