カルボナーラ街道

計測と観察

Swift Concurrency メインスレッドクイズ(3) 1問

実行環境

> swift --version
swift-driver version: 1.90.11.1 Apple Swift version 5.10 (swiftlang-5.10.0.13 clang-1500.3.9.4)
Target: arm64-apple-macosx14.0

> xcodebuild -version
Xcode 15.4
Build version 15F31d

モチベーション

コードを書いていたらメインスレッドがブロックされた挙動になったのでメモとして残す。このブログで書いてきたSwift Concurrency話題の記事を全て読んでいたら原因と対策が全て分かるだろう。復習としてどうぞ。

Question

gist.github.com

答えと解説

答え

true

解説

Detached Taskを使っているからactor contextは引き継がれないので非メインスレッドでしょ、と思いきやメインスレッドで実行される。

8行目の await self.heavyCall() の呼び出しは非メインスレッドで行われるが、heavyCall() の中身の処理はメインスレッドで実行される。heavyCall() はUIViewControllerのサブクラスが持つメソッドで、UIViewControllerはMainActorにisolatedされるためメソッドもMainActorにisolatedされる。処理順とスレッドの対応は以下。

  1. ViewController viewDidLoad(): MainActorにisolatedされているためメインスレッド
  2. ViewController viewDidLoad() Task.detached(): Detached Taskはactor contextを引き継がないため非メインスレッド
  3. ViewController heavyCall(): メソッド自体はMainActorのexecutor上で実行されるためメインスレッド

await キーワードはsuspension point

await というキーワードはただ単に非同期処理が行われるんだな、程度の理解だったけど、具体的には実行中のスレッドを放棄・譲ることを示すsuspension pointである。suspension pointであることを知っていれば、実際の処理がどのスレッドで呼ばれるかは呼び出し先の定義に左右されるということがイメージできる。await キーワードが使われる箇所自体は非同期処理が実行されるスレッドを指定しない(できない)。

developer.apple.com

forums.swift.org

メインスレッドをブロックしないためには

今回のコードは重い処理がメインスレッドで実行されるため、heavyCall() が終わるまでメインスレッドをブロックしてしまう。メインスレッド上で処理が実行されっぱなしだとUIが固まってしまう。UIが固まるということはユーザーの操作がアプリ上で不可能になってしまうためよろしくない。対応としては以下の3つがある。

1. 処理をnonisolatedにする

メソッドに nonisolated を付与してMainActorのisolatedを外す。

gist.github.com

2. 処理を他に移す

gist.github.com

パッと見、L18-19では非同期処理が無いのでDetached Taskは必要ないのでは?と思うが、heavyCall() は同期メソッドのため呼び出し元のスレッド上で実行される。つまり、Detached Taskに渡さなければメインスレッドで実行されてしまうのでやや注意。

3. Task.yield() を使ってスレッドを譲る

gist.github.com

今回のケースだと2が一番良いんじゃないかな。重い処理でメインスレッドに関係ない処理はMain Actorにisolatedされていないクラスや構造体で行えば良い。3は他と比べて処理に時間がかかった。スレッドを譲る処理はオーバーヘッドが結構ありそう、ということが分かって良かった。

  • ループの回数を10の6乗に変更した時のそれぞれにかかった時間
    • 1: 0.9秒
    • 2: 0.8秒
    • 3: 8秒

参考

Swift concurrency: Behind the scenes(WWDC21)を見ることでTask.yield()を使う必要性を理解できた

要約

今まで Task.yield() を積極的に使わなくても特に問題は無いと思っていたけど、以下の考えに落ち着いて積極的に使おうという考えに変わった。

  • Swift ConcurrencyではCPUコアと同数のスレッドが作成される
  • タスクが完了するか、 Task.yield() を使ってスレッドを明け渡さないと、優先度が高いタスクを即座に実行できない可能性があるため、重い処理を扱うループ内では Task.yield() を呼ぶ

Task.yield()

developer.apple.com

実行中のタスクを一時停止するAPI。他に優先度が高いタスクがあればそちらが実行され、そうでなければ再開される。

いつ使うのかがピンと来ていない

使うタイミングがピンと来ていなかったので自分の考えを整理した。

メインスレッド

そもそもメインスレッドに重い処理を実行させると、iOSアプリ文脈においてはUIの応答性低下につながるため、重い処理は実行させたくない。複数の重いUI描画処理を実行させたい場合のループ毎に Task.yield() を呼ぶ程度。あまり出番は無い認識。

非メインスレッド

非メインスレッドにおいては Task.yield() を呼ぶ必要ないだろう。というのも、アプリケーションが実行される昨今の環境におけるリソースは潤沢だから。広義モバイルの文脈においては、Apple Watchですら2コアあり、iPhoneは6コア。複数のアプリケーションが端末上で動作しているとはいえ、アプリケーション開発者がスレッド周りのリソースを管理しなければならないほどリソースは枯渇しないだろう。

APIが用意されているということは理由があるのかも

自分の考えを書いたが、思惑無くしてAPIが用意されることはあるのだろうか、いやないと思い、仮説を立てて調べた。

  • リソース制約が厳しい環境でSwiftが使われていく説
  • 自分のSwift Concurrencyの理解が甘い説

リソース制約が厳しい環境でSwiftが使われていく説

最近では以下のような組み込み文脈でSwiftが使われる事例が出ている。リソースがiPhoneなどのモバイル端末よりも厳しい環境下での動作を見越してAPIが提供されている説を考えた。

www.swift.org

しかし、記事中のapple/swift-embedded-examplesでTaskをサーチしても引っかかからずだった。

https://github.com/search?q=repo%3Aapple%2Fswift-embedded-examples%20Task&type=code

examplesレベルでまだ実践的な内容が無いからなのか、そもそも非同期処理が組み込みでそれほど必要とされないのかの判断がつかなかった。

自分のSwift Concurrencyの理解が甘い説

最初に疑うはこちらだった。結論としてはこっち。Swift Concurrency関係のWWDCの動画やswift-evolutionを全て見ていないなと思って、ひたすら見ていくと、以下の動画を見たら自分の考えが固まった。

developer.apple.com

この動画によると、Swift Concurrency以前の非同期処理を提供するGCD(Grand Central Dispatch)では、CPUコア数よりも多い数のスレッドを扱えること、スレッドが多すぎるとコンテキスト切り替えによるパフォーマンス低下が課題としてあった。一方でSwift ConcurrencyではCPUコア数と同数のスレッドのみの作成で非同期処理を実現している。CPUコア数分のスレッド上でタスクが実行されている際に、優先度が高いタスクに処理をさせるには実行中のタスクが完了するか、Task.yield() で譲る他ないため、積極的に Task.yield() を使っていこうと思えた。優先度が高いタスクが生まれたからといって勝手にスレッド上で実行するタスクを組み替えてくれるわけではないのだな。

初見ではスレッド周りの知識が無くて理解ができず、動画を最後まで見ることができなかった。対応として、以下の5章まで読んでプロセスやスレッドの理解を深めてから再度動画を見ると概ね言わんとしていることは理解できた。

gihyo.jp

また、以下の動画も考えを固める上で参考になった。

developer.apple.com

13:36あたりで、SwiftUI文脈でObservableObjectを使う際に @MainActor を付与すると、コンパイラがそのプロパティやメソッドがmain actorからのみアクセスされることを保証することが紹介されている。裏を返すと、@MainActor が付与されたクラスはデフォルトで処理がメインスレッド上で行われるため、メインスレッドで行わなくて良い処理は適宜別のactorにisolatedさせるなどをした方が良いなと思った。自分もよく使うパターンだったけど、重い処理をmain actorから逃すことまでの意識はそれほど無かった。成り行きに任せていた。

以上を踏まえた Task.yield() を使うタイミング

実装時に明にスレッドを明け渡す処理を書かないと、優先度の高いタスクが来たとしても他のタスクの実行状況によっては即座に実行できない場合があるため、重い処理を実行させる時はループ毎に Task.yield() を呼んでスレッドを明け渡す余地を作る。メインスレッドだろうが非メインスレッドだろうが関係なく。

参考

Swift Concurrency メインスレッドクイズ(2) 2問

実行環境

>swift --version
swift-driver version: 1.90.11.1 Apple Swift version 5.10 (swiftlang-5.10.0.13 clang-1500.3.9.4)
Target: arm64-apple-macosx14.0

>xcodebuild -version
Xcode 15.3
Build version 15E204a

モチベーション

まだまだSwift Concurrencyパワーが足りていない。読み、書き、動かし、クイズを作り、Swift Concurrencyを身体に馴染ませる。

過去のクイズは以下。

Question 1

gist.github.com

答えと解説

答え

false

解説

前提として、Detached Taskは実行元のactor contextを引き継がないので、ViewControllerに暗黙的に付与されているGlobal ActorであるMain Actorのexecutor上で y.f() は実行されない。

0316-global-actors.md に載っている例だが、説明が初見で分からなったので整理した。

プロポーザルに出てくるwitnessというのは特定のプロトコル準拠内でその要件を満たすために使用される定義のこと。プロポーザルを読むと、プロトコルのメソッド実装がそのプロトコルに適合する宣言と同じ型定義やextension内にある場合のみ、actor isolationの推論が有効になるようで、今回は YP の準拠と YP の要件実装が別のためactor isolationの推論が有効にならず、P のメソッド定義に付与されている @MainActorY で有効にならない。YP の準拠と Y の要件実装を同じ箇所で行うと推論が効くとのことなので試すと、trueになった。

gist.github.com

これまでの説明を踏まえてコードを振り返ると、y.f() の前にawaitキーワードを書くことを要求されなかったので、Main Actorにisolatedされていないことが分かる。

Question 2

gist.github.com

答えと解説

答え

true

解説

0316-global-actors.md にて、Global Actorが付与された wrappedValue を持つプロパティを含む構造体やクラスはそのProperty Wrapperからactor isolationの推論が行われるようになった。

@TwelveOrLess Property Wrapperの wrappedValue はMain Actorにisolatedされているため、それを持つ構造体 SmallRectangle はMain Actorにisolatedされる。SmallRectangle のメソッドもMain Actorにisolatedされるためメインスレッドで実行される。呼び出し時に await が必要になるのでヒントがあった。

なお、 0401-remove-property-wrapper-isolation にて、Swift 6 language modeではこの推論は行われなくなるためfalseになる。コンパイル時に -enable-upcoming-feature DisableOutwardActorInference を渡すとfalseになることを確認した。今回の実行環境であるSwift 5.10ではデフォルトでその機能は有効になっていないためtrueとなる。