- Published on
How Do You Build a Mutex That Works with async/await ?
- Authors

- Name
- Omar Elsayed
Introduction
A couple of days ago I ran into a really interesting use case: I wanted to protect a method from being called more than once at a time. You might think "actor, done" — but nope, an actor won't save you here.
Because of actor reentrancy, the moment your method hits an await and suspends, another task can sneak in and start running the same method.
Mutual exclusion across suspension points? Not guaranteed.
The classic tool for "only one thing touches this at a time" is a Mutex — in general CS terms, a primitive that ensures only one thread or task can access a specific resource at any given moment. And with the new Synchronization framework, we finally have Mutex available natively in Swift.
In my case the "resource" I needed to protect wasn't a piece of state — it was the act of calling a method that mutates the backend database. I wanted to make sure no two tasks could ever be inside it simultaneously.
Here's the catch: my method is async, and Mutex doesn't play with async/await at all. You can't await inside its critical section. 🤔
That's when I reached out to Matt Massicotte, and he pointed me to TaskGate — his package built exactly for this.
It exposes two types, AsyncGate and AsyncRecursiveGate, that let you define asynchronous critical sections: only one task enters at a time, but unlike a traditional lock, you can safely await while the gate is held.
I found it genuinely elegant, so I kept digging through the source to understand exactly how it works — and today I'm sharing that with you.
Before we dive in, a special thanks to Matt Massicotte. Without his guidance I'd still be lost in Modern Concurrency. Thank you for everything you share with the community, and for your contributions to Swift itself. 🙏
The logic behind TaskGate
Forget Swift for a second. At its core, AsyncGate is just a doorway with a single-file turnstile and a waiting line behind it. It keeps track of only two things:
AsyncGate holds just two pieces of state:
locked? ──▶ is anyone inside the critical section right now?
waiters ──▶ [ T2 , T3 , … ] a FIFO line of parked tasks
That's the whole machine. Everything else is just rules for how tasks move through it.
When the first task shows up and the gate is open, there's no drama. It walks straight through, and the gate locks behind it.
T1 ──▶ gate OPEN? ──yes──▶ T1 walks in ──▶ gate now LOCKED
│
▼
T1 runs the critical section
(free to await inside ✓)
And that last line is the part that separates this from a plain Mutex. Once T1 owns the section, it can await all it likes in there — hit a suspension point, fire off a network call, come back — and the gate stays locked the entire time.
The interesting path: the door's already locked
Now a second task arrives while T1 is still inside. A traditional lock would block the thread right here.
We can't do that — we're in async/await land, and blocking a cooperative thread is how you ruin everyone's afternoon. 😅
So instead of blocking, the task parks:
gate is LOCKED (T1 inside)
T2 ──▶ gate OPEN? ──no──▶ T2 parks:
• suspends (no thread blocked)
• leaves a `continuation` = its ticket
• joins the back of the line
waiters: [ T2 ] T3 shows up too → [ T2 , T3 ]
Think of the continuation as a numbered ticket. The task hands it over, steps out of the way, and simply stops existing on the CPU until someone calls its number.
The handoff: the part that clicks
Here's the move I find genuinely elegant. When the task inside finishes, it does not just throw the door open and let everyone stampede in. It checks the line:
T1 finishes ──▶ any waiters in line?
├── no ──▶ flip gate back to OPEN, done
│
└── yes ──▶ pop FIRST in line (FIFO)
resume its `continuation`
└─▶ T2 wakes up already holding the gate
(the gate never reopened —
the key was passed hand-to-hand)
That second branch is the trick. The gate never actually opens — ownership is handed directly from the task that's leaving to the next one in line.
The waiter wakes up exactly where it parked, now owning the section, and the cycle repeats. No scramble to grab the lock, no thundering herd, and tasks get served in the order they arrived.
One last thing worth planting in the reader's head before we look at code: the gate has its own little pile of mutable state — the locked? flag and the waiters line — and that state needs protecting too. It guards its own bookkeeping with a plain NSLock.
Here's the subtlety that makes it work: that lock is only ever held for the tiny, synchronous moments — flip the flag, append a ticket, pop the next one — and never across the await itself.
The "who's allowed inside the critical section" question is answered by the continuation handoff we just walked through; the NSLock is only there to keep the gate's internal state from being corrupted while two tasks poke at it at once.
🤔 A neat little "who guards the guard" detail we'll see play out in the code.
The actual implementation
Here's the fun part: the diagrams we just drew aren't an analogy that sort of matches the code.
They basically are the code. Once you've got the doorway, the waiting line, and the handoff in your head, the source reads like a transcript of it.
Let's go piece by piece.
The shape of the state
Remember the two things the gate tracks — "is anyone inside?" and "the waiting line." Here's how that's modeled:
public final class AsyncGate {
private enum State {
case unlocked
case locked(PendingContinuationQueue)
}
private let lock = NSLock()
private var state: State = .unlocked
}
Notice the enum fuses both pieces into one value. .unlocked is the open door — no line, nothing to track.
.locked(queue) is the door shut with the waiting line carried right inside the case, there's no separate boolean flag; "locked" and "who's waiting" are the same fact.
And there's the NSLock this is what guards state — every read and write of it goes through this lock. The gate protects itself.
Closing the gate (acquiring it)
This is our two paths — "walk in" and "park in the line" — as a single switch:
private func closeGate() async {
takeLock() // thin wrapper around lock.lock()
switch state {
case .unlocked:
// door was open — walk in, lock it behind us
self.state = .locked(PendingContinuationQueue())
releaseLock()
case .locked(var pending):
// someone's inside already — take a ticket and wait
await withCheckedContinuation { continuation in
pending.add(continuation)
self.state = .locked(pending)
releaseLock()
}
}
}
The .unlocked branch is the happy path diagram: the door's open, so we create a fresh (empty) line, flip the state to .locked, and we're now the one inside.
The .locked branch is the parking diagram: we hand over a continuation — our numbered ticket — append it to the line, write the line back into the state (it's a struct, a value type, so we have to reassign it), and then wait.
Now, two details I really want you to catch here, because they're the whole reason this type exists:
First — when the lock is released. Look closely at the parking branch. We takeLock() at the top, and we releaseLock() inside the withCheckedContinuation closure.
That closure runs synchronously, before the task actually suspends. So the lock is held only long enough to register our ticket and update the state — and it's already released by the time we're genuinely sitting in the line waiting.
The lock never spans the wait. Exactly the "held only for the tiny synchronous bookkeeping".
Second — why this can't be a Mutex. Registering the continuation has to happen wrapped around an await (the suspension is the whole point).
But Mutex's critical section is synchronous — you cannot await inside withLock.
That single constraint is why we're reaching for an NSLock plus a continuation instead of the shiny new Mutex.
This is the exact problem from the intro, staring back at us in the implementation. 😅
Opening the gate (releasing it)
The handoff diagram — the part I said was the elegant bit — is just this:
private func openGate() {
lock.withLock {
guard var pending = state.pending else {
preconditionFailure("Gate is not closed")
}
if pending.isEmpty {
self.state = .unlocked // nobody waiting → actually open the door
return
}
pending.resumeNext() // hand the key to the first in line
self.state = .locked(pending) // ...so the door never reopens
}
}
Empty line? Flip back to .unlocked and walk away. Someone waiting? resumeNext() pops the first ticket and resumes it — that's the key being passed hand-to-hand — and we stay .locked.
The gate never actually opens when there's a line; ownership transfers straight to the next task. No stampede, no race to grab it.
Putting it together: withGate
This is the public API you actually call. Ignore the withEscalationMonitoring wrapper for one more minute — that's the priority story, and I'm saving it for the end.
public func withGate<Result, Failure>(
_ body: () async throws(Failure) -> Result
) async throws(Failure) -> Result where Failure: Error {
try await withEscalationMonitoring { // ← parking this; explained at the very end
await closeGate() // acquire (maybe wait)
do throws(Failure) {
let value = try await body() // run your critical section
openGate() // release
return value
} catch {
openGate() // release on the error path too
throw error
}
}
}
The flow is dead simple: close the gate → run your body → open the gate. The do/catch exists for one reason — to make sure the gate opens whether your closure returns or throws.
Skip the release on the error path and you've deadlocked everyone behind you 😅.
The typed throws(Failure) is a nice touch too: it preserves your actual error type instead of flattening everything to any Error.
The waiting line itself
Last structural piece — the line is, unsurprisingly just an array:
struct PendingContinuationQueue {
typealias Continuation = CheckedContinuation<Void, Never>
struct Pair {
let continuation: Continuation
let task: UnsafeCurrentTask
}
private var pending: [Pair] = []
mutating func add(_ continuation: Continuation) {
guard let task = withUnsafeCurrentTask(body: { $0 }) else {
preconditionFailure("this API cannot be used outside of an asynchronous function")
}
pending.append(Pair(continuation: continuation, task: task))
}
mutating func resumeNext() {
let pair = pending.removeFirst() // front of the line — true FIFO
pair.continuation.resume()
}
var isEmpty: Bool { pending.isEmpty }
}
Each entry is a Pair: the continuation (the handle we use to wake the task back up) and the UnsafeCurrentTask it belongs to.
Why bother storing the task and not just the continuation? Hold that thought — it's the setup for the very last section.
add grabs the current task with withUnsafeCurrentTask and appends.
resumeNext does removeFirst — front of the line — which is the fairness guarantee from our diagram: first to arrive, first to be served.
And that's the entire mechanism, a state enum, an NSLock, an array of tickets, and one continuation handoff.
No magic — just careful bookkeeping around a single await.
One last thing: priority escalation
This is the withEscalationMonitoring wrapper I kept telling you to ignore. It solves a specific, sneaky problem: priority inversion.
Picture a high-priority task stuck in the line behind a bunch of lower-priority work, we don't want that urgent task throttled just because it's politely waiting its turn.
Swift's runtime can escalate a task's priority when something important comes to depend on it — and the gate wants to forward that bump to the tasks it's keeping parked in the queue, so the whole line keeps pace.
private func withEscalationMonitoring<Result, Failure: Error>(
_ body: () async throws(Failure) -> Result
) async throws(Failure) -> Result {
guard #available(macOS 26.0, iOS 26.0, /* …all the 26 OSes… */ *) else {
return try await body() // older OSes: just run it, no-op
}
// capture self in the Task below without making the gate Sendable
nonisolated(unsafe) let uncheckedSelf = self
return try await withTaskPriorityEscalationHandler {
try await body()
} onPriorityEscalated: { _, newPriority in
Task { uncheckedSelf.escalatePriority(to: newPriority) }
}
}
Three things are happening here:
It's brand new, so it gracefully degrades. The whole feature rides on withTaskPriorityEscalationHandler, which arrived with SE-0462 and only exists on the 26-era OSes. So the first move is an #available check — on anything older, it just runs body() and forgets the feature entirely. No behavior change.
It listens for the bump. On a supported OS, it installs an escalation handler. When the runtime raises this task's priority, onPriorityEscalated fires with the new priority, and we spin up a Task to push that priority into the gate.
nonisolated(unsafe) let uncheckedSelf = self is the spicy bit. The gate is deliberately non-Sendable — it's meant to live on a single actor. But that Task {} closure needs to capture self, which normally demands Sendable.
Since every touch of the gate's state already goes through the NSLock, capturing it is actually safe — so instead of marking the whole type Sendable, Matt uses nonisolated(unsafe) to tell the compiler "trust me, I've got this."
And here's what the bump actually does — the payoff for storing that task back in the queue:
// on AsyncGate
public func escalatePriority(to priority: TaskPriority) {
lock.withLock {
state.pending?.escalatePriority(to: priority)
}
}
// on PendingContinuationQueue
func escalatePriority(to priority: TaskPriority) {
guard #available(macOS 26.0, /* …the 26 OSes… */ *) else { return }
for pair in pending {
pair.task.escalatePriority(to: priority)
}
}
Under the lock, it walks every parked task in the line and escalates it. This is why the queue held onto an UnsafeCurrentTask next to each continuation — without a handle on the task, there'd be nothing to escalate.
The result: raise one task's priority and the whole waiting line keeps up, so urgent work doesn't get stranded behind the gate. And once again, on an older OS the loop is simply a no-op.
So the gate isn't just correct — it's a good citizen about priority too.
Not bad for a state enum, a lock, and an array. ⚡️
Conclusion
So — back to where we started. I needed to stop one async method from running twice at the same time, and every obvious tool let me down: actor reentrancy lets tasks interleave across await, and Mutex flat-out refuses to be held across a suspension.
AsyncGate threads that needle — and the most surprising thing about it is how little there is to it.
Strip it down and the whole gate is a two-case enum, an NSLock guarding it, an array of tickets, and a continuation passed hand-to-hand from one task to the next. That's the entire trick.
The "magic" of an async lock turns out to be plain bookkeeping wrapped around the one primitive that bridges the synchronous and asynchronous worlds: the continuation.
Once that clicks, a big chunk of Modern Concurrency stops feeling like a black box and starts feeling like something you could've built yourself.
Two things worth keeping in mind if you reach for it. It's deliberately non-Sendable and meant to live inside a single actor — that's not a limitation, it's the compiler helping you keep the gate where it belongs.
And re-entering the same gate from the same task will deadlock you — you'd be standing in line waiting for a key you're already holding. That's the exact problem AsyncRecursiveGate exists to solve, and now that the basic gate makes sense, the recursive variant is a great next read.
That's the real payoff of cracking open a library like this. You don't just walk away with a tool you can drop into your code — you walk away understanding why it's shaped the way it is, which means the next time you hit a weird concurrency corner, you've got a few more moves in your pocket.
If this kind of thing is your jam — taking a small, well-built library apart just to see what makes it tick — that's most of what I write about here, so stick around and I'll keep them coming.
And one more time: thank you, Matt, for building this and for being so generous with the community. 🙏