はじめに
nonisolatedな非同期関数はSwift 5.7から実行されるスレッドが変わっており、Swift 6.2でUpcoming Feature Flagにより任意でそれが変更される実装をされています。
この記事では説明のため、その前提や経緯について書いています。
間違い等あったら編集リクエストにお願いします。
前提
この文章を読む上での前提も書いておきます。
環境
- この資料を作成している2025.5.14現在の環境は次のようなバージョンとなっています
- Xcode 16.2.xはSwift 6.0.3
- Xcode 16.3.xはSwift 6.1.0
- Xcode 16.4.xはSwift 6.1.2
- 時期的に、おそらく次のXcode 17はSwift 6.2なのでしょう
- 後述するSE-0461などはSwift 6.2
nonisolatedな関数とは
- actor境界を指定されていない関数です
- nonisolatedな関数は2つであり今回の話の中心です
- nonisolated同期関数
- nonisolated非同期関数
- nonisolatedな関数は2つであり今回の話の中心です
この文書でのことばの定義
- Actorのコンテキスト
- Actor境界内という概念の表現に使ってます
- Actorコンテキストには1つのExecutorがあります
- Actor境界内という概念の表現に使ってます
- Executor
- ActorコンテキストないでTaskを実行するスレッドを抽象化しています
- ただし、スレッドプールから利用可能なスレッドを選択するため、同一のExecutorであっても同一のスレッドではない場合もあります
- MainActorは特別なのでMainActorコンテキストのExecutorは常にメインスレッドを利用します
- ActorコンテキストないでTaskを実行するスレッドを抽象化しています
- Actorコンテキストの引き継ぎ
- Actorコンテキストを引き継ぐと、引き継ぎ先はそのコンテキストで使われたExecutorを引き継ぎます
- ActorのコンテキストがMainActorなら
- Executorは常にメインスレッドを使うため引き継ぎ先の実行はメインスレッドとなります
- Actorのコンテキストがカスタムなものじゃないなら
- 先述の通り、Executorは特定のスレッドを固定していないため別のスレッドが使われることもあります
- ActorのコンテキストがMainActorなら
- Actorコンテキストを引き継ぐと、引き継ぎ先はそのコンテキストで使われたExecutorを引き継ぎます
- nonisolatedな関数
- 条件
- 直接globalActor指定がされておらず、型からもglobalActorを引き継いでない関数
- 条件
nonisolatedな関数はActorコンテキストの隔離をされていません。しかし、Swiftのバージョンによっては、Executorを引き継いだり、引き継がなかったりします。つまり、Actorコンテキストの引き継ぎと、Executorの引き継ぎは別物と考えた方がよく、話を単純化すると呼び出しスレッドのまま実行されたりされなかったり、という話をしたいというのが主旨です。
nonisolated非同期関数の挙動の変化
Swiftのバージョンで変わるという説明のため、時系列にしてみます。
タイムライン
時系列では次のように大きく3つのポイントがあります。
- Swift Concurrency登場
- nonisolated非同期関数の挙動
- nonisolated非同期関数が呼び出し元のスレッドで実行される
- nonisolated非同期関数の挙動
-
SE-0338: Swift 5.7以降
- nonisolated非同期関数の挙動
- nonisolated非同期関数が呼び出し元のスレッドで実行されない
- Actor分離されていない非同期関数は汎用Executorで実行される
-
async functions that are not actor-isolated should formally run on a generic executor associated with no actor.
-
- Actor分離されていない非同期関数は汎用Executorで実行される
- nonisolated非同期関数が呼び出し元のスレッドで実行されない
- 参考
- nonisolated非同期関数の挙動
-
SE-0461: Swift 6.2以降
- nonisolated非同期関数の挙動
- nonisolated非同期関数が呼び出し元のスレッドで実行される
- Swift Concurrency登場時と同様になる
- nonisolated非同期関数が呼び出し元のスレッドで実行される
- nonisolated非同期関数の挙動
- Swift 6.3以降のどこか
- 上記のSE-0461がデフォルトな挙動となる
この挙動の変更は、さまざまな事情が絡み合っていて説明が少しだけ難しいです。しかし、nonisolated同期関数は変化しておらず、これをまず理解することでnonisolated非同期関数が理解しやすいはずです。
nonisolated同期関数
同期関数の特徴は同期的に関数を呼び出されなければいけないため、その制約によりルールがシンプルです。
- 呼び出し元のスレッドにより同期関数は実行される
- 関数内でTask.initする場合どうなる?
- 同期関数内でTask.initすると非同期実行される
- 関数内部はnonisolatedでActorコンテキストを引き継がないので関数内のスレッドとは違うスレッドとなる
MainActorで呼び出す例がわかりやすいはずです。次に図にします。
図
(図の緑や赤っぽい背景に囲まれた四角が実行スレッドの違いを表現しています)
MainActorからnonisolated同期関数を呼び出す場合、呼び出される対象が同期関数なので、スレッドが切り替わらず呼び出し元のMainActorのExecutorが指定するメインスレッドで動作します。実質的にExecutorが引き継がれています。
呼び出された関数内部はnonisolatedな関数なので呼び出し元のMainActorのコンテキストを引き継ぎません。重要なので繰り返し言い方を変えます。スレッドは呼び出し元と同様メインスレッドとなりますが、Actorコンテキストは引き継いでいるわけではありません。
このnonisolated同期関数内でコンテキストを引き継いでない状態でTask {}
を実行すると、Taskは呼び出された同期関数からコンテキストを引き継ぎをしようとしますが、nonisolatedであり引き継ぐものがありません。言い方を変えると、nonisolatedなのでアクターは隔離されておらず、コンテキストがありません。そのためTaskのクロージャの中は非同期実行のために別のスレッドとなります。
この挙動でとりあえず、同期関数の制約とTaskの性質をおさらいできたはずです。
サンプルコードをOnline Playgroundに置いておきます。
nonisolated非同期関数
Swift Concurrency登場時
Swift Concurrency搭乗時の非同期関数についておさらいします
- 呼び出し元のスレッドとおなじスレッドで実行される
- おそらく仕様として明示されてるというよりも当時は暗黙的にそうなっていた(?)
- 同期関数と同じような動作感ではある
- おそらく仕様として明示されてるというよりも当時は暗黙的にそうなっていた(?)
- 関数内でTask.initする場合どうなる?
- 関数内部はnonisolatedでActorコンテキストを引き継がないので関数内のスレッドとは違うスレッドとなる
これもMainActorから呼び出された例の図にします。
図
これも説明のため呼び出し側はMainActorとします。
呼び出される関数はnonisolated非同期関数なため、もちろんActorコンテキストを引き継いでいません。
先ほど説明したようにTask {}
は別スレッドで動作します。
当時、これはこれで別の課題があり、その課題については説明しませんがSE-0338が出てその挙動がSwift 5.7から変わりました。
SE-0338: Swift 5.7以降
図
これも説明のため呼び出し側はMainActorとします。
呼び出されるnonisolated非同期の関数は呼び出しのスレッドとは別のスレッドで実行されるようになりました。言い換えるとExecutorの引き継ぎをやめました。
もちろん、nonisolatedなためActorコンテキストも引き継ぎされていません。もちろんそのため内部でTask {}
するとこれも別スレッドとなります。プロポーザルでは汎用のExecutorを使うと言う表現がされているはずです。
サンプルコードは次のようにしています。
Swift 5.7からトップレベルでawaitができるようになったため、簡単にサンプルコードが試せるはずです。
しかし、Swiftキッズな私としてはまず同期関数を使って挙動を知り、そこから非同期関数を動かしたりします。nonisolated非同期関数の挙動は驚きです。
SE-0461: Swift 6.2以降
世の中のSwiftキッズたちもSE-0338むずいよねと、そのためSE-0461ではSwift Concurrency登場時のように、呼び出し側のExecutorを引き継ぐようになり、例えば呼び出しスレッドがMainActorならExecutorはメインスレッドなので、メインスレッドで実行されるようになりました。
このように変化する理由は、おそらくSwift 5.7(SE-0338)のnonisolatedな非同期関数は同期関数との違いがありすぎるからだと思います。同期関数は制約上、呼び出し時にスレッドを切り替えることもできないため、そうなるべくしてそうなってもいますが、それが非同期関数になった途端変わるのは意表を突かれました。まあ他にも理由はあるでしょうが、一貫性がないと驚きますね。
変更はUpcoming Feature Flagで有効になる
ということで、もしSwift 6.2にすると非同期関数のスレッドが元に戻るなら、さらに驚いてしまうのはわかりきってます。
そのため、我々開発者自身がUpcoming Feature Flagで NonisolatedNonsendingByDefault
を指定することにより挙動を変更できるようになっています。将来的にはSwift 6.3以降のどこかでデフォルトで変わるとしても、まあとりあえずはSwift 6.2でいきなり変わるのではありません。
Task.initと.detachedとの違いはTaskのLocalValueを引き継ぐかどうか
ここまで読むと、じゃあTask.detached { }
との違いはなんだってばよ、とあなたは思うかもしれません。なぜならTask {}
はスレッドを切り替えてますからね。detachedとの最も大きな違いはTaskのLocalValueを引き継ぐか引き継がないかが大きいはずです(他にも優先度も親の構造から引き継がないはずです)。
そうなると、Task.detached {}
は本当に使い勝手が難しいです。なぜなら、LocalValueを使わないことを宣言してしまうと、そこで構造化が切れてしまい、その下の層にLocalValueを使えなくってしますからです。雑に言うと、LocalValueでDIする手段を潰してしまいます。LocalValueによるDIは並列で動作するテストコードにおいて、そのTask専用の情報を与える手段の1つなため、それを潰すのはかなり勿体無いはずです。
正直、Task.deatchedをどんな場面で利用したいかがよくわからないので使わないというガイドを決め、どうしても使いたい場面が出るまでは忘れた方がいい気がします。
下記はTaskのLocalValue使う例です。
他にもTask.deatched使いたくないよねという記事がありました。
Executorについて補足
上記のSwiftバージョン別のnonisolatedな関数呼び出し側はMainActorを例を使いました。MainActorのコンテキストはExecutorがメインスレッドを利用します。つまり、特定のスレッドを使うことと直列であることが固定されています。
しかしMainActorでなくカスタムされていないActorのExecutorは、効率のためにスレッドプールから利用可能なスレッドを選びます。
まとめ
- 同期関数から
Task {}
を呼び出すともちろん別スレッドを使う -
Task {}
はActorコンテキストを引き継ぐが、引き継ぎができなければ別スレッドを使う - Actorコンテキストを引き継ぐ話と、Executorを引き継ぐ話は別々に考えた方が良さそう
-
Task.deatched {}
は使い所がよくわからん - nonisolated強烈〜
おわりに
Swift Concurrencyではnonisolatedという存在が強烈だという感じがしませんか?これは関数にglobalActor指定をし忘れた際、このnonisolatedとなるというのも現状の仕様なわけです。しかし、それもまた課題として認識されており改善されるのがSwiftでしょう。Swift 6.2では他にもいくつもプロポーザルが実装されており、それはまた別に書くくかもしれまし、書かないかもしれません。
ご指摘など