What goes wrong with an unbalanced DispatchGroup, and how do you use a semaphore to throttle work without deadlocking?
Predict first
What happens when leave() is called on a group that was never entered?
import Foundation
let g = DispatchGroup()
g.leave()
print("unreachable")
- It traps
- It prints unreachable
- It hangs
Predict first
Eight jobs run behind a semaphore with value 2. What is the peak?
import Foundation
let sem = DispatchSemaphore(value: 2)
let lock = NSLock()
nonisolated(unsafe) var now = 0, peak = 0
let g = DispatchGroup()
for _ in 0..<8 {
DispatchQueue.global().async(group: g) {
sem.wait()
lock.lock(); now += 1; peak = max(peak, now); lock.unlock()
Thread.sleep(forTimeInterval: 0.05)
lock.lock(); now -= 1; lock.unlock()
sem.signal()
}
}
g.wait()
print("peak concurrent:", peak)
- peak concurrent: 2
- peak concurrent: 8
- peak concurrent: 1
Mental model. A group is a headcount at the school gate: if someone slips out without being counted, the bus never leaves; if the teacher counts someone out who was never in, the register explodes.
A DispatchGroup is a counter: enter() adds one, leave() removes one, and wait() or notify fires at zero. The two bugs are forgetting a leave (the group never reaches zero, so a notify never fires and a wait hangs, often on an error path that returns early) and calling leave too often: the check calls leave() on a fresh group and the process traps (libdispatch detects the unbalanced leave), exiting with a signal. Put the leave in a defer directly after the enter so every path balances, and never call wait() on the main thread.
A semaphore with value N lets at most N holders in: wait() before the work and signal() after, in a defer. The check starts eight jobs on the global queue with a semaphore of 2, and the observed peak is exactly 2. That is how you throttle callback-based work. The traps: wait() blocks a thread, so doing it on the main thread freezes the UI, and doing it inside many global-queue blocks can use up the thread pool (thread explosion, Q290); and a semaphore must not be destroyed while its value is below its initial value. In Swift concurrency prefer a bounded task group (Q293) over semaphores: waiting on a semaphore inside async code blocks a cooperative thread and can deadlock the pool.
libdispatch detects the unbalanced leave and crashes the process.
The semaphore admits two at a time.
Common traps
- Returning early between
enter()andleave(). The group never completes; usedefer. - Using
semaphore.wait()inside async code. It blocks a cooperative thread and starves the pool.
They might follow up with
- Is a semaphore a lock? A semaphore of 1 behaves like one but has no owner or priority donation, so prefer a real lock for mutual exclusion.
- When is
notifybetter thanwait? Almost always: it does not block a thread.
Related questions
- 10.11.2 What does DispatchQueue.concurrentPerform do, when is it better than a loop of async calls, and how do you…
- 10.8.1 In what order do task group results arrive, and how do you limit how many children run at once?
- 10.11.3 How does a DispatchSourceTimer differ from Timer, and what are the lifecycle rules that crash if you break…
- 10.1.2 How do barriers, groups, semaphores and dispatch sources coordinate work?
- 10.1.3 What are thread explosion and priority inversion, and how do QoS and the system help?
- 10.7.1 How do you find and fix deadlocks, data races and slow main-actor hops?
What does DispatchQueue.concurrentPerform do, when is it better than… →