-
Notifications
You must be signed in to change notification settings - Fork 0
10 — DispatchGroup
Table 12 orders three dishes at once — a steak from the grill chef, a salad from the cold station, a risotto from the sauté chef. Three separate tickets go into the order line, and three different chefs pick them up independently. None of them knows or cares what the other two are cooking. Each one just cooks their own dish, plates it, and sets it down at the pass.
The problem is that nobody wants table 12's food delivered one plate at a time, five minutes apart, going cold while the other two dishes are still on the grill. The table ordered together and expects to eat together. So somebody has to stand at the pass, keep an eye on all three tickets, and hold off doing anything until all three plates are sitting there at once. That's the waiter's job — not cooking anything, and not carrying the plates out either, just tracking whether the set of dishes for table 12 is complete, and only calling the head chef over once it is. The head chef is still the only one who ever crosses to the customer-facing counter — the waiter's job ends at the pass.
Notice what the waiter is not doing. The waiter isn't standing frozen at the pass with their eyes locked on the grill, unable to do anything else until the steak is done — that would be the blocking, do-nothing-else behavior Chapter 4 already ruled out. And the waiter isn't guessing — flagging the head chef down after two plates land, hoping the third is close behind. The waiter has an exact count: three dishes ordered, and a plate arriving at the pass ticks that count down. The moment the count hits zero, and not one tick before, the waiter taps the head chef on the shoulder: table 12 is complete.
👉 The waiter doesn't cook anything and doesn't wait around doing nothing — they just track a count of unfinished dishes for one table, and the signal to the head chef fires exactly once that count reaches zero.
A DispatchGroup is that waiter — an object whose entire job is tracking how many pieces of outstanding work belong to one logical batch, and telling you the instant that count returns to zero. It doesn't run any work itself. It doesn't know what a "dish" is. It just holds a counter, and gives you three operations to work with it:
-
group.enter()increments the counter by one. You call this once, right before kicking off each piece of async work you want the group to track — it's the equivalent of writing table 12's ticket for the steak and dropping it in the order line. The group now knows there's one more outstanding dish it's waiting on. -
group.leave()decrements the counter by one. You call this once the corresponding piece of work is actually finished — the equivalent of a chef setting a finished plate down at the pass. Everyenter()needs exactly one matchingleave(), called from wherever that specific block of work completes, whether that's a completion handler, the end of a closure, or a callback fired from somewhere else entirely. -
group.notify(queue:execute:)registers a block to run the moment the counter returns to zero — the waiter's tap on the head chef's shoulder. It fires once, asynchronously, on whatever queue you hand it. If you callnotifywhile the counter is already at zero (nothing outstanding), it fires right away. If work is still outstanding, it just waits, unblocked, until the lastleave()brings the count down.
That enter()/leave() pairing is the entire mechanism, and it has to balance exactly. Call enter() three times and leave() only twice, and the counter sits at one forever — the waiter is still holding two fingers up, convinced a third dish is coming, and notify never fires. There's nothing incorrect about that from GCD's point of view; it's just permanently, silently waiting, because nothing told the group it could stop. The opposite mistake — calling leave() more times than enter() — is a different kind of failure entirely, and GCD does not let it pass quietly. The moment the internal counter would go negative, GCD treats it as a programming error and traps immediately, crashing the process on the spot. A dish materializing at the pass that was never ordered isn't something the waiter can gracefully shrug off — it means the bookkeeping itself is broken, and GCD would rather crash loudly right there than let a group limp along with a counter that no longer means anything.
Manually pairing every enter() with a leave() is exactly the kind of bookkeeping that's easy to get right in a simple example and easy to get wrong once error paths, early returns, or multiple completion callbacks enter the picture — miss one exit path out of a closure and you've silently broken the count. GCD offers a shortcut for the common case: group.async(queue:execute:) submits a block to a queue and automatically calls enter() right before it starts and leave() right after it finishes — no manual pairing required. It's sugar over exactly the same enter()/leave() mechanism, just with the balancing done for you, which is why it's the preferred form whenever the work is a plain synchronous block rather than something with its own async completion handler.
group.enter() ── grill: steak ── group.leave()
/ \
group created ── group.enter() ── cold station: salad── group.leave() ── counter == 0
\ /
group.enter() ── sauté: risotto ── group.leave()
│
▼
group.notify(queue:) {
🍽️ waiter alerts head chef — table 12 ready
}
Three tickets enter the group independently and finish on their own schedule — the salad might be up before the steak even hits the grill. notify's block doesn't run at the pace of any one chef; it only fires once every single track has reported back with a leave(), however long the slowest dish takes.
let group = DispatchGroup()
group.enter()
cookDish1 { group.leave() }
group.enter()
cookDish2 { group.leave() }
group.enter()
cookDish3 { group.leave() }
group.notify(queue: .main) {
print("All dishes ready, serve the table 🍽️")
}Each cookDish call is assumed to be async and to take a completion closure — enter() fires immediately before handing the dish off, and the matching leave() sits inside that dish's own completion closure, wherever it happens to run. The three cookDish calls don't wait on each other; they can all be in flight on different queues at the same moment. notify is registered once, up front, and Swift doesn't care in what order the three leave() calls actually arrive — only that all three eventually do.
👉 enter() says "one more dish is coming," leave() says "this dish is done," and notify only ever fires once those two counts are equal again — it has no idea whether that took three cooks running in parallel or one cook working through them in sequence.
If cookDish1 were written using group.async(queue:) instead of a bare completion handler — say it was pure synchronous work with no callback of its own — the enter()/leave() pair around it would collapse to:
group.async(queue: .global()) {
cookSteakSynchronously()
}Same counter, same balancing, just without writing the enter()/leave() by hand.
Swift Concurrency replaces DispatchGroup with two different tools, chosen by whether the set of concurrent tasks is known up front or built dynamically:
async let steak = cookSteak()
async let salad = cookSalad()
async let risotto = cookRisotto()
let (a, b, c) = await (steak, salad, risotto)
print("All dishes ready, serve the table 🍽️")async let is for a fixed number of concurrent calls known at compile time — exactly three dishes, written out as three bindings. Each async let starts its child task immediately; the await on the tuple suspends until all three have finished. For a set whose size isn't known ahead of time — table 12 orders anywhere from one to a dozen dishes, decided at runtime — TaskGroup is the equivalent of a dynamic version of the same idea:
await withTaskGroup(of: Dish.self) { group in
for order in table12Orders {
group.addTask { await cook(order) }
}
for await dish in group {
// each dish arrives as its cooking finishes
}
print("All dishes ready, serve the table 🍽️")
}The difference from DispatchGroup isn't just syntax — it's who's responsible for the count. With DispatchGroup, the compiler has no idea how many enter() calls exist or whether every one has a matching leave(); that bookkeeping is entirely on you, at runtime, with no static check. With async let and TaskGroup, the language itself tracks how many child tasks are outstanding and knows exactly when the last one finishes — there's no counter you touch by hand, so there's no way to under-call or over-call anything. Cancellation and error propagation ride along automatically, too: if cookRisotto() throws, TaskGroup (or the surrounding async let await) propagates that error out and cancels the sibling tasks, something DispatchGroup has no built-in concept of at all — a leave() fires whether the work "succeeded" or not, and a thrown error inside a group's block has to be captured and reported by hand.
👉 A DispatchGroup is a counter you manage yourself, one enter()/leave() pair at a time; async let and TaskGroup are the same "wait for everything" idea with the counting — and the failure to count correctly — removed entirely.
A DispatchGroup is the waiter who won't call the head chef to the counter until every dish is up.
A waiter who's shorted a dish — three tickets went in, but only two plates ever showed up at the pass — doesn't stand there indefinitely with no idea why. A real waiter would eventually walk over to the grill and ask what happened to the steak. DispatchGroup offers nothing like that. If a leave() call is missing — a completion handler that's never invoked because of an early return, a forgotten call on one branch of an if/else, a callback that silently swallows an error instead of still calling leave() — the counter sits above zero forever. notify's block simply never runs. No crash, no log line, no timeout, nothing in the console pointing at the problem. From the outside, the app just looks like it stopped doing whatever was supposed to happen after "all dishes ready" — a spinner that never goes away, a screen that never updates — with no signal anywhere that a DispatchGroup is the reason.
That silence is the sharpest place the analogy breaks. A real restaurant has a dozen social mechanisms that surface a missing dish — someone notices, someone asks, someone eventually complains. A DispatchGroup with an unbalanced missing leave() has none of that. It just waits, correctly, forever, for a signal that is never going to come, and there is no built-in way to ask it "what are you still waiting on?" Tracking down a hang like this means auditing every code path between each enter() and its intended leave() by hand, which is exactly the manual-bookkeeping risk that async let and TaskGroup remove in the previous section.
Suppose a bug causes group.leave() to be called one more time than group.enter() was ever called for that group. Based on how the counter works, what happens — and how is that different from the missing-leave() case above?