【Swift Concurrency・SwiftUI】Task がキャンセルされても実行中の async なメソッド等を続行させる

2024/05/02に公開

まとめ

「構造化されていない Task」を作り、それの終了を Task.value など[1] で待つようにすると、キャンセル情報等は伝搬されない。

mainTask がキャンセルされても subTask はキャンセルされない
  let mainTask = Task {
+     let subTask = Task {
          await asyncFunc()
+     }
+     await subTask.value
  }

Swift Concurrency のキャンセルの伝搬

ひとつの async な関数 asyncFunc() を用意します。

func asyncFunc() async {
    do {
        print(#function, "[START]")
        try await Task.sleep(for: .seconds(5))
        print(#function, "[FINISH]")
    } catch {
        print(#function, "[ERROR]", error)
    }
}

この asyncFunc() は、呼び出された直後に "asyncFunc() [START]"、約5秒後に "asyncFunc() [FINISH]" を出力します。

let mainTask = Task {
    print("mainTask", "[START]")
    await asyncFunc()
    print("mainTask", "[FINISH]")
}

/*
 mainTask [START]
 asyncFunc() [START]
 // 約5秒の間を空けて
 asyncFunc() [FINISH]
 mainTask [FINISH]
 */

また、その5秒経過を待っている間に asyncFunc() が実行されている Task がキャンセルされた場合、"asyncFunc() [ERROR] CancellationError()" が出力されます。

let mainTask = Task {
    print("mainTask", "[START]")
    await asyncFunc()
    print("mainTask", "[FINISH]")
}

// 約1秒待って `mainTask` をキャンセルする
try await Task.sleep(for: .seconds(1))
mainTask.cancel()

/*
 mainTask [START]
 asyncFunc() [START]
 // 約1秒の間を空けて
 asyncFunc() [ERROR] CancellationError()
 mainTask [FINISH]
 */

この挙動は Swift Concurrency の非常に強力なキャンセルのサポートによってもたらされるもので、とてもありがたいなと私は思っています。一方で、さまざまな事情によってこのキャンセルの伝搬が都合が悪いとされることも考えられます。

「構造化されていない Task」を作ってキャンセルの伝搬をやめる

WWDC21 のセッション「Swift における構造化並行処理 (Explore structured concurrency in Swift)」に出てくる "構造化されていない Task (unstructured task)" を用いることで、このキャンセルの伝搬を受け取らないことが可能です。

let mainTask = Task {
    print("mainTask", "[START]")
    let subTask = Task {
        await asyncFunc()
    }
    print("mainTask", "[FINISH]")
}

// 約1秒待って `mainTask` をキャンセルする
try await Task.sleep(for: .seconds(1))
mainTask.cancel()

/*
 mainTask [START]
 mainTask [FINISH]
 asyncFunc() [START]
 // 約5秒の間を空けて
 asyncFunc() [FINISH]
 */

上のコード例では、「構造化されていない Task」として subTaskmainTask の中で作り、そこで asyncFunc() を呼び出すようにしました。これにより、mainTask がキャンセルされてもそれの影響を受けることなく、"asyncFunc() [FINISH]" が出力されています。

しかし、"asyncFunc() [FINISH]" が出力される前に "mainTask [FINISH]" と出力されているようすから、subTask 内の非同期処理の終了を待たずに mainTask が終了してしまっているようです。これは「構造化されていない Task」として実行したためにこのような挙動になっています。

キャンセルの影響は受けないながらも subTask 内の非同期処理の終了を待って mainTask を終了するようにするには、Task.value など[1:1]を用います。

let mainTask = Task {
    print("mainTask", "[START]")
    let subTask = Task {
        await asyncFunc()
    }
    await subTask.value  // ✅ 追加
    print("mainTask", "[FINISH]")
}

// 約1秒待って `mainTask` をキャンセルする
try await Task.sleep(for: .seconds(1))
mainTask.cancel()

/*
 mainTask [START]
 asyncFunc() [START]
 // 約5秒の間を空けて
 asyncFunc() [FINISH]
 mainTask [FINISH]
 */

SwiftUI での活用例

上記までのコード例では asyncFunc() を呼び出すための親となる Task として mainTask を作っていましたが、SwiftUI には async なメソッド等を呼び出すことに対応した task(priority:_:)refreshable(action:) などといった Modifier がいくつかあります。

これらは View が消えた後などの決まったタイミングで、呼び出している async なメソッド等をキャンセルするように促してきます。

task(priority:_:) の例

import SwiftUI

struct ContentView: View {
    var body: some View {
        Text("Hello, happy world!")
            .task {
                await asyncFunc()
            }
    }
}

/*
 ℹ️ ContentView が開きっぱなしの場合
 asyncFunc() [START]
 // 約5秒の間を空けて
 asyncFunc() [FINISH]
*/

/*
 ℹ️ ContentView が asyncFunc() の実行中(=約5秒)に閉じられた場合
 asyncFunc() [START]
 // 閉じられてから
 asyncFunc() [ERROR] CancellationError()
 */

もし、View が閉じられても実行中の async なメソッド等を続行させたい場合は、「構造化されていない Task」を作ってそこで実行し、それの終了を Task.value など[1:2] で待つようにします。

import SwiftUI

struct ContentView: View {
    var body: some View {
        Text("Hello, happy world!")
            .task {
                let task = Task {
                    await asyncFunc()
                }
                await task.value
            }
    }
}

/*
 ℹ️ ContentView が開きっぱなしの場合・asyncFunc() の実行中(=約5秒)に閉じられた場合のいずれでも
 asyncFunc() [START]
 // 約5秒の間を空けて
 asyncFunc() [FINISH]
*/

refreshable(action:) の例

refreshable(action:) のドキュメントから

For example, when you apply this modifier on iOS and iPadOS to a List, the list enables a standard pull-to-refresh gesture that refreshes the list contents. When the user drags the top of the scrollable area downward, the view reveals a progress indicator and executes the specified handler. The indicator remains visible for the duration of the refresh, which runs asynchronously:

https://developer.apple.com/documentation/swiftui/view/refreshable(action:)

と読めるように、action に渡す非同期処理が実行されている間はインジケーターが表示されます。

初期状態 下に引っ張って非同期処理開始 非同期処理終了後

もし、この挙動に「キャンセルの影響を受けたくない」という要件を加えたい場合は、「構造化されていない Task」を作ってそこで実行し、それの終了を Task.value など[1:3] で待つようにします。

import SwiftUI

struct ContentView: View {
    @State private var contents: [String] = /* ... */
    
    var body: some View {
        List(contents, id: \.self) {
            Text($0)
        }
        .refreshable {
            let task = Task {
                await refresh()
            }
            await task.value
        }
    }
    
    func refresh() async { /* ... */ }
}

キャンセル時の処理のカスタマイズはお行儀よく

「構造化されていない Task」を用いるということは、「構造化されている Task」のときに Swift 側がよしなに行ってくれていたキャンセル情報等の伝搬を、コードの書き手の責任によって自由にできるという意味でもあります。これを行う場合は、適切に Task.isCancelledTask.checkCancellation()withTaskCancellationHandler(operation:onCancel:) などといった仕組みを用いて、自身でキャンセル時の処理をお行儀よく実装しましょう。

参考

脚注
  1. 上で述べた get asyncTask.value の他にも、get async throwsTask.valueget asyncTask.result を用いることもできます。 ↩︎ ↩︎ ↩︎ ↩︎

Discussion