[Pitch 4] Delayed Enqueuing for Executors

This is a fourth pitch for SE-0505: Delayed Enqueuing for Executors, which was returned for revision.

There is a new PR to update the proposal (view changes), which makes the following changes:

  • There is now only a single enqueue<C: Clock>(...) method, rather than having two with default implementations that might mutually recurse if neither is implemented.
  • Added CancellationBehaviour to control what happens if a task is cancelled while enqueued.
  • Made the enqueue API return a JobCancellationToken that can be used to cancel a scheduled job.
  • Added a cancel(jobWithToken:) API to tell the executor to cancel a job.
  • Updated the new APIs on Clock to also return a JobCancellationToken.
1 Like

Thanks, Alastair! A lot of interesting ideas here. I'm going to ignore naming for now. A couple questions:

I see that CancellationBehavior is not frozen. Was this an intentional choice? If so, I think it needs some more discussion. Normally a semantics-deciding enum like this would be expected to be handled exhaustively, which is impossible outside of the standard library if it's not frozen. I think it might be okay here, since executors always have the option of simply ignoring the attempt to cancel the job, but that needs to be acknowledged and its consequences explored.

(A similar question applies to FireTime, except that the asInstant / asDuration API nicely solves the semantic question. I'd say it'd be nice if callers could directly construct the API based on a fixed layout, but the existing clock types have non-frozen Instant types, so that's not possible anyway.)

Is there a reason why CancellationBehavior is set at job submission time rather than job cancellation time? What implementation are you imagining that would benefit from this?

I can imagine a CancellationBehavior that would benefit from being known at submission time, but it's not one that I think you've left any room for in your design — that would be CancellationBehavior.willNotCancel, a promise that there won't be an attempt to cancel the job. But then enqueue should probably return a nil token.

I think that the drop option especially requires cancel to provide feedback about whether cancellation actually succeeded. Otherwise, the API seems to leave the ownership of the job in a permanently unknowable state — there is no point at which a client can know conclusively that the job won't be executed. Also, should the job itself be returned to the client somehow?

I don't love that jobs have to be identified by job ID. Should we maybe just accept that cancellable jobs are a different kind of job that can have extra lifecycle requirements tied to the token? This would prevent us from using task objects directly as cancellable jobs, but I think that's okay; I don't think there's any situation where we really want to schedule the task directly this way anyway. (For example, we don't want to do this during sleep because sleep needs to short-circuit during cancellation, so if cancellation fails we really don't want to leave the task enqueued.)

Not really, we can make it frozen.

I was thinking that not every job we submit for future execution might want the slightly strange run-immediate-on-cancellation behaviour that we need for Tasks.

An example I came up with more recently is repeating timers — at the moment people are forced to use Task.sleep() in a loop, which is quite inefficient (particularly for short timers); I can imagine we might at some point want to be able to enqueue a job that represents a repeating timer, perhaps holding a closure that will be invoked whenever the timer fires, and in that case the right choice when enqueuing would likely be .drop.

The way I have things in the implementations I've built so far, the executor takes responsibility for the job when it is scheduled, and in the .drop case it cleans the job up in the same way it would after executing it.

They don't (and I don't love that option either, which is why there are extra fields in the JobCancellationToken that you can use to avoid having to do that). Some executors may nevertheless want to do that, perhaps because they already have a table mapping job IDs to jobs for some reason.

This is also the reason for the cleanUp closure in the cancellation token; that allows executors to stash things in the opaque data fields that they can then act on when the token is destroyed. The Dispatch executor implementation makes use of this — it stashes the dispatch_source_t reference in the opaque data, and uses that to both locate the job itself and to provide for cancellation. Without that, we would indeed be reduced to using the job ID in a dictionary to locate the job, which would also mean additional locking; that seemed undesirable.

Thanks for pushing this forward, and for the updated pitch. Addressing the cancellation handling is really important since the current implementation leads to enqueued jobs outliving the cancellation which can be an attack vector in services.

I want to step back and look at the overall shape, because I think several of the moving parts here are connected and point toward a cleaner division of responsibility between the runtime and executors.

The problems I see with this updated pitch:

  1. Cancellation lives outside the executor. In the current design Task.sleep needs an AtomicSleepCancellationState, a var token, and a withTaskCancellationHandler wrapping the whole suspension. All of that exists only because the executor can't install a cancellation handler from inside its enqueue method, so the call site has to bridge cancellation to the executor through a token and a hand-rolled state machine. I think this is entirely unnecessary since executors already track state of jobs, their deadlines and any additional resources. More importantly, the executors already need to synchronize this state so the atomic in the runtime can be avoided.
  2. As a result of (1), the implementation of throwing a CancellationError in Task.sleep is actually racy. Since the executor is not responsible for handling the cancellation the code inside Task.sleep calls try Task.checkCancellation() after the executor resumed the job. This check might throw a CancellationError even though the timer successfully ran and was cancelled in the window between the executor resuming the job and the cancellation check. Only the executor actually knows if the job ran successfully or was cancelled.
  3. cleanUp on JobCancellationToken externalizes executor state. The DispatchSource lifetime is the executor's concern. The executor knows when the job runs and when cancel(jobWithToken:) is called so it can release the source itself. A cleanup closure in the public API feels to me like a workaround for Dispatch not being a first-class SchedulingExecutor, and I don't think that implementation detail should surface in the public API.
  4. CancellationBehavior.drop isn't valid for task-based jobs. Every job we enqueue today in this new proposed API is a task, and dropping one hangs the task forever. I personally think we should remove it now and re-propose it once a use-case arises.

In my opinion, all four are symptoms of the same thing: we're doing work in the runtime, and pushing state into the job and the token, that the executor should simply own. Executors already manage exactly this kind of state directly: Dispatch already tracks its sources, a cooperative executor already has its timer queues, a native pthread based executor also tracks its jobs and deadlines, and any executor that can be cancelled from another thread already has synchronization for its bookkeeping. In most cases, cancellation state is just a jobID -> source (or a small pending-jobs map) next to state the executor already keeps, protected by the synchronization primitive it already uses.

So I don't think we need to smuggle executor state through the job's private data or a token's opaqueData/cleanUp at all. Keying off job.id in the executor's own structure is enough, and it's strictly less machinery than reconstructing a parallel token + cleanup + drop/execute policy in the runtime.

As a concrete alternative approach I was thinking we could do this instead:

  1. job.addTaskCancellationHandler { } / job.removeTaskCancellationHandler(_:): A synchronous pair on ExecutorJob that installs (and later removes) a task-cancellation handler using the task context already embedded in the job. This mirrors how withTaskCancellationHandler is already built (add on entry, remove on exit); we're just exposing it so an executor can straddle its own enqueue/run boundary instead of a lexical scope.

The important constraint is that neither enqueue nor the cancellation handler ever runs the job inline. The handler can fire on another thread, and, in the already-cancelled case, it can fire before the task has reached its suspension point, so resuming there would resume a continuation that hasn't suspended yet. Instead, both paths just move the job into the executor's ready state and let the next tick of the executor's own loop actually run it. That keeps a single, clean invariant: jobs are only ever run from the executor's run loop.

// Pseudocode
enum PendingJob {
    case reserved
    case stored(ExecutorJob, TaskCancellationHandler)
    case cancelled(ExecutorJob?)   // nil only transiently, until enqueue completes
}

func enqueue(_ job: consuming ExecutorJob, run at: FireTime<C>, ...) {
    let jobID = job.id
    pendingJobs[jobID] = .reserved                     // under the executor's lock

    let handler = job.addTaskCancellationHandler {       // may fire now, or later, on any thread
        switch pendingJobs.removeValue(forKey: jobID) {
        case .stored(let job, _): pendingJobs[jobID] = .cancelled(job); wakeUp()
        case .reserved:           pendingJobs[jobID] = .cancelled(nil)   // cancelled mid-enqueue
        case .cancelled:  preconditionFailure("handler fired twice")     // genuine invariant violation
        case nil:         break                                          // loop already claimed it (timer fired) — no-op
        }
    }

    switch pendingJobs.removeValue(forKey: jobID) {
    case .reserved:       pendingJobs[jobID] = .stored(consume job, handler)
    case .cancelled:      pendingJobs[jobID] = .cancelled(consume job); wakeUp()
    case .stored, nil:    preconditionFailure("unreachable")
    }
}

On a loop tick the executor removes any job that is either cancelled or the deadline has passed:

// on a loop tick, for a job whose deadline passed or that was cancelled:
switch pendingJobs.removeValue(forKey: jobID) {
case .stored(let job, let handler):          // fired normally
    job.removeTaskCancellationHandler(handler)
    job.runSynchronously(on: self, with: .success(()))
case .cancelled(let job?):                 // cancelled; deliver the error
    job.runSynchronously(on: self, with: .failure(CancellationError()))
case .cancelled(nil), .reserved, nil:
    preconditionFailure("unreachable at run time")
}

Which leads me to the second part that executors should be able to pass/return values to jobs they are running. This will allow the executor to correctly inform the job if it got cancelled or not.

  1. TypedExecutorJob<T, E: Error> with runSynchronously(on:, with: Result<T, E>): An executor-side counterpart of UnsafeContinuation<T, E>. A job is fundamentally a function thunk, so we should be able to pass a value in when we run it, rather than storing a buffer in the job or threading the result through a side channel. The executor delivers the outcome directly through the job, which removes the racy trailing Task.checkCancellation().

I believe with both of these changes we can:

  • Remove a lot of the proposed API surface
  • Make the implementation more performant in the executor implementations
  • Pave the way for a more general I/O executor which also needs to return hypothetical IOResult and IOError to the job.

On Dispatch specifically

The current implementation also uses multiple dispatch sources in the global Dispatch-backed executor. That is far from ideal since every source is at least one allocation and one kernel object. We should be able to use just one source per priority that is armed to the closest deadline.

I'd rather we make Dispatch implement SchedulingExecutor natively than keep runtime machinery that compensates for it not doing so. Dispatch owns its timer sources; if it implements the interface directly it manages source lifetime and cancellation itself, on its own queues, which is also the highest-performance option. This is similar to how we would implement it in the PThreadExecutor in GitHub - swiftlang/swift-platform-executors: This package provides platform-native executors for Swift Concurrency. · GitHub.

4 Likes

I would perhaps say that exposing the fire times as duration based is in it of itself a potential anti-pattern. Ideally all clocks should be considering time in the most meaningful manner based upon instants, because else wise you have to contend with the inaccuracy that is involved with a duration which likely would have to spend time fetching now, calculating and then converting to an instant.

Also the CancellationBehavior is a pretty broad name for a top level type. That could easily be confused with other things. That really feels like a specific sub-type for jobs. Because else wise that runs afoul for close names used in other proposals in flight like the deadline one.

1 Like

This has come up before. Some executors have underlying timer implementations that are capable of waiting for a duration directly, rather than just waiting until a particular instant. On those executors, you might end up computing an instant, then turning it back into a duration again to pass it to the underlying implementation.

That's fair. I think FireTime is likely not the best name either. (I'm open to suggestions on naming.)

That is true, and perhaps we should provide a way for the executor to install its own cancellation handler for a task/job, which might let us fix that. I'll think about that and see if I can come up with something.

Executors might make all kinds of choices about what they want to place in the opaque data, and this is just a hook to let them clear that up, whatever it is. It doesn't take up much space and is entirely optional. If we don't have it, then we're restricting executor implementations to only putting data in there that doesn't require clean-up.

I gave an example above of where you might want that feature, and having the option means that the code explicitly spells out that it is expecting the .runImmediately behaviour (which is good because that's a pretty strange thing for it to do on cancellation when you think about it).

Job is intended to be a low-level scheduling primitive, and I do not want to accumulate overhead on it like cancellation handlers, if that concept can even be given well-defined semantics.

1 Like

The issue is that going one way - Instants to Durations is not inaccurate/lossy, Durations to Instants however are. So favoring the Instant ends up encouraging developers to be more accurate. This was feedback we had on the withDeadline as well as the initial Clock proposals. Also by favoring Instant it obviates the need for the FireTime to even exist (avoiding the naming issue).

So the withDeadline proposal is nesting the CancellationError.Reason which makes the error's reason distinctively associated with the error type. Perhaps that makes sense here for jobs? So if we could nest it into any of the specific concrete types might make it easier to name.

2 Likes

Well, but you're asking every executor to implement both semantics, and it has to do so dynamically based on this flag. The semantic difference naturally applies to the cancellation request, not the enqueuing, so the question is whether knowing up front that the job can only be cancelled in a specific way actually optimizes anything for the executor. I can believe that it would, I'm just curious what that actually looks like. Intuitively, it feels like executors with built-in timer support are going to have separate data structures tracking jobs that are currently waiting on timers. Supporting either kind of cancellation mostly involves extracting the job from that data structure, and the difference is just whether the job then gets moved to the active queue or not. I'm not sure how knowing which cancellation will be done in advance would optimize anything there.

Now, knowing that a job can't be cancelled, that I can believe could optimize things for the executor because it can avoid any overhead that would otherwise be needed to set up efficiently removing the job from that data structure.

Executors aren't supposed to do anything to clean up a job after executing them, though. Ownership of the job is transferred to the job's execution function. There's no concept of a destructor for jobs in general.

Yeah, that's the sort of implementation I think we want to encourage. Except, well, it does rely on the executor having to make an independent enqueue-specific allocation that can be stuffed in the private data of the token, right?

If we really committed to the idea that a cancellable job had to be a different kind of job — one with independent lifetime, such that the job structure has to stay valid until both the job executes/drops and the token is used/destroyed — that feels like it would admit a pretty high-performance implementation. The token would then just be (at an implementation level) a reference to the job. (Maybe we can trust the user to pass it back to the right executor? I think clients will generally be naturally holding a reference to the executor, so it'd be nice to avoid the refcounting traffic that would otherwise be forced by including an independent executor reference in the token.)

We could also give the executor more private data in the extended job structure as part of that, such that Dispatch (well, a hypothetical future Dispatch) could reasonably store whatever data it needs for managing the timer directly in the job without a separate allocation.

I have been prototyping a bit and I landed on a slightly different approach that does not expose cancellation handlers on jobs; rather it reframes the SchedulingExecutor API to look like this:

protocol SchedulingExecutor {
  func enqueueJob(
    _ job: consuming TypedExecutorJob<T, E>,
    run: FireTime<C>,
    clock: C,
    tolerance: C.Duration?
  )
  func cancel(_ jobID: UInt64)
}

The caveat here is that cancel can be called before enqueueJob was called. This is necessary because otherwise we have to handle that race in the runtime which is a large reason why the latest pitched design had to invent job tokens and atomic state handling around the executor calls. Executors can easily handle that though and they only need to store a "record" that the job was cancelled if it hasn't been run yet.

FWIW, I don't think that's the only thing we need. Executors are not aware of job priority escalation currently so we also need a hypothetical method for that:

func escalatePriority(for jobID: UInt64, newPriority: TaskPriority)

I don't think executors need to store private data in the job here. This is in my opinion an artifact of executers that are not natively implementing the protocol but where we wrap the protocol around them such as the Dispatch executor. In this example, the current implementation stores a dispatch source in the private data. As I outlined above this results in non-optimal performance since we have to allocate a source for every sleeping task. Even if we were to optimize that allocation by giving executors more storage I think that executors can achieve a better implementation by using their existing data structures to more efficiently handle many concurrent sleep jobs. In the end, an executor really only needs to store:

  • A heap of jobs without deadline
  • A heap of jobs with a deadline
  • The nearest deadline

I like @FranzBusch's train of thought here. I think the right direction is to keep the SchedulingExecutor protocol as simple as possible.

This is essentially how the cooperative global executor was implemented initially.

Also, perhaps I’m missing something, but shouldn’t UnownedJob / ExecutorJob have a public id property first?

That seems like a pretty big imposition on executors. It also seems to rely on jobs being managed by integer ID.

I don't think the atomic is really a significant issue. We have two concurrent signals, of course we need to synchronize. The awkwardness is really about managing the cancellation handler together with the continuation, and that I think is something we can tackle more directly; I've been thinking for a while that withContinuation should probably take an asynchronous block, and my earlier thinking that it was important for it to be synchronous is mistaken.

Correct, we need to synchronize; however, the executor already does the synchronization anyways. The atomic is strictly speaking an additional synchronization layer. The reason we need the atomic currently is because we have to first retrieve the job token from the executor to then have something to call cancel on. If we had an existing identifier for jobs like the job ID then we don't have to go through the inversion. Another possibility to avoid job ID is to decouple the API that returns a job token from the enqueue like this:

protocol SchedulingExecutor {
  func nextJobToken() -> JobToken
  func enqueueJob(
    _ job: consuming TypedExecutorJob<T, E>,
    token: JobToken,
    run: FireTime<C>,
    clock: C,
    tolerance: C.Duration?
  )
  func cancel(token: JobToken)
}

I am curious to understand how you think asynchronous code inside the withContinuation method would help here.

You would just be able to call withTaskCancellationHandler directly within the continuation block, allowing the handler function to directly reference the continuation, instead of having to call it outside the block and then synchronize sharing the continuation value back with the handler function.

If I understand correctly what you are suggesting, it’s something like this right?

await Builtin.suspend { job in
  await withTaskCancellationHandler {
    executor.enqueueJob(job, run:, clock:, tolerance:)
  } onCancel: {
    executor.cancel(...) // What do we pass here? Job is ~Copyable
  }
}

My current approach is very similar to this but I pass the job id to cancel since we need something the executor can use for bookkeeping.

Oh, you know, that idea doesn't work because the cancellation handler wouldn't still be in scope for the actual await of the continuation, which is implicit when exiting the withContinuation scope. We'd need a continuation API that made the await explicit somehow.

Ideally an optimal, hop-minimizing sleep would just look something like this:

try await withContinuation { cont in
  let hasBeenResumed = Atomic(false)
  let executor = currentExecutor
  let job = makeAJob {
    if hasBeenResumed.compareExchange(expected: false, desired: true, ordering: .relaxed).exchanged {
      cont.resumeSynchronously(on: executor, returning: ())
    }
  }
  let jobToken = executor.enqueue(job)
  try await withTaskCancellationHandler {
    try await cont.await()
  } onCancel: {
    if hasBeenResumed.compareExchange(expected: false, desired: true, ordering: .relaxed).exchanged {
      cont.resume(throwing: CancellationError())
    }   
  }
}

It would make sense for an async withContinuation block to have this ability to await the continuation. It would make the API less of a drop-in generalization of the existing APIs, though; among other things, we'd probably need two continuation handles, one for resuming and one just for awaiting. We'd need to tweak the runtime to support this anyway, though.

Your code is missing informing the executor about the cancellation so it can clean up its state. Since, the executor already needs to synchronize that cancellation event I see no reason why we would need the atomic. I would argue it looks like this:

func sleep() async throws {
  let executor = currentExecutor
  let id = jobID
  let result = await withTaskCancellationHandler {
    await Builtin.suspend { job in
      executor.enqueue(job)
    }
  onCancel: {
    executor.cancelJob(id)
  }

  try result.get()
}

Ah, right, it would be:

try await withContinuation { cont in
  let cancelled = Atomic(false)
  let executor = currentExecutor
  let job = makeAJob {
    if cancelled.load(ordering: .acquiring) {
      cont.resumeSynchronously(on: executor, throwing: ContinuationError())
    } else {
      cont.resumeSynchronously(on: executor, returning: ())
    }
  }
  let jobToken = executor.enqueue(job, deadline: ...)
  try await withTaskCancellationHandler {
    try await cont.await()
  } onCancel: {
    cancelled.store(true, ordering: .releasing)
    executor.cancel(jobToken, behavior: .runImmediately)
  }
}

Your suggestion does avoid the extra synchronization by relying on the executor, but I think it does that by adding a significant amount of complexity and overhead to the executor implementation, like all the machinery around allocating and tracking job tokens (or ID-based lookups). And I don't see how you'd accommodate the information flow around deciding whether to throw.

My argument is that executors already have all of this complexity just by the nature of how executors work. They already need to tolerate cancel being called from off-thread so they need to synchronize, check their data structures, remove the queued job, re-arm the timer, etc.. Even normal jobs executors already use the job ids and job priority for storing the enqueued jobs in their data structures.

I was thinking we could have a job primitive that would allow us to alloc a buffer on the async stack of the task, which is then passed to the executor where it can write it's return value into. Very similar to how UnsafeContinuation works just that we avoid the double-enqueue. The reason I think we need this anyways is that while sleeps are trivial I expect that we need the same kind of pattern for I/O where the return value and error are depending on how the actual syscall returned.