Taskをキャンセルした時の振る舞いを知る

# はじめに
Swift6になるとSwift Concurrencyの`Task`を多用するようになると思います。
`Task`では非同期処理を扱いますが、`cancel`という`Task`で行う処理を中断する処理があります。

https://developer.apple.com/documentation/swift/task/cancel()

`Task`を使い始めた時`cancel`を実行すると`Task`で行う処理がどういう振る舞いをするのかよくわからなかったので、整理してみました。

# `Task`の`cancel`で`Task`内で起きること
playgroundで実際に`Task`を`cancel`した時の挙動を以下コードで確認してみました。

```swift
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されています。

別のコードでもう少し挙動をみてみましょう。

```swift
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するようにすることです。
以下で実際に試してみました。

```swift
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`対応した場合の例が以下です。

```swift
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`のキャンセルの振る舞いを完全に理解した！となりました。
キャンセルの振る舞いを理解した上で以下コードをキャンセル対応してみます。(前例のコードと書き方が違うだけですが、、)

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

先ほどまでの話を踏まえると、以下のような対応が思い付きます。
```swift
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という仕様が絡んできます。

https://github.com/swiftlang/swift-evolution/blob/main/proposals/0304-structured-concurrency.md#cancellation-1

Structured Concurrencyには子タスクという概念があり`Task`の処理内で`async`/`await`による非同期関数を呼び出すと非同期関数のブロックは子タスクとして扱われ、呼び出し元の`Task`のコンテキストを継承します。
同じ`Task`のコンテキスト内では`Task.isCancelled`のようにstatic参照するキャンセルの状態が共有されます。
つまり`Task`がキャンセルされれば、子タスクもキャンセルされるのです。

しかし、逆に別のコンテキストであればキャンセル状態は共有されません。

上記を理解した上で再度下記コードを見てみると、なぜ`sleep`関数でキャンセルが検知できないかが想像できます。
おそらく`DispatchQueue.main.async`のブロックは`Task`のコンテキストから分離されてしまうのだと思います。(プロポーサル見てみたのですが、`DispatchQueue`ではコンテキストが継承されないといった記述は見つかりませんでしたが、コードの実行結果からそのように推測できます)

```swift
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`毎にコンテキストを持つので以下のようにしても、キャンセルを検知できない問題は発生します。

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