Why does my Swift actor deadlock when I call it from a Task?
I have an actor that owns a WebRTC connection. When I call await conn.send(frame) from inside a Task { } started in the actor itself, the app hangs. No crash, just nothing happens.
actor Connection {
private var queue: [Frame] = []
func enqueue(_ f: Frame) {
queue.append(f)
Task { await self.flush() }
}
func flush() async {
while let f = queue.first {
queue.removeFirst()
try? await transport.send(f) // suspends
semaphore.wait() // waits for ack
}
}
}The hang is semaphore.wait(). It blocks the thread, and the thread it blocks is one of the cooperative pool threads that Swift concurrency needs to deliver the ack that would signal the semaphore. You’ve built a one-line deadlock: the ack can’t arrive because the thread that would deliver it is parked waiting for the ack.
Rules that get you out of this class of bug:
- Never block inside an actor or a Task. No
DispatchSemaphore.wait(), nosleep(), no.wait()on a group. Blocking a cooperative thread starves everything. - Turn the ack into something awaitable: a
CheckedContinuationresumed by the ack callback, or anAsyncStreamof acks youfor awaiton.
Minimal fix:
func flush() async {
while let f = queue.first {
queue.removeFirst()
try? await transport.send(f)
await withCheckedContinuation { cont in
transport.onAck = { cont.resume() }
}
}
}
Two more things in your snippet:
Task { await self.flush() }from insideenqueuecan start several concurrentflushloops ifenqueueis called quickly; guard it with anisFlushingflag so only one loop runs.- The
while let f = queue.first+removeFirst()pair is fine inside an actor, but make suretransport.sendisn’t itself hopping back to the main actor for UI reasons; that’s the second most common “it just hangs”.
How do I see which thread is blocked?
Pause in Xcode when it hangs and look at the thread list: you’ll see a thread named like com.apple.root.default-qos.cooperative sitting in __ulock_wait under DispatchSemaphore.wait. That thread name is the giveaway — anything blocking on a “cooperative” thread is a bug.
Also turn on the runtime warning: set LIBDISPATCH_COOPERATIVE_POOL_STRICT=1 in the scheme’s environment. It makes the cooperative pool a single thread, so this class of deadlock shows up immediately instead of “sometimes on the customer’s machine”.
Comments (0)
No comments yet.
Sign in to comment.