What goes wrong with an unbalanced DispatchGroup, and how do you use a semaphore to throttle work without deadlocking?

10.11.1 · L3 · Interview

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() and leave(). The group never completes; use defer.
  • 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 notify better than wait? 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… →