Closed5

Swift Concurrencyのキャンセル処理を実装したい我

ほへとほへと

これをみる
https://developer.apple.com/videos/play/wwdc2023/10170/?time=272


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()
}

・既に実行された非同期処理は途中で止めることができないっぽい。なので、重い処理を行う前にキャンセルが行われているかを見る感じで使うっぽい。
以下参考にするとわかりやすい。
https://blog.personal-factory.com/2022/08/06/not-require-weak-self-in-task-init/

・子タスクへの伝搬が嫌な場合は、以下のように非構造化タスクを使えば良さそう。
https://zenn.dev/treastrain/articles/e83b2e30f804c5


以下は、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の上書き時と画面破棄時にキャンセルしてくれる仕組みがいい。

ほへとほへと

やりたいことと近そうなので読んでみる。
https://medium.com/mobimeo-technology/effective-task-cancellation-in-swift-by-leveraging-combines-anycancellable-7814b50fc8c8


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を持ち続けることがありそうだなと。
https://blog.personal-factory.com/2022/08/06/not-require-weak-self-in-task-init/

ほへとほへと

今回の対応

・combineをimportするのは、大きいなと思ったので、新しくTaskCancellable用のクラスを作って、
AnyCancellableと同様の動きをさせるようにした。

・Taskの上書きと、画面表示後のcancelを実行させたかったので、viewDidDisappear()でも、cancel処理を動かすよう実装した。以下の対応でも大丈夫かも。
https://tech.medpeer.co.jp/entry/2022/06/27/132318

・重いタスクでないと恩恵を受けない気がしたので、キャンセル処理の実装するかしないかを一度検討してみても良いのかも、、 SwiftUIでの実装は.task{}の中に書けばView破棄時にキャンセルしてくれるみたいなので羨ましい、、

このスクラップは6ヶ月前にクローズされました