Skip to content

Replace Task { } in priority-escalation callback with atomic + unlock-helper pattern #6

Description

@EngOmarElsayed

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

  1. Allocation per escalation. Each fired callback spawns a new Task, with the associated allocation and scheduling cost.
  2. 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.
  3. No coalescing. Rapid back-to-back escalations spawn multiple tasks, all racing for the same lock and performing redundant work.
  4. 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.
  5. 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

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions