-
Notifications
You must be signed in to change notification settings - Fork 0
11 — DispatchSemaphore
The sauté station in the back has exactly two seats. Two burners, two cutting boards, two chefs working elbow-to-elbow — that's the physical limit. It's not a rule written on a clipboard; it's just how many bodies actually fit at that bench.
Two chefs grab their tickets and sit down. They start cooking, side by side, both stations busy. A third chef, ticket in hand, walks up to sauté — and there's nowhere to sit. Every seat is taken. This chef doesn't barge in and cook standing over someone's shoulder, and doesn't wander off and give up on the order either. The chef just stands there, waiting, ticket still in hand, watching the two occupied seats.
Eventually one of the seated chefs finishes, gets up, and walks away. The instant that seat is empty, the waiting chef takes it and starts cooking. If a fourth and fifth chef had shown up in the meantime, they'd be waiting too — however many chefs arrive, only two are ever cooking at that station at once, and everyone else queues for a seat to open up.
Notice this isn't the order line from Chapter 3 gatekeeping which chef gets a ticket. Every chef here already has a ticket — the queuing is over a seat, a physical resource with a fixed, countable capacity. That's a different kind of limit than "serial" or "concurrent" describe: not one-at-a-time, not unlimited-at-once, but exactly N-at-once, whatever N happens to be.
👉 A semaphore isn't managing tickets — it's managing seats. Any chef can hold a ticket for the station; only as many as there are seats can actually be sitting there at once.
DispatchSemaphore(value:) creates a counting semaphore, and value is the number of seats — the initial count of how many callers are allowed through at once before anyone has to wait.
Two operations, and both matter:
-
.wait()— decrements the count by one. If the count after decrementing is still zero or greater, a seat was available, and.wait()returns immediately — the chef sits down and gets to work. If decrementing would take the count below zero, there was no seat free, and.wait()blocks the calling thread right there, parking it until someone else frees a seat. -
.signal()— increments the count by one. If any thread is currently parked inside.wait(), one of them is woken up and allowed to proceed — the equivalent of a seat opening up and the next waiting chef sitting down.
This is a different mechanism from DispatchGroup in Chapter 10, even though both involve a counter. A group tracks how many pieces of outstanding work remain and fires a notification once that count hits zero — nobody's thread is ever forced to sit still waiting for it. A semaphore's count instead caps how many callers can be past the gate at once, and a caller that arrives when the gate is full doesn't get notified later — it blocks, right there, on the spot, until room opens up.
There's no group.async-style sugar here, either. Every .wait() needs exactly one matching .signal(), called by hand, and GCD does nothing to help you keep them balanced. That's exactly why the tiny example below reaches for defer:
let semaphore = DispatchSemaphore(value: 2) // only 2 concurrent seats
func useStation() {
semaphore.wait()
defer { semaphore.signal() }
// do the limited-concurrency work
}defer guarantees signal() runs on every exit path out of useStation() — the normal fall-through at the end, an early return buried in the middle, or an error thrown partway through. Without it, a single forgotten exit path is enough to permanently lose a seat: the count never climbs back to where it should be, and every .wait() call from then on has one fewer seat to compete for than it should. Two or three leaks like that in a busy station, and the "station" effectively shrinks to zero seats — every future chef who shows up just waits forever, with nothing pointing at why.
A warning worth taking seriously: .wait() doesn't suspend anything politely — it blocks the calling thread, exactly like .sync from Chapter 4 and Chapter 8. That means it carries the exact same deadlock risk. If a chef calls .wait() on the main thread, and the matching .signal() is only ever going to run as a block scheduled on the main thread too — say, dispatched with DispatchQueue.main.async — that signal can never fire, because the main thread is the one thing that could run it, and the main thread is frozen inside .wait(), waiting on a seat that can only be freed by itself. Same frozen-chef shape as Chapter 8's deadlock, just reached through a semaphore instead of a nested .sync.
STATION — 2 seats 🪑🪑 semaphore = DispatchSemaphore(value: 2)
🧑🍳 Chef A ──wait()──> seat 1 (count: 2 → 1, seat taken, cooking)
🧑🍳 Chef B ──wait()──> seat 2 (count: 1 → 0, seat taken, cooking)
🧑🍳 Chef C ──wait()──> 🚫 station full — BLOCKED
(count would go 0 → -1, so Chef C waits)
│
Chef A finishes, calls signal()
(count: 0 → 1)
│
▼
Chef C's wait() unblocks,
takes the freed seat, starts cooking
(count: 1 → 0)
Chef C isn't polling, isn't checking back every few seconds — the thread is parked, asleep, until the exact moment .signal() runs and wakes it.
let semaphore = DispatchSemaphore(value: 2) // only 2 concurrent seats
func useStation() {
semaphore.wait()
defer { semaphore.signal() }
// do the limited-concurrency work
}
for _ in 0..<5 {
DispatchQueue.global().async {
useStation()
}
}👉 Five chefs show up, but only two are ever cooking at the sauté station at the same instant — the other three are parked inside .wait(), one seat opening up at a time as each useStation() call finishes and its defer fires.
Swap value: 2 for value: 1 and the exact same code stops being "cap concurrency at N" and becomes something more specific: a lock. With only one seat, at most one chef is ever inside useStation()'s critical section at a time — the second, third, fourth, and fifth callers all queue up behind whichever one got there first, one after another, never overlapping.
Swift Concurrency's answer to "only one caller touches this state at a time" is the actor:
actor Station {
func useStation() {
// guaranteed exclusive access — no wait(), no signal(), no counter
}
}No wait(), no signal(), no counter to get out of balance, no defer standing guard over a manual release. The compiler enforces that only one task's code runs inside Station at a time, full stop — mutual exclusion is a property of the type, not a runtime discipline you have to remember to uphold correctly every single call site.
The deeper reason the two don't behave the same way comes down to how each one makes a caller wait. A semaphore's .wait() blocks a thread — a full OS thread sits frozen, doing nothing, occupying a slot in whatever pool it came from, until a .signal() wakes it. An actor, by contrast, suspends the task, not the thread — when a task tries to enter an already-busy actor, it's parked without holding a thread hostage, and the thread underneath is freed to go run other work entirely, exactly like await versus .sync in Chapter 8.
That distinction isn't academic once semaphores show up inside async code. Swift Concurrency runs on a cooperative thread pool sized roughly to the number of CPU cores — a small, fixed set of threads meant to always be either running work or suspended, never blocked. Call semaphore.wait() from inside a Task and the thread running that task doesn't suspend — it blocks, taking one of those few cooperative threads out of circulation entirely while it waits. Do that enough times concurrently, and it's possible to starve the pool: every cooperative thread parked inside a .wait() call, with no thread left free to ever run the work that would call the matching .signal(). An actor never creates that hazard, because "waiting your turn" for an actor was never implemented as blocking a thread in the first place — it's implemented as a task quietly stepping aside.
👉 A semaphore caps concurrency by parking whole threads until a seat opens; an actor caps concurrency by suspending tasks and never touching a thread at all — which is exactly why porting DispatchSemaphore habits straight into async code is a real hazard, not just an outdated style.
A semaphore is a station with a fixed number of seats.
A real host standing at a station with two seats knows exactly who's sitting, who's waiting, and roughly how long each chef has been in line — ask, and they can tell you. DispatchSemaphore keeps none of that.
- It's a bare integer counter and nothing more. There's no list of who's currently blocked in
.wait(), no way to ask "how many chefs are waiting right now," no timestamp on when each one arrived. - If the count ever drifts — a missing
.signal()shrinks the effective number of seats, or a stray extra.signal()hands out a seat that shouldn't exist — nothing detects it. The counter has no idea what the "correct" value should be; it just tracks whatever sequence of increments and decrements actually happened. - From the outside, a semaphore that's run out of seats because of a leaked
.signal()looks identical to one that's just legitimately busy. A hung.wait()call gives no signal, no log, no distinction between "working as designed, seat's just taken" and "broken forever, that seat is never coming back."
So "a station with seats" captures the capacity limit — but not the fact that a real host would notice something wrong immediately, while DispatchSemaphore just silently keeps counting, correct or not, with no way to ask it what went wrong.
What does DispatchSemaphore(value: 1) behave like once multiple chefs start calling .wait() on it — and why?