Swift 6.2 (Xcode 26) で nonisolated な async 関数の振る舞いが変わる

概要

以前に [swift] メインスレッドから処理を逃すために Task.detached を使う必要はない(ことが多い) - zenn という記事で「async 関数はあえて main actor に isolate しない限り必ずバックグラウンドで実行される」ことについて書きました。より一般的には「nonisolated な async 関数は actor から呼び出されたとしても actor 外では実行される」ということです。これが Swift 6.2 で以下のように変わります。

  • nonisolated な async 関数に nonisolated(nonsending) をつけることで、その関数は呼び出し元の actor でそのまま実行される
  • さらに upcoming feature の NonisolatedNonsendingByDefault を有効にすることで、 nonisolated な async 関数が呼び出し元の actor でそのまま実行される振る舞いがデフォルトになる

この仕様変更について、関連する以下のリンクを参考にまとめます。

Swift 6.1 以前の振る舞い

Swift 6.1 以前では、 nonisolated な async 関数は必ず actor 外で実行される仕様になっていました。まずこれがどういうことかを、 main actor である SwiftUI の View を例に整理してみます。以下の View を表示することを考えます。

func printIsMainThread(label: String) {
    print("executing \(label) on main thread: \(Thread.isMainThread)")
}

func nonisolatedAsync() async {
    printIsMainThread(label: "nonisolatedAsync")
}

struct SampleView: View {
    var body: some View {
        Text("Hello World!")
            .task {
                printIsMainThread(label: "task")
                await nonisolatedAsync()
            }
    }
}

Swift 6.1 以下の環境では必ず以下の出力が得られるはずです。

executing task on main thread: true
executing nonisolatedAsync on main thread: false
  • task に渡すクロージャはメインスレッドで
  • そこから呼び出される nonisolatedAsync はバックグラウンドスレッドで

実行されていることがわかります。これがなぜかを考えます。

SampleView は SwiftUI の View であるため main actor に隔離されています。そのフィールドである body も main actor であり、その中に定義される task に渡すクロージャも main actor になるので、メインスレッドで実行されることになります。じゃあ main actor から呼び出される nonisolatedAsync もそのままメインスレッドで実行されるかというと、そうは問屋(誰?)がおろしません。

nonisolatedAsync は nonisolated な async 関数です。 SE-0338 Clarify the Execution of Non-Actor-Isolated Async Functions の通り、Swift 6.1 以前では nonisolated な async 関数は default concurrent executor によって必ず actor の外で実行されることになっています。 main actor から呼び出されたとしても、呼び出しの直前に実行スレッドが切り替わって main actor の外、すなわちバックグラウンドスレッドで実行されることになります。

この仕様の主なモチベーションは、重い計算を actor 上で実行してしまうことで、その actor が他タスクを処理できずブロックされることを防ぐことです。今回の例でいうと、仮に nonisolatedAsync にめちゃくちゃ重い計算が含まれていたとき、それを main actor から呼び出されたからといって main actor で実行してしまうと、その処理が終わるまで main actor が占有されるため UI が固まってしまう懸念が考えられる、ということです。呼び出し元に関わらず actor 外で実行する仕様にしておくことで、そういう事態が起こらないようにしているわけです。

Swift 6.1 以前の振る舞いの問題点

Swift 6.1 以前の nonisolated async 関数は、常に actor 外で実行されるという点で一貫性はありましたが、実用上は困ることも多かったようです。とくに大きな困りとしては、

  • 1: nonisolated な sync 関数と async 関数で振る舞いが異なるのがわかりづらい
  • 2: actor から nonisolated な async 関数を読んだ時に引数が Sendable であることが要求されるのがやっかい

あたりが挙げられると思います。

isolated / nonisolated というのは async 関数に限った概念ではありません。 isolated / nonisolated な sync 関数というのももちろん存在します。 nonisolated な sync 関数は、 async 関数とは反対に actor から呼び出された場合にその actor 上でそのまま実行されます。先ほどの SampleViewonAppear で nonisolated な sync 関数を呼び出すことでこれを確認してみます。

func printIsMainThread(label: String) {
    print("executing \(label) on main thread: \(Thread.isMainThread)")
}

+ func nonisolatedSync() {
+     printIsMainThread(label: "nonisolatedSync")
+ }

func nonisolatedAsync() async {
    printIsMainThread(label: "nonisolatedAsync")
}

struct SampleView: View {
    var body: some View {
        Text("Hello World!")
+           .onAppear {
+               printIsMainThread(label: "onAppear")
+               nonisolatedSync()
+           }
            .task {
                printIsMainThread(label: "task")
                await nonisolatedAsync()
            }
    }
}
+ executing onAppear on main thread: true
+ executing nonisolatedSync on main thread: true
  executing task on main thread: true
  executing nonisolatedAsync on main thread: false

onAppear に渡すクロージャも、 task の場合と同様 main actor 上で実行されます。そして、そこから nonisolatedSync 関数が呼び出されますが、こちらは sync 関数なのでそのまま main actor で実行されていることがわかります。

このように、 nonisolated な sync 関数と async 関数は actor から呼び出された場合にそのままその actor 上で実行されるかが異なっていて、これがわかりづらいとされています。

これに付随して、 Sendable でないオブジェクトの受け渡しについても sync 関数と async 関数で振る舞いが異なります。 actor から nonisolated な async 関数を呼び出した時に actor 外で実行されるということは、その呼び出しが actor 境界を跨ぐということです。 actor 境界を跨ぐオブジェクトは Sendable である必要があります。なぜなら、

  • actor を跨いだオブジェクトは複数のスレッドから同時に触られる可能性がある
  • Sendable でないオブジェクトは複数のスレッドから同時に触られたときにデータ競合を起こす可能性がある

ためです。

以下の incrementAsync の呼び出しには Swift 5 モード+ Strict Concurrency Checking が Complete なら警告が、 Swift 6 モードならエラーが出ます。

class NonSendableCounter {
    var count: Int = 0
}

func incrementSync(counter: NonSendableCounter) {
    counter.count += 1
}

func incrementAsync(counter: NonSendableCounter) async {
    counter.count += 1
}

struct SampleView: View {
    let counter: NonSendableCounter
    
    var body: some View {
        Text("Hello World!")
            .onAppear {
                // ✅ OK
                incrementSync(counter: counter)
            }
            .task {
                // ❗️ Sending main actor-isolated 'self.counter' to nonisolated global function 'incrementAsync(counter:)' risks causing data races between nonisolated and main actor-isolated uses
                await incrementAsync(counter: counter)
            }
    }
}

Sendable でない NonSendableCounter が nonisolated な async 関数に渡されることでコンパイラに怒られてしまいます。

一方で、 incrementSync は同じ nonisolated な関数なのに、 NonSendableCounter を渡しても問題になりません。これは先ほど言ったように nonislated な sync 関数は呼び出し元の actor でそのまま実行されるので actor を跨がない、よって Sendable でないオブジェクトを渡しても問題ないためです。

この違いは、単にわかりづらいだけではなく、 nonisolated な async 関数ではエラーが出るという実際上の問題になってしまっています。日常的に Swift を書いている人であればエラーの意味を理解して改善策・回避策を取れるでしょうが、そうでない人にとっては理解も対応も難しそうです。

Swift 6.2 で入る変更

ここまで述べてきた問題点をなんとかするために、Xcode 26 に同梱される Swift 6.2 にて、関数に nonisolated(nonsending) とつけることで、呼び出し元の actor でそのまま実行されるようにできるようになりました。つまり、以下の incrementAsync を main actor から呼び出せば main actor 上で実行されます。 nonisolated な sync 関数と同じ振る舞いになるということです。

nonisolated(nonsending) func incrementAsync(counter: NonSendableCounter) async {
    counter.count += 1
}

main actor からの呼び出し時に actor 境界を跨がなくなるので、 Sendable でないオブジェクトの受け渡しも可能です。先ほどの SampleViewincrementAsync にもエラーが出なくなります。値を send しなくてよくなるという意味合いで nonisolated(nonsending) という命名になっています。

nonisolated(nonsending) func incrementAsync(counter: NonSendableCounter) async { 
    counter.count += 1
}

struct SampleView: View {
    let counter: NonSendableCounter
    
    var body: some View {
        Text("Hello World!")
            // ...
            .task {
                // ✅ OK
                await incrementAsync(counter: counter)
            }
    }
}

ただ、 async 関数を actor 上で実行するために nonisolated(nonsending) をつけていくというのは Swift 6.2 の変更の本線ではありません。より重要なのは、 upcoming feature flag の NonisolatedNonsendingByDefault を有効にすることで、 nonisolated(nonsending) の振る舞いがデフォルトになることです。いちいちコード上で指定せずとも、 Xcode Project なら Build Settings の nonisolated(nonsending) By DefaultYes に、 Swift Package なら swiftSettings.enableUpcomingFeature("NonisolatedNonsendingByDefault") を指定することでそのモジュールの中の nonisolated な async 関数はデフォルトで nonisolated(nonsending) になります。

この feature flag を有効化すると、 nonisolated な async 関数はすべて呼び出し元の actor で呼ばれることになります。つまり、 main actor から呼び出された場合は main actor で実行されることになるので、重い処理やバックグラウンドで呼ばれる前提の処理など、これまで通り default concurrent executor で実行してほしい場合もあるかもしれません。これは @concurrent というアトリビュートをつけることで実現できます。

@concurrent func incrementAsync(counter: NonSendableCounter) async { 
    // 呼び出し元の actor を引き継がずにバックグラウンドで実行される
}

変更をどのように受け入れるべきか

NonisolatedNonsendingByDefault flag は有効化したからといってすべてがよくなって最高という種類のものではないし、そもそも Xcode 26 にしか存在しないので、慌てて有効化するようなものではない認識です。ただ、有効化することで actor から nonisolated な async 関数を呼ぶときの Sendable の制約がなくなり、基本的には Concurrency 周りのエラーは減ると思うので、 Swift 6 対応 / Swift Concurrency 対応の一環としてよきタイミングで有効化するとよさそうです。

別の観点ですが、この変更は Swift の今後の方向性を考える上でも知っておくと興味深いと思います。 nonisolated な async 関数は、 SE-0338 にて actor 外で実行されるようになったのですが、その主な理由として actor を無駄に占有することで actor のタスクが詰まってしまうのを避けたいというものが挙げられています。いわばパフォーマンス上の理由です。

これが今回 SE-0461 にてパフォーマンスよりも理解のしやすさや Concurrency のエラーを減らすことを優先して仕様変更された流れになります。同じような方向性の変更に SE-0466 Control default actor isolation inference があります。これはモジュール内のすべてのコードを main actor にできるようにしてしまうというもので、これにより Concurrency のエラーも減るし、いちいち @MainActorアノテーションを書く手間も減ります。

このように、 Swift は Concurrency を「簡単で、使いやすく」しようとしているように思えます。この方向性については Improving the approachability of data-race safetyVision ドキュメントに詳しく書いてあり、なかなかおもしろいです。