[pre-pitch] Nonisolated nonsending Clock protocol

I'd like to pre-pitch updating the Clock protocol to have a nonisolated(nonsending) sleep, just to get people's first impression.

The Clock protocol has one method requirement, and currently it is @concurrent. This means every time clock.sleep is called it will jump to the concurrent executor, whether it needs to or not. Other parts of the standard library have gotten the nonisolated(nonsending) treatment, such as withTaskCancellationHandler and withCheckedContinuation, so why not Clock?

And if you need a more concrete reason, consider a theoretical ImmediateClock conformance that does not actually suspend when asked to sleep. Such a clock can be handy in testing code where we don't actually care about the passage of time and just want to squash all of time down to a single instant.

With such a clock, what would you expect the following to print when run in a main.swift executable?

let clock = ImmediateClock()
var count = 0
let task = Task.immediate {
  while count < 1_000 {
    try await clock.sleep(for: .seconds(1), tolerance: nil)
    count += 1
  }
}
print("count", count)
try await task.value
print("count", count)

To me, since Task.immediate runs all synchronous code immediately, and since clock.sleep does not actually suspend in ImmediateClock's implementation, I would hope the entire loop is done synchronously and "count 1000" is printed twice to the console.

But this does not happen. Instead "count 0" is printed first, and then "count 1000". This code does incur actual suspension points even though ImmediateClock implements sleep as a synchronous method. It is not possible to properly implement ImmediateClock using the Clock protocol alone. But if Clock's sleep requirement were made nonisolated(nonsending), this code would deterministically behave exactly how we want.

7 Likes

I agree that we need this but we also need to adopt nonisolated(nonsending) in the concrete Clock types, in Task.sleep(clock), all the internal async methods that connect those two, and Task.yield as well. To motivate this more, this is required for performance. A sleep on the MainExecutor is currently hopping to a global executor thread and can cause priority inversions.

Now the tricky part is in the Clock protocol since adding nonisolated(nonsending) is an API breaking change. Coincidentally I was thinking about this today and I think we can stage this in by adding the new requirement with a default implementation that calls the existing @concurrent. Since all parameters are required to be Sendable this should be safe.

2 Likes

Yeah, updating all interconnected pieces to be nonisolated(nonsending) would definitely be part of the proposal.

To avoid needing to come up with a new name for this requirement we could just add sleep(until:tolerance:isolation:). Unless that is what you mean?