[Pitch 2] Custom Main and Global Executors

There is a lot in this proposal; at least two distinct features. it might be difficult to review as a single piece.

How is that supposed to happen, though? I see that you're adding a convert operation to Clock that converts an arbitrary instant of an arbitrary other clock into this clock, but it's really unclear how that could possibly work for a truly arbitrary clock. The idea seems to be that it looks at the clock traits and picks one of the system implementations, but that's effectively requiring all useful implementations of Clock to be essentially like one of the system clocks. And as an abstract operation on clocks, it doesn't really make much sense — there's no meaningful way to turn a future instant of a suspending clock into any other measure of time.

Rather than asking executors to do this somewhat heroic recognition of different kinds of clocks, it might make sense to add a new primitive scheduling operation to Clock. Clock.sleep() is already a scheduling operation, so this wouldn't be crossing any surprising boundaries. The only problem with Clock.sleep() as a scheduling operation is that it's so high-level that it doesn't give you primitive control, but that's fixable with a new operation, and of course the new operation can be (inefficiently) implemented in terms of the old.

I would suggest this:

protocol Clock {
  /// Run the given job on an unspecified executor at some point
  /// after the given instant.
  func run(_ job: consuming ExecutorJob,
           after: Instant, tolerance: Duration?)
}

SchedulableExecutor.enqueue can try to recognize specific clocks, but if it sees one it doesn't recognize, it can fall back on the above, passing a new job that immediately enqueues the original job on self. This is basically always fine as a fallback, and it'll usually be necessary anyway, since systems do not have a general ability to trigger timers on an arbitrary executor, and any optimized path for a particular clock + executor that does exist is necessarily specific to the pair.

Why does SchedulableExecutor offer both enqueue(_:after:tolerance:clock:) and enqueue(_:at:tolerance:clock) as requirements? Are there different clocks that prefer each of these for some reason? My perhaps-ignorant expectation is that allowing a duration to be specified is a user-facing convenience — a perfectly reasonable thing to offer as a protocol extension method — but that in the core implementation (and thus the generic entry-points) we'd always want to derive the target instant ASAP and then wait for that rather than passing the duration around.

Also, is it important to distinguish SchedulableExecutor from Executor? It seems to me that pulling this up to Executor and giving it a default implementation that does a double-dispatch would be perfectly fine.

I think if you do all this, you don't need clock instant/duration conversion or the clock traits.

4 Likes