はじめに
Swift6になるとSwift ConcurrencyのTaskを多用するようになると思います。
Taskでは非同期処理を扱いますが、cancelというTaskで行う処理を中断する処理があります。
Taskを使い始めた時cancelを実行するとTaskで行う処理がどういう振る舞いをするのかよくわからなかったので、整理してみました。
TaskのcancelでTask内で起きること
playgroundで実際にTaskをcancelした時の挙動を以下コードで確認してみました。
var task: Task<Void, Error>
task = Task {
do {
print("Start task(\(Date.now)).")
try await Task.sleep(for: .seconds(5))
print("Completed task(\(Date.now)).")
}
catch {
print("error: \(error)(\(Date.now)).")
}
}
Task {
task.cancel()
print("Canceled task(\(Date.now)).")
}
Start task(2024-11-17 11:38:41 +0000).
Canceled task(2024-11-17 11:38:41 +0000).
error: CancellationError()(2024-11-17 11:38:41 +0000).
上記例ではTask.sleepが完了する前にcancelにより処理が中断され、CancellationErrorがthrowされています。
別のコードでもう少し挙動をみてみましょう。
func sleep(seconds: Int) async {
return await withCheckedContinuation { continuation in
DispatchQueue.global().asyncAfter(wallDeadline: .now() + .seconds(seconds)) {
continuation.resume()
}
}
}
var task: Task<Void, Error>
task = Task {
do {
print("Start task(\(Date.now)).")
await sleep(seconds: 5)
print("Completed task(\(Date.now)).")
}
catch {
print("error: \(error)(\(Date.now)).")
}
}
Task {
task.cancel()
print("Canceled task(\(Date.now)).")
}
Start task(2024-11-17 11:48:00 +0000).
Canceled task(2024-11-17 11:48:00 +0000).
Completed task(2024-11-17 11:48:05 +0000).
引数のseconds分待機する自前のsleep(seconds: Int)関数を定義し、Task.sleepの代わりにそちらを使用してみたところ、先ほどとは違いcancel後も待機し続けsleepが完了していることがわかります。
どうしてこのようなことが起きるかというと、cancelのドキュメントに記載のある通りです。
タスクのキャンセルは協調的です。キャンセルをサポートするタスクは、作業中のさまざまな時点でキャンセルされたかどうかをチェックします。
キャンセルをサポートしていないタスクでこのメソッドを呼び出しても効果はありません。同様に、タスクが早期に停止する最後のポイントをすでに過ぎている場合、このメソッドを呼び出しても効果はありません。
つまりcancelをサポートしていないTaskはcancelしても早期終了する振る舞いにならないのです。
cancelをサポートするには
まず一つ目はtry Task.checkCancellation()でキャンセルしていたらCancellationErrorをthrowするようにすることです。
以下で実際に試してみました。
func sleep(seconds: TimeInterval) async throws {
var waitFlg = true
let startTime = Date.now
while waitFlg {
waitFlg = Date.now.timeIntervalSince(startTime) < seconds
try Task.checkCancellation()
}
}
var task: Task<Void, Error>
task = Task {
do {
print("Start task(\(Date.now)).")
try await sleep(seconds: 5)
print("Completed task(\(Date.now)).")
}
catch {
print("error: \(error)(\(Date.now)).")
}
}
Task {
task.cancel()
print("Canceled task(\(Date.now)).")
}
Start task(2024-11-17 12:20:22 +0000).
Canceled task(2024-11-17 12:20:22 +0000).
error: CancellationError()(2024-11-17 12:20:22 +0000).
二つ目の方法として、Task.isCancelledでキャンセルしているかどうかを検知して処理を抜けるように実装することです。
sleep関数をTask.isCancelledでcancel対応した場合の例が以下です。
func sleep(seconds: TimeInterval) async throws {
var waitFlg = true
let startTime = Date.now
while waitFlg {
waitFlg = Date.now.timeIntervalSince(startTime) < seconds
if Task.isCancelled { throw CancellationError() }
}
}
上記のような例であれば、try Task.checkCancellation()を使用した方が簡潔でわかりやすいですね。
CancellationErrorでない型でthrowした場合などはTask.isCancelledの方法が良さそうです。
cancel対応のはまりポイント
先ほどの例でcancelに対応する方法がわかったので、Taskのキャンセルの振る舞いを完全に理解した!となりました。
キャンセルの振る舞いを理解した上で以下コードをキャンセル対応してみます。(前例のコードと書き方が違うだけですが、、)
func sleep(seconds: TimeInterval) async {
await withCheckedContinuation { continuation in
DispatchQueue.main.async {
var waitFlg = true
let startTime = Date.now
while waitFlg {
waitFlg = Date.now.timeIntervalSince(startTime) < seconds
}
continuation.resume()
}
}
}
先ほどまでの話を踏まえると、以下のような対応が思い付きます。
func sleep(seconds: TimeInterval) async throws {
try await withCheckedThrowingContinuation { continuation in
DispatchQueue.main.async {
do {
var waitFlg = true
let startTime = Date.now
while waitFlg {
waitFlg = Date.now.timeIntervalSince(startTime) < seconds
try Task.checkCancellation()
}
continuation.resume()
}
catch {
continuation.resume(throwing: error)
}
}
}
}
しかし、これはうまくいかずキャンセルしたことを検知することができません。
これでTaskのキャンセルやっぱりわからん!となるわけです。
この挙動はStructured Concurrencyという仕様が絡んできます。
Structured Concurrencyには子タスクという概念がありTaskの処理内でasync/awaitによる非同期関数を呼び出すと非同期関数のブロックは子タスクとして扱われ、呼び出し元のTaskのコンテキストを継承します。
同じTaskのコンテキスト内ではTask.isCancelledのようにstatic参照するキャンセルの状態が共有されます。
つまりTaskがキャンセルされれば、子タスクもキャンセルされるのです。
しかし、逆に別のコンテキストであればキャンセル状態は共有されません。
上記を理解した上で再度下記コードを見てみると、なぜsleep関数でキャンセルが検知できないかが想像できます。
おそらくDispatchQueue.main.asyncのブロックはTaskのコンテキストから分離されてしまうのだと思います。(プロポーサル見てみたのですが、DispatchQueueではコンテキストが継承されないといった記述は見つかりませんでしたが、コードの実行結果からそのように推測できます)
func sleep(seconds: TimeInterval) async throws {
try await withCheckedThrowingContinuation { continuation in // このブロックまではTask1の子タスクとしてコンテキストを受け継いでいる
DispatchQueue.main.async { // 別のコンテキスト
do {
var waitFlg = true
let startTime = Date.now
while waitFlg {
waitFlg = Date.now.timeIntervalSince(startTime) < seconds
try Task.checkCancellation()
}
continuation.resume()
}
catch {
continuation.resume(throwing: error)
}
}
}
}
var task: Task<Void, Error>
task = Task { // Task1のコンテキスト
do {
print("Start task(\(Date.now)).")
try await sleep(seconds: 5)
print("Completed task(\(Date.now)).")
}
catch {
print("error: \(error)(\(Date.now)).")
}
}
Task { // Task2のコンテキスト
task.cancel()
print("Canceled task(\(Date.now)).")
}
Task毎にコンテキストを持つので以下のようにしても、キャンセルを検知できない問題は発生します。
func sleep(seconds: TimeInterval) async throws {
// Task1のコンテキスト
try await Task { // Task3のコンテキスト(Task1のコンテキストからは分離される)
var waitFlg = true
let startTime = Date.now
while waitFlg {
waitFlg = Date.now.timeIntervalSince(startTime) < seconds
try Task.checkCancellation() // Task3のキャンセルがされないと検知できない
}
}.value
}
var task: Task<Void, Error>
task = Task { // Task1のコンテキスト
do {
print("Start task(\(Date.now)).")
try await sleep(seconds: 5)
print("Completed task(\(Date.now)).")
}
catch {
print("error: \(error)(\(Date.now)).")
}
}
Task { // Task2のコンテキスト
task.cancel()
print("Canceled task(\(Date.now)).")
}
上記のように、Taskのコンテキストを意識しないと、意図しないキャンセルの挙動になってしまうため、基本的には一連の処理は一つのTaskで行うようにする必要があります。
おわり
Taskのキャンセルの振る舞い一つでもはまりポイントがあり結構奥深いなと思いました。