Search code examples
swiftswift-concurrency

Understanding Task and TaskGroup cancellation in Swift


In Swift Concurrency when Tasks are cancelled, child tasks and task groups are also implicitly canceled, according to Apple's documentation and WWDC talks. However, when I implement work in a TaskGroup and cancel its parent task, I'm unable to get it to recognize it has been cancelled.

Below is a short example demonstrating the problem.

The function start starts a Task that spins up a TaskGroup and adds a task to it every 2 seconds. Two seconds in, cancel() is called on the Task. I expect that one or two of the resulting values is marked as not completed. However, even after cancellation they're all marked as completed, meaning they did not respect addTaskUnlessCancelled.

public struct Thing {
    let completed: Bool
    let value: Int?
}

public struct TaskGroupCancellation {
    public mutating func start() async {
        let task = Task {
            let things = await withTaskGroup(of: Thing.self, returning: [Thing].self, body: { group in
                print("withTaskGroup start")
                var t = [Thing]()
                for i in 0..<3 {
                    let isCancelled = group.isCancelled
                    let added = group.addTaskUnlessCancelled {
                        print("Adding value \(i) (canceled?: \(isCancelled))")
                        return Thing(completed: true, value: i)
                    }
                    if !added {
                        print("Marking value \(i) as cancelled")
                        t.append(Thing(completed: false, value: nil))
                    }
                    try? await Task.sleep(for: .seconds(2))
                }

                for await thing in group {
                    t.append(thing)
                }
                print("withTaskGroup end")

                return t
            })

            print("Things: \(things)")
        }

        try? await Task.sleep(for: .seconds(2))
        task.cancel()
        print("Cancelled")
    }
}

Running the above yields the following output:

withTaskGroup start
Adding value 0 (canceled?: false)
Cancelled
Adding value 2 (canceled?: false)
Adding value 1 (canceled?: false)
withTaskGroup end
Things: [TaskGroupCancellation.Thing(completed: true, value: Optional(0)), TaskGroupCancellation.Thing(completed: true, value: Optional(2)), TaskGroupCancellation.Thing(completed: true, value: Optional(1))]

I'm expecting the task group's addTaskUnlessCancelled to not be entered after cancellation, but it seems like it still is. Am I understanding this wrong?


Solution

  • I experience the same TaskGroup behavior.

    The TaskGroup documentation for isCancelled says:

    If the task that’s currently running this group is canceled, the group is also implicitly canceled, which is also reflected in this property’s value.

    That caveat about the broader task cancelation will be “reflected in this property’s value” has not been my experience. I find that if the parent task is canceled, the group is canceled alright, but neither the group.isCancelled value nor addTaskUnlessCancelled method reflect this.

    To get around this, you can try Task.checkCancellation() (or examine Task.isCancelled), as that obviously does correctly represent whether this task has been canceled.

    Or alternatively, it generally isn't necessary to do anything and just let the automatic propagation of cancelation take care of everything. E.g., consider:

    import os.log
    
    let poi = OSSignposter(subsystem: "Test", category: .pointsOfInterest)
    
    func experimentCancelingParentTask() async throws {
        let task = Task {
            try await withThrowingTaskGroup(of: Void.self) { group in
                let id = poi.makeSignpostID()
                let state = poi.beginInterval("group", id: id)
    
                defer {
                    let groupIsCancelled = group.isCancelled
                    poi.endInterval("group", state, "group.isCancelled = \(groupIsCancelled.description); Task.isCancelled = \(Task.isCancelled.description)")
                }
    
                for await value in tickSequence() {
                    poi.emitEvent("tick", "\(value)")
                    group.addTask { try await self.performTask(with: value) }
                }
    
                try await group.waitForAll()
            }
        }
    
        try await Task.sleep(for: .seconds(4.5))
        poi.emitEvent("cancel")
        task.cancel()
    }
    

    If I profile that in Instruments with the “Points of Interest” tool, I will see:

    enter image description here

    Note that group.isCancelled is false, but Task.isCancelled is true. But, regardless, when the task group was canceled (that last Ⓢ signpost), the tasks that were in-flight were canceled, and even the AsyncSequence that this task group was iterating through was canceled.


    With all of that having been said, it is worth noting that group.cancelAll() results in the group cancelation state being reflected by group.isCancelled and addTaskUnlessCancelled. I know that group.cancelAll() was not the question at hand, but I wanted to note that this group-cancelation logic basically works, but merely does not reflect the cancelation state of the parent task.

    But as shown above, that is generally not needed, anyway.


    It's not terribly relevant, but here are a few functions that the above code snippet used:

    // just a placeholder function to perform a task (and log stuff for “Points of Interest”); 
    // the only salient detail is that this supports cancelation
    
    func performTask(with value: Int) async throws {
        let id = poi.makeSignpostID()
        let state = poi.beginInterval("task", id: id, "\(value)")
    
        defer {
            poi.endInterval("task", state, "\(value)")
        }
    
        try await Task.sleep(for: .seconds(1.75))
    }
    
    // a sequence of 12 values yielded one per second
    
    func tickSequence() -> AsyncStream<Int> {
        AsyncStream { continuation in
            let task = Task {
                for i in 0 ..< 100 {
                    continuation.yield(i)
                    try await Task.sleep(for: .seconds(1))
                }
                continuation.finish()
            }
    
            continuation.onTermination = { _ in
                task.cancel()
            }
        }
    }