はじめに
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
のキャンセルの振る舞い一つでもはまりポイントがあり結構奥深いなと思いました。