3

I found that Task cancellation state is not propagated when any task from tasks group has failed with error.

In my example the 2 long running async operations starts simultaneously. The 1st one lasts 3sec and fails. The 2nd one lasts 6sec and completes with success. Both tasks check Task.isCancelled state before completion. Actually I see that 6s task is not receiving Task.isCancelled state.

My expectation was as soon as 1st task fails then tasks group sends cancel to all remaining tasks in the group. So 6sec task should pass checking for Task.isCancelled

Below the source code of long running async operation, it emulates long running executing via sleep(). After sleep() it checks Task.isCancelled state:

func runLongOperation(key: String, sleepSec: UInt32, shouldFailWithError: Bool) async throws -> String {
    try await withUnsafeThrowingContinuation { continuation in
        let queue = DispatchQueue(label: "loq")
        queue.async {
            print(" \(key) started...")
            sleep(sleepSec)
            
            if Task.isCancelled {
                print(" ...\(key) Task is cancelled")
            }
                
            if shouldFailWithError {
                print(" ...\(key) finished with failure")
                continuation.resume(throwing: MyError.generic)
            }
            else {
                print(" ...\(key) finished with success")
                continuation.resume(returning: key)
            }
        }
    }
}

This async function executes 2 long running operations simultaneously in tasks group:

func runTasksGroup() async throws -> [String] {
    return try await withThrowingTaskGroup(of: String.self, body: { group in
        group.addTask {
            return try await self.runLongOperation(key: "#1[3s]", sleepSec: 3, shouldFailWithError: true)
        }
        group.addTask {
            return try await self.runLongOperation(key: "#2[6s]", sleepSec: 6, shouldFailWithError: false)
        }
        
        var strs = [String]()
        for try await str in group {
            strs += [str]
        }
        return strs
    })
}

And runTasksGroup function starts from sync viewDidLoad() function

override func viewDidLoad() {
    super.viewDidLoad()
    Task {
        do {
            let strs = try await runTasksGroup()
            print("all done with success \(strs.joined(separator: " "))")
        }
        catch {
            print("some tasks failed")
        }
    }
}

The output

 #1[3s] started...
 #2[6s] started...
 ...#1[3s] finished with failure
 ...#2[6s] finished with success
some tasks failed

The task which lasts 6sec doesn't receive Task.isCancelled state, although 3sec task has failed earlier. Could anybody explain why the 6s task is not cancelled?

Rob
  • 415,655
  • 72
  • 787
  • 1,044
Ihar Katkavets
  • 1,510
  • 14
  • 25

1 Answers1

1

The sleep in runLongOperation will not respond to cancelation. And a block dispatched to a dispatch queue will not catch the cancelation automatically.

  • Ideally, if the code in the block could be refactored to not dispatch asynchronously to a GCD queue, then the cancelation would be handled correctly. E.g., consider using the cancelable Task.sleep(nanoseconds:):

    func runLongOperation(key: String, sleepSec: UInt64, shouldFailWithError: Bool) async throws -> String {
        logger.debug("\(key) started...")
        try await Task.sleep(nanoseconds: sleepSec * NSEC_PER_SEC)
    
        if shouldFailWithError {
            logger.error("\(key) ... finished with failure")
            throw MyError.generic
        } else {
            logger.debug("\(key) ... finished with success")
            return key
        }
    }
    

    Or, if you want to log the cancelation, then catch, print, and rethrow the error:

    func runLongOperation(key: String, sleepSec: UInt64, shouldFailWithError: Bool) async throws -> String {
        do {
            logger.debug("\(key) started...")
            try await Task.sleep(nanoseconds: sleepSec * NSEC_PER_SEC)
    
            if shouldFailWithError {
                logger.error("\(key) ... finished with failure")
                throw MyError.generic
            } else {
                logger.debug("\(key) ... finished with success")
                return key
            }
        } catch {
            logger.error("\(key): \(error.localizedDescription)")
            throw error
        }
    }
    
  • If you are using continuations (e.g., wrapping dispatch to a dispatch queue inside a withCheckedThrowingContinuation, as in your example), you need to handle cancelation manually. If you’re wrapping some legacy, cancelable, asynchronous process, you would use withTaskCancellationHandler. If you are looping (e.g., doing some long calculation), you would manually test cancelation status with either Task.checkCancellation or periodically test Task.isCancelled. But sleep is not cancelable.

    Let us assume for a second that your task was really a slow, blocking, uncancelable, process. So, a few observations:

    1. Using a dispatch queue takes you out of a context where you can use Task.isCancelled anymore.

    2. If possible, I would encourage you to stay within the Swift concurrency cooperative thread pool. I.e., use detached task rather than GCD.

    3. You can use withTaskCancellationHandler to detect cancelation and update your own state.

    4. You will want to synchronize your access to that state variable. Either create your own Sendable state property with some synchronization, or use an actor.

    E.g., below I am running my slow, uncancelable process with a detached task:

    func runLongOperation(key: String, sleepSec: UInt32, shouldFailWithError: Bool) async throws -> String {
        let state = State()
    
        return try await withTaskCancellationHandler {
            try await withCheckedThrowingContinuation { continuation in
                Task.detached {
                    print(" \(key) started...")
                    sleep(sleepSec)
    
                    if await state.isCancelled {
                        print(" ...\(key) Task is cancelled")
                        continuation.resume(throwing: CancellationError())
                    } else if shouldFailWithError {
                        print(" ...\(key) finished with failure")
                        continuation.resume(throwing: MyError.generic)
                    } else {
                        print(" ...\(key) finished with success")
                        continuation.resume(returning: key)
                    }
                }
            }
        } onCancel: {
            Task { await state.cancel() }
        }
    }
    

    Where State is a thread-safe type:

    actor State {
        private(set) var isCancelled = false
    
        func cancel() {
            isCancelled = true
        }
    }
    
Rob
  • 415,655
  • 72
  • 787
  • 1,044