Repository navigation
Replace Task { } in priority-escalation callback with atomic + unlock-helper pattern #6
Description
Summary
The current withEscalationMonitoring implementation defers priority-escalation work via Task { } because the escalation callback fires synchronously and cannot directly take NSLock (risk of self-deadlock if the firing thread already holds the lock).
This issue proposes replacing the Task { } deferral with a lock-free signaling pattern using ManagedAtomic (from swift-atomics) plus an unlock-helper. The escalation callback becomes fully synchronous, allocation-free, and free of Sendable workarounds, while preserving all current semantics.
Current implementation
private func withEscalationMonitoring<Result, Failure: Error>(
_ body: () async throws(Failure) -> Result
) async throws(Failure) -> Result {
guard #available(macOS 26.0, macCatalyst 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *) else {
return try await body()
}
nonisolated(unsafe) let uncheckedSelf = self
return try await withTaskPriorityEscalationHandler { () throws(Failure) -> Result in
try await body()
} onPriorityEscalated: { _, newPriority in
Task {
uncheckedSelf.escalatePriority(to: newPriority)
}
}
}Why the Task { } exists
The escalation callback (onPriorityEscalated) is invoked synchronously by the Swift runtime, potentially on a thread that is currently executing inside closeGate or openGate — i.e. a thread that already holds the gate's NSLock. A synchronous call to escalatePriority from that context would self-deadlock on the non-recursive lock. The Task { } defers the work to a separate execution context where the lock is presumably free.
Problems with the current approach
- Allocation per escalation. Each fired callback spawns a new
Task, with the associated allocation and scheduling cost. - Indeterminate delay. The escalation propagation runs at "some future point" on the cooperative pool, not at a deterministic moment relative to the gate's lifecycle.
- No coalescing. Rapid back-to-back escalations spawn multiple tasks, all racing for the same lock and performing redundant work.
Sendablegymnastics. Capturingselfin the@Sendabletask closure requiresnonisolated(unsafe) let uncheckedSelf = selfto bypass Sendable analysis on the non-SendableAsyncGatetype.- Unbounded fan-out potential. If escalations fire faster than tasks drain, the queue of pending tasks grows.
Proposed solution: atomic signal + unlock-helper
Use ManagedAtomic<UInt8> to record the highest pending escalation priority, and ensure every lock release applies any pending escalation before unlocking. This eliminates the deferral entirely — the escalation work is performed by whichever thread is already going to release the lock.
Pattern overview
- Escalation callback writes the requested priority into an atomic using max-semantics (only writes if higher than current), then opportunistically tries to acquire the lock. If the lock is free, it applies the escalation itself. If not, it returns immediately and trusts the current lock-holder to apply it on release.
- Lock-holder, on every unlock path, calls a helper that drains the atomic and applies any pending escalation under the lock it already holds, then unlocks.
This is the standard "publish via atomic, consume under lock" concurrency pattern.
Proposed code
import Atomics
public final class AsyncGate {
// ... existing state ...
private let pendingEscalation = ManagedAtomic<UInt8>(0)
/// Drain any pending escalation request and apply it.
/// MUST be called with `lock` held.
private func applyPendingEscalationLocked() {
let raw = pendingEscalation.exchange(0, ordering: .acquiringAndReleasing)
guard raw > 0, let priority = TaskPriority(rawValue: raw) else { return }
state.pending?.escalatePriority(to: priority)
}
/// Replacement for `lock.unlock()` everywhere in the class.
/// MUST be called with `lock` held; releases the lock.
private func releaseLockApplyingEscalation() {
applyPendingEscalationLocked()
lock.unlock()
}
public func escalatePriority(to priority: TaskPriority) {
// Record the highest requested priority (max-semantics).
let requested = priority.rawValue
var current = pendingEscalation.load(ordering: .relaxed)
while requested > current {
let (exchanged, observed) = pendingEscalation.compareExchange(
expected: current,
desired: requested,
ordering: .acquiringAndReleasing
)
if exchanged { break }
current = observed
}
// Fast path: if the lock is free, apply immediately.
// Slow path: the current lock-holder will apply on their way out.
if lock.try() {
applyPendingEscalationLocked()
lock.unlock()
}
}
private func withEscalationMonitoring<Result, Failure: Error>(
_ body: () async throws(Failure) -> Result
) async throws(Failure) -> Result {
guard #available(macOS 26.0, macCatalyst 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *) else {
return try await body()
}
return try await withTaskPriorityEscalationHandler { () throws(Failure) -> Result in
try await body()
} onPriorityEscalated: { _, newPriority in
escalatePriority(to: newPriority)
}
}
}All current call sites of lock.unlock() inside closeGate, openGate, escalatePriority (the synchronous getters), and any other state mutation must be routed through releaseLockApplyingEscalation() to maintain the invariant.
Properties of the proposed solution
| Property | Current (Task { }) |
Proposed (atomic) |
|---|---|---|
| Allocation per escalation | Yes (Task) | No |
| Deferral time | Until cooperative pool runs the task | Until next unlock (microseconds) |
| Coalesces rapid escalations | No (N tasks for N callbacks) | Yes (max-semantics in atomic) |
| Priority of propagation work | Inherits escalated priority via Task |
Lock-holder is already the escalated task |
Sendable workaround needed |
Yes (nonisolated(unsafe)) |
No |
| Self-deadlock risk | Avoided via deferral | Avoided via try-acquire |
Correctness sketch
The invariant is: any escalation request will be applied before the next closeGate returns or openGate releases the lock.
- Fast path: The escalation callback acquires the lock via
try(), applies the escalation, releases. Direct application, no deferral. - Slow path: The escalation callback fails to acquire the lock because some thread X holds it. The request is recorded in the atomic before
try()is attempted, so the write is visible by the time thread X reaches its unlock helper. Thread X reads the atomic inapplyPendingEscalationLocked()and applies the escalation before releasing. - Race between fast and slow: If the callback's
try()succeeds, it drains the atomic. If it fails, the holder drains it. Either way, the atomic is read and cleared by exactly one party — theexchange(0, ...)ensures no double-application. - Max-semantics: If multiple callbacks fire in rapid succession with different priorities, only the highest is retained. The lock-holder applies the highest once, rather than applying each in sequence.
Behavioral compatibility
The proposed change preserves all externally observable behavior:
withGate { }continues to serialize as before.- Escalation requests continue to reach the pending continuation queue.
- Priority of the propagation work matches or exceeds the current implementation (the lock-holder is the task being unstuck, which has already been escalated by the runtime).
The only externally visible difference is that escalation propagation may be applied marginally faster (no Task scheduling delay) and is bounded relative to the gate's lifecycle.
References
swift-atomicspackage: https://github.com/apple/swift-atomics- Swift
Synchronizationmodule (nativeAtomic): https://developer.apple.com/documentation/synchronization/atomic withTaskPriorityEscalationHandler: https://developer.apple.com/documentation/swift/withtaskpriorityescalationhandler
Activity
mattmassicotte commentedon May 26, 2026
Lock-free systems are very difficult to reason about. Is the existing implementation actually a problem?
EngOmarElsayed commentedon May 26, 2026
You're right — I don't have evidence that the current implementation is
causing real problems. The proposal trades a simple lock + Task pattern
for a lock-free signaling pattern that's harder to audit, in exchange for
benefits that are mostly theoretical at the scale this primitive operates
at.
The
Task { }is genuinely a bit awkward to explain, but "awkward toexplain" isn't worth the added complexity of atomics and the discipline
of routing every unlock through a helper.
Happy to close this unless you see value in keeping it open as a future
reference.
mattmassicotte commentedon May 26, 2026
Well, I'm happy to discuss! But I want to know what you think.
EngOmarElsayed commentedon May 31, 2026
To be honest, I think it’s nice to add because it will make the code easier to reasone about than using
Task {}. But at the same time usingTask {}isn’t wrong but doesn’t make sense to use it in that context.This is why I am a little bit baised to implementing it with ManagedAtomic and at the same time we will not remove the lock to avoid increasing the complexity.
We will only use the
ManagedAtomicin the priority escalation to prevent opening aTask {}in the priority escalation callback because it doesn’t feel right to open aTask {}there specially there is no clear reasone why it’s done and can easily be miss understood.At the same time using
ManagedAtomicfor the priority escalation isn’t that difficult.mattmassicotte commentedon May 31, 2026
Here's why I'm hesitating. Lock-free algorithms are probably the most challenging of all. They are notoriously difficult to get right. It would require considerably more testing to prove it, and that might actually be impossible to do. They aren't just hard to write, they are also extremely difficult to test.
I doubt the above code is bug-free. It could be! But proving it will be next to impossible. I think there would have to be an extremely compelling reason to look at atomics.
The existing implementation just needs a comment explaining why the Task is necessary. And then done!
EngOmarElsayed commentedon Jun 1, 2026
Totally agree with you
EngOmarElsayed commentedon Jun 1, 2026
#7