What happens to the other children when one child in a throwing task group fails, and what does a discarding task group change?

10.8.2 · L5 · Interview

Predict first

One child throws after 50 ms while another sleeps for two seconds. What happens?

import Foundation

struct Boom: Error {}
let start = ContinuousClock.now
do {
    try await withThrowingTaskGroup(of: Int.self) { g in
        g.addTask { try await Task.sleep(nanoseconds: 50_000_000); throw Boom() }
        g.addTask {
            do { try await Task.sleep(nanoseconds: 2_000_000_000); print("slow finished"); return 1 }
            catch { print("slow was cancelled:", Task.isCancelled, type(of: error)); throw error }
        }
        for try await v in g { print("got", v) }
    }
} catch { print("caught", type(of: error)) }
print("returned quickly:", ContinuousClock.now - start < .seconds(1))
  • slow was cancelled: true CancellationError / caught Boom / returned quickly: true
  • slow finished / caught Boom / returned quickly: false
  • caught Boom / returned quickly: true

Predict first

A discarding group where the fourth child throws and the fifth is slow. How many children finish?

import Foundation

struct Boom: Error {}
final class Tally: @unchecked Sendable {
    private let lock = NSLock(); private var n = 0
    func add() { lock.lock(); n += 1; lock.unlock() }
    var value: Int { lock.lock(); defer { lock.unlock() }; return n }
}
let tally = Tally()
do {
    try await withThrowingDiscardingTaskGroup { g in
        for i in 0..<5 {
            g.addTask {
                try await Task.sleep(nanoseconds: i == 4 ? 2_000_000_000 : UInt64(10 + i) * 1_000_000)
                tally.add()
                if i == 3 { throw Boom() }
            }
        }
    }
} catch { print("caught", type(of: error)) }
print("children that finished:", tally.value)
  • caught Boom / children that finished: 4
  • caught Boom / children that finished: 5
  • caught Boom / children that finished: 3

Mental model. A relay team where one runner drops the baton: the referee tells the others to stop, but the race is only over once everyone has stopped running.

In withThrowingTaskGroup an error thrown by a child is rethrown when you try await its result (for try await or next()). If that error then leaves the group’s closure, the group cancels all remaining children and still waits for them to finish before it rethrows. In the check the slow child, which was asleep for two seconds, sees Task.isCancelled == true and a CancellationError almost at once, so the whole group returns in well under a second. The waiting matters: a child that ignores cancellation (a long loop with no checks) still delays the group, so cancellation only speeds things up if children cooperate.

A normal group keeps every child’s result until you consume it, which is wasteful for fire-and-forget work such as handling a stream of connections. withDiscardingTaskGroup and withThrowingDiscardingTaskGroup (Swift 5.9) throw each result away as soon as the child finishes, so memory stays flat for long-lived groups. They also have no next(): the first child to throw cancels the group automatically, and the error is rethrown from the group. In the check, one child throws at 13 ms, the child sleeping for two seconds is cancelled before it can count itself, and exactly 4 of the 5 children finished.

The failing child cancels its sibling, and the group waits for the sibling to stop.

The throw cancels the group, so the slow child is cancelled before it counts itself.

Common traps

  • Expecting the group to return the instant a child throws. It waits for the cancelled children to actually finish.
  • Using a normal group for an endless stream of work. Unconsumed results pile up; use a discarding group.

They might follow up with

  • Can I collect partial results and ignore errors? Return a Result from each child so no child throws, and decide afterwards.
  • Does a discarding group give me the results? No, by design; write results to an actor or a stream from inside the child.

Related questions

  • 10.8.1 In what order do task group results arrive, and how do you limit how many children run at once?
  • 10.8.4 How does cancellation actually reach your code: what do checkCancellation, isCancelled…
  • 10.3.2 How do tasks form a tree: Task, task groups, async let and detached tasks, and what does a child inherit?
  • 10.3.3 How does cancellation work in Swift concurrency, and what are task priorities and task locals for?

← In what order do task group results arrive, and how do you limit how…   What happens to an async let that you never await when the scope ends? →