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.
Sendable gymnastics. Capturing self in the @Sendable task closure requires nonisolated(unsafe) let uncheckedSelf = self to bypass Sendable analysis on the non-Sendable AsyncGate type.
- 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 in applyPendingEscalationLocked() 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 — the exchange(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
Summary
The current
withEscalationMonitoringimplementation defers priority-escalation work viaTask { }because the escalation callback fires synchronously and cannot directly takeNSLock(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 usingManagedAtomic(fromswift-atomics) plus an unlock-helper. The escalation callback becomes fully synchronous, allocation-free, and free ofSendableworkarounds, while preserving all current semantics.Current implementation
Why the
Task { }existsThe escalation callback (
onPriorityEscalated) is invoked synchronously by the Swift runtime, potentially on a thread that is currently executing insidecloseGateoropenGate— i.e. a thread that already holds the gate'sNSLock. A synchronous call toescalatePriorityfrom that context would self-deadlock on the non-recursive lock. TheTask { }defers the work to a separate execution context where the lock is presumably free.Problems with the current approach
Task, with the associated allocation and scheduling cost.Sendablegymnastics. Capturingselfin the@Sendabletask closure requiresnonisolated(unsafe) let uncheckedSelf = selfto bypass Sendable analysis on the non-SendableAsyncGatetype.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
This is the standard "publish via atomic, consume under lock" concurrency pattern.
Proposed code
All current call sites of
lock.unlock()insidecloseGate,openGate,escalatePriority(the synchronous getters), and any other state mutation must be routed throughreleaseLockApplyingEscalation()to maintain the invariant.Properties of the proposed solution
Task { })TaskSendableworkaround needednonisolated(unsafe))try-acquireCorrectness sketch
The invariant is: any escalation request will be applied before the next
closeGatereturns oropenGatereleases the lock.try(), applies the escalation, releases. Direct application, no deferral.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.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.Behavioral compatibility
The proposed change preserves all externally observable behavior:
withGate { }continues to serialize as before.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-atomicsSynchronizationmodule (nativeAtomic): https://developer.apple.com/documentation/synchronization/atomicwithTaskPriorityEscalationHandler: https://developer.apple.com/documentation/swift/withtaskpriorityescalationhandler