-
Notifications
You must be signed in to change notification settings - Fork 0
08 — Deadlocks
Imagine handing a chef a ticket that says: "Before you start this order, wait for yourself to finish this order."
The chef stares at it. To finish the order, he has to start it. To start it, he has to finish it first. There's no move that gets him out of that sentence. He doesn't shrug and cook anyway — the instruction is the instruction. So he just stands there, frozen, forever, holding a ticket that can never be satisfied.
Nobody else did this to him. He's not waiting on another chef, another station, another ticket. He's waiting on himself — and that's exactly what makes it unbreakable. There's no order ahead of him to finish first, no chef to tap on the shoulder and ask for a hand. The wait has no end because it was never possible to begin with.
👉 That frozen chef, stuck on an instruction that can only complete after it completes, is a deadlock.
This is exactly what Chapter 4 warned about and set aside for later: calling .sync on the queue you're already running on deadlocks.
Here's why, step by step:
- A chef is currently executing a block on serial queue
Q. - That block calls
Q.sync { ... }— submitting more work toQ, and blocking until it finishes. - But
Qis serial — it runs one block at a time, in order. The new block can't start until the currently running block (the one that just called.sync) finishes. - The currently running block can't finish, because it's blocked, waiting for
.syncto return. -
.synccan't return until its block runs. Its block can't run until the calling block finishes. The calling block can't finish until.syncreturns.
Nobody moves. That's the circular wait from the intuition section, expressed in queue terms instead of ticket terms.
The classic, most common way developers hit this by accident: calling DispatchQueue.main.sync { } from code that's already running on the main thread. The main queue is serial, and it's already executing your code — so asking it, mid-execution, to synchronously run more work on itself is the "wait for yourself to finish" ticket. It hangs forever, and in a UI app it typically freezes the entire interface, since the main queue is exactly what's supposed to be pumping UI events.
Note this is specific to the queue you're currently on, not queues in general — calling .sync on some other serial queue, one you're not already executing on, is completely fine and is a normal, common pattern.
CIRCULAR WAIT 🔒
Block A (running on queue Q)
│
│ calls Q.sync { B }
▼
Block A is now BLOCKED, waiting for B to finish
│
▼
Queue Q is serial — can't start B until A finishes
│
▼
Block B waits for Q ──────┐
▲ │
└──────────────────────┘
A waits for B, B waits for Q, Q waits for A.
Nobody moves. Forever. 💀
// DO NOT RUN — this deadlocks
func onMainThread() {
DispatchQueue.main.sync {
print("Never reached 💀")
}
}👉 Q.sync from inside Q is the "wait for yourself to finish" ticket in code form — the currently running block can't finish until .sync returns, and .sync can't return until the currently running block finishes.
This function only deadlocks if it's called from the main thread — that's the trap. onMainThread() looks completely harmless from the outside; nothing about its signature warns you. Attach it to a button's didTap handler (which fires on the main thread) and tapping the button freezes the app instantly, with no crash, no log, no error — just silence, because the main chef is stuck waiting for himself.
If the same function were called from a background queue instead, it wouldn't deadlock at all — .sync onto the main queue from elsewhere is a normal, if less common, thing to do. The danger is entirely about which chef is already standing where when .sync gets called.
await inside an actor can look reentrant in exactly the shape that would deadlock GCD — code calling back into work that depends on itself — and yet it doesn't lock up. The reason is what await actually does to the chef:
-
.syncblocks the thread. The chef physically cannot do anything else until the block returns — he's frozen, occupying that thread, contributing nothing. -
awaitsuspends the task, not the thread. When execution hits anawait, the chef is freed to go do other work — run a different task, service a different actor, whatever's next — and the suspended task resumes later, whenever its result is ready, not necessarily even on the same chef.
👉 .sync parks the chef in place until the wait resolves; await sends the chef off to do other work and calls him back later — so the same "waits on itself" shape that freezes .sync solid just suspends and resumes with await.
Because the chef never actually stops moving, the "wait for yourself to finish" trap mostly can't form the way it does with .sync. An actor awaiting work that circles back to itself doesn't wedge a thread in place — it just suspends and resumes as dependencies resolve. This doesn't mean async code is immune to every kind of stuck-forever bug (a task that awaits something that never completes still never completes), but the specific, classic GCD deadlock — a chef frozen solid, blocking a thread that can never come free — mostly disappears once nothing is blocking a thread in the first place.
Never make a chef wait in his own line.
In a real kitchen, a chef handed an impossible ticket would eventually notice the absurdity. He'd raise an eyebrow, put the ticket down, and go ask someone for help — or just refuse the order and move on to the next one. A human has an escape hatch: judgment.
GCD has no such thing:
-
.syncdoesn't detect the trap, doesn't time out, doesn't log a warning. It just blocks, unconditionally, waiting for a return that structurally cannot happen. - There's no built-in deadlock detector watching for this pattern the way some database systems watch for lock cycles. The queue has no concept of "this will never resolve" — it just keeps waiting.
- The only fix is never writing the instruction in the first place — checking, whenever you call
.sync, that you aren't already standing on the queue you're calling it on.
So "the chef would just notice" is the one place the analogy breaks down completely. A real chef has common sense to fall back on. GCD has none — it hangs forever, silently, with no crash and no clue, until something outside the process notices the app isn't responding.
Why does DispatchQueue.main.sync { } always deadlock when called from code already running on the main thread — even though calling .sync on other queues is perfectly normal?