Swift Concurrencyのキャンセル処理を実装したい我
これをみる
Strictured concurrency
全てのタスクが構造化するわけではない。
構造化タスクはasync let
とタスクグループを使って作成され、
非構造化タスクはTask
及びTask.detached
を使い作成される。
構造化タスクはローカル変数のように宣言されたスコープの終わりまで生存し、
スコープを抜けると自動的にキャンセルされるためタスク寿命が明確になる。
可能な限り構造化タスクを使うことをお勧めする。
関数に並行性を追加するため非構造化タスクを作成し
必要に応じてそれらの値を待機する場合がある。
これは推奨されるSwiftの並行処理の利用方法ではない。
同じ関数を構造化並行処理を使えば次のようになる。
既知の数の子タスクを作成するため、便利なasync let
構文を使用できる。
これらのタスクは親タスクと構造的な関係を形成する。
この親子の階層はタスクツリーと呼ばれる。
Task Cancellation
タスクキャンセルはアプリがそのタスクの結果を不要とし
タスクが停止して部分的な結果を返すかエラーをスローすることを示すために使用される。
構造化タスクはスコープを超えると暗黙的にキャンセルされるが、
タスクグループはcancelAllを呼び出すことでアクティブな子タスクを全てキャンセルできる。
非構造化のタスクはcancel関数を使って明示的にキャンセルされる。
親タスクをキャンセルすると属する全ての子タスクもキャンセルされます。
子タスクは即時に停止されるわけではない。
単にタスクのisCancelledフラグが設定されるだけ。
実際のキャンセル処理はコード内で行われる。
もしチェック前にキャンセルするとmakeSoupはエラーをスローする。
guardが実行された後にタスクがキャンセルされたら
プログラムはスープの準備を続ける。
部分的なキャンセルエラーをスローする代わりにTask.checkCancellation
を呼び出すことで、
タスクがキャンセルされた場合CancellationError
をスローできる。
高コストな作業を開始する前にタスクのキャンセルのステータスをチェックすることが重要。
→Task.cancel()実行後は、makeSoupが返す値と、その子タスクが返す値から結果が判断される。
isCancelledで、trueの場合に特定の値を返して、エラーにしないことも、checkCancellation()を実行して、エラーにすることもできる。
実際に構造化タスクで実装してわかったこと
・子タスクでask.sleep(nanoseconds:)
を使うとデフォルトでキャンセルされた場合にCancellationError
を返してしまうので、エラー処理のテストがしたい場合は、Task.sleep(3 * NSEC_PER_SEC)
を使うと良い
task = Task {
// Taskがキャンセルされるとすぐに停止する
try await Task.sleep(nanoseconds: 3 * NSEC_PER_SEC)
}
task = Task {
// Taskがキャンセルされても停止しない
await Task.sleep(3 * NSEC_PER_SEC)
}
→子タスク側で実装しなくてもエラーをスローしてくれて??
となったので、注意!
・try Task.checkCancellation()
は、ずっと監視してくれるわけではなく、その時にキャンセルが来てないとスローしてくれないので、何度か呼ぶ必要がある
task = Task {
// すでにキャンセルされていたら処理を停止
try Task.checkCancellation()
// CancellationErrorスローしない処理
await Task.sleep(3 * NSEC_PER_SEC)
// すでにキャンセルされていたら処理を停止
try Task.checkCancellation()
}
・既に実行された非同期処理は途中で止めることができないっぽい。なので、重い処理を行う前にキャンセルが行われているかを見る感じで使うっぽい。
以下参考にするとわかりやすい。
・子タスクへの伝搬が嫌な場合は、以下のように非構造化タスクを使えば良さそう。
以下は、ChatGPTでもう少し調べて見た内容
someRequest()
がキャンセルされてもエラーをスローせず、特定の値(例えば 0
)を返すように実装した場合、catch
ブロックで CancellationError
をキャッチすることはありません。
例えば、以下のように someRequest()
を実装した場合を考えます:
func someRequest() async -> Int {
// Simulate a long-running task
for _ in 1...10 {
try? await Task.sleep(nanoseconds: 1_000_000_000)
if Task.isCancelled {
// Task was cancelled, return a default value
return 0
}
}
return 42 // Return some result if not cancelled
}
そして、この関数を Task
内で呼び出す場合:
let task = Task {
do {
let result = await someRequest()
print("Result: \(result)")
} catch {
// This block will not be executed if someRequest() does not throw an error
print("An error occurred: \(error)")
}
}
// Cancel the task
task.cancel()
この場合、task.cancel()
を呼び出しても someRequest()
はエラーをスローしません。代わりに、Task.isCancelled
をチェックし、キャンセルされていたら 0
を返します。そのため、catch
ブロックは実行されず、キャンセルされても Result: 0
が出力されます。
まとめると、someRequest()
がキャンセルされた際にスローを投げず、代わりに特定の値を返すように実装されている場合、catch
ブロックは実行されません。その場合の処理はキャンセルされたときに返す値に依存します。
Taskをキャンセルするために保持したとして、都度cancel()を実行するのは面倒くさいので、
Taskの上書き時と画面破棄時にキャンセルしてくれる仕組みがいい。
やりたいことと近そうなので読んでみる。
combine AnyCancellableを使用したタスクキャンセル
AnyCancellableには、参照がなくなるとすぐにキャンセル関数を呼び出して自動的にキャンセルされるという利点がある。これを利用するには、Taskに簡単な拡張機能を作成する。
import Combine
extension Task {
func eraseToAnyCancellable() -> AnyCancellable {
AnyCancellable(cancel)
}
}
class FoodViewModel: ObservableObject {
...
private var cancellable: AnyCancellable?
func displayVegetable(id: String) {
cancellable = Task { @MainActor [weak self, networkService] in
...
}.eraseToAnyCancellable()
}
...
}
複数のリクエストがある場合には、(同時実行でない場合)
同じ参照を使用でき、前のタスクをキャンセルする必要はない
→AynCancellableがなくなるとき、自動的にキャンセルされるから
class FoodViewModel: ObservableObject {
...
private var cancellable: AnyCancellable?
// Canceled automatically if a fruit is requested
func displayVegetable(id: String) {
cancellable = Task { @MainActor [weak self, networkService] in
...
}.eraseToAnyCancellable()
}
// Canceled automatically if a vegetable is requested
func displayFruit(id: String) {
cancellable = Task { @MainActor [weak self, networkService] in
...
}.eraseToAnyCancellable()
}
...
}
マルチの場合の実装も記載があるので参考にする!
以下のようにAnyCancellableで実装したのだが、
Task内のクロージャで強参照していたみたいで、ViewControllerが無くならず、deinitも実行されないので、cancel処理が走らないということがあった。
走ったとしても、実行中の非同期処理が全て完了後に走るので、意味がなかった。
Taskが上書きされた時はdeinitが走るのでcancelされてよかった。
extension Task {
func eraseToAnyCancellable() -> AnyCancellable {
AnyCancellable(cancel)
}
}
画面非表示のタイミングで、Task.cancel()を走らせる必要がありそう。
このプロパティがdeinitされたらーという実装にしてしまうと、無くならないことがほとんどだと思うので、ずっとself
を持ち続けることがありそうだなと。
今回の対応
・combineをimportするのは、大きいなと思ったので、新しくTaskCancellable用のクラスを作って、
AnyCancellableと同様の動きをさせるようにした。
・Taskの上書きと、画面表示後のcancelを実行させたかったので、viewDidDisappear()
でも、cancel処理を動かすよう実装した。以下の対応でも大丈夫かも。
・重いタスクでないと恩恵を受けない気がしたので、キャンセル処理の実装するかしないかを一度検討してみても良いのかも、、 SwiftUIでの実装は.task{}
の中に書けばView破棄時にキャンセルしてくれるみたいなので羨ましい、、