1
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

Posted at

はじめに

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

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

TaskcancelTask内で起きること

playgroundで実際にTaskcancelした時の挙動を以下コードで確認してみました。

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をサポートしていないTaskcancelしても早期終了する振る舞いにならないのです。

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

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

1
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?