Search code examples
swiftswift-concurrency

Why are nested tasks not canceled when they parent task is cancelled?


I need to cancel all nested tasks I try to cancel their parent but nothing happens all nested tasks keep running.

private var observationTask: Task<Void, Never>?
...
observationTask = Task {
    Task {
        for await users in list.$users.values {
            updateTableView(withUsers: users)
        }
    }
    Task {
        for await users in list.$users.values {
            updateTableView(withUsers: users)
        }
    }
}
....
observationTask.cancel()

}


Solution

  • You asked:

    Why are nested tasks not canceled when they parent task is cancelled?

    Because you are using Task, which is for unstructured concurrency. As the docs say, it is not a subtask, but a new “top-level task”.

    If you want to enjoy the benefits of structured concurrency (e.g., automatic propagation of cancelation), use task group instead of Task { ... }. E.g.:

    let observationTask = Task {
        await withTaskGroup(of: Void.self) { group in
            group.addTask {
                ...
            }
            group.addTask {
                ...
            }
        }
    }
    

    When you cancel the observationTask, all of the task group child tasks will now be canceled for you automatically (assuming, of course, that these child tasks have been implemented to support cancelation).

    For more information, see WWDC 2021 video Explore structured concurrency in Swift. Or see the discussion of structured vs unstructured concurrency in The Swift Programming Language: Concurrency.


    For the sake of completeness, if you want to use unstructured concurrency, you have to manually handle cancelation with withTaskCancellationHandler:

    observationTask = Task {
        let task1 = Task {
            …
        }
        let task2 = Task {
            …
        }
    
        await withTaskCancellationHandler {
            _ = await task1.result
            _ = await task2.result
        } onCancel: {
            task1.cancel()
            task2.cancel()
        }
    }
    

    As you can see, manually handling cancelation adds some noise to our code, which is why we would generally prefer structured concurrency.


    Please note that the above assumes that these subtasks (and possibly updateTableView, too) are cancelable. The fact that we do not see try anywhere suggests it has not implemented cancelation support (where you would Task.checkCancellation or test Task.isCancelled and manual handle the exiting of the loops if canceled).


    To illustrate cancelation, consider:

    import os.log
    
    let log = OSSignposter(subsystem: "Test", category: .pointsOfInterest)
    
    class Demonstration {
        private var observationTask: Task<Void, Error>?
        private let lock = NSLock()
    
        func start() {
            observationTask = Task {
                try await withThrowingTaskGroup(of: Void.self) { group in
                    group.addTask { [self] in
                        for i in 0 ..< 4 {
                            try await process(i)
                        }
                    }
                    group.addTask { [self] in
                        for i in 200 ..< 204 {
                            try await process(i)
                        }
                    }
                    try await group.waitForAll()
                }
            }
        }
    
        func stop() {
            log.emitEvent(#function)
            
            observationTask?.cancel()
        }
    
        func process(_ value: Int) async throws {
            let state = lock.withLock { log.beginInterval(#function, id: log.makeSignpostID(), "\(value)") }
            defer { lock.withLock { log.endInterval(#function, state) } }
    
            try await Task.sleep(for: .seconds(3))   // simulate something slow and asynchronous
        }
    }
    

    If I do not cancel the observationTask and profile it in Instruments’s “Points of Interest” tool, I see:

    enter image description here

    But if I do cancel it (at the Ⓢ signpost), I see the following:

    enter image description here

    That works because:

    • We used structured concurrency withThrowingTaskGroup; and
    • The subtasks were cancelable.