SE-0374: Add `sleep(for:)` to Clock

Hi all --

The proposal has been updated to incorporate feedback from @benrimmington during review. I am extending the review period by an additional week to allow for comments on the additional API. It will run through Tuesday, November 1. Thank you for participating in Swift Evolution.

9 Likes

Can the Task.sleep(until:) method have a default clock, via the SE-0347 feature?

 extension Task where Success == Never, Failure == Never {
 
   public static func sleep<C: Clock>(
     until deadline: C.Instant,
     tolerance: C.Instant.Duration? = nil,
-    clock: C
+    clock: C = .continuous
   ) async throws {
     try await clock.sleep(until: deadline, tolerance: tolerance)
   }
 }

Can the Task.sleep(for:) method become generic, also with a default clock?

 extension Task where Success == Never, Failure == Never {
 
-  public static func sleep(
-    for duration: Duration
+  public static func sleep<C: Clock>(
+    for duration: C.Instant.Duration,
+    tolerance: C.Instant.Duration? = nil,
+    clock: C = .continuous
   ) async throws {
-    try await sleep(until: .now + duration, clock: .continuous)
+    try await clock.sleep(for: duration, tolerance: tolerance)
   }
 }
3 Likes

That does work, and it would bring full symmetry between Task.sleep(for:) / Task.sleep(until:) and clock.sleep(for:) / clock.sleep(until:).

7 Likes

I'm working on a specialized clock implementation and just noticed that this proposal does not add one missing convenience bit when working with custom clocks.

Specifically, this adds:

  public static func sleep(for duration: Duration) async throws

but we're missing a version that can work with a custom clock, which IMHO also deserves having the sleep(for:) treatment, i.e. this should also be included IMHO:

  /// Suspends the current task for the given duration on the provided clock.
  ///
  /// Exact semantics of how the sleep is performed depend on the passed clock argument.
  ///
  /// This function doesn't block the underlying thread.
  ///
  ///       try await Task.sleep(for: .seconds(3), clock: .custom)
  ///
  /// - Parameter duration: The duration to wait.
  /// - Parameter clock: The clock on which the sleep shall be performed.
  @available(SwiftStdlib 5.7, *)
  @_alwaysEmitIntoClient
  public static func sleep<C: Clock>(for duration: C.Duration, clock: C) async throws {
    try await sleep(until: clock.now.advanced(by: duration), clock: clock)
  }

I'd like to ask the team and proposal author to consider this addition, because it allows custom clock users to have a not-worse user-experience than default clock users, by just passing the apropriate clock whenever they need to.

Arguably, projects which always must use a different clock perhaps should define a global sleep function which passes "the right clock for our system" to make the verbose clock passing not a thing...

Either way, figured I'll raise this as a question, since it seemed we're missing parity here a little bit.

7 Likes

In adding that new convenience method, I'm struggling to see what advantages would you gain going from:

try await Clock.custom.sleep(for: .seconds(3))

to:

try await Task.sleep(for: .seconds(3), clock: .custom)

In fact, given that Clock also includes a sleep(until:tolerance:) instance method, I'm having a hard time seeing the use-case for the preexisting Task.sleep(until:tolerance:clock:) type method - it feels like we now have two ways of invoking the same custom-clock sleep behavior without any real differences between the ergonomics of the two.

I do think this matters. We're introducing Swift to people who have never seen it before, and may know about Java or C++ or similar languages where Thread.sleep is a thing -- so the parity of Task.sleep is an useful one from a learning and discovery perspective. I'm right now working with such a team for example.

3 Likes

Hmm, if you're approaching it from the progressive disclosure side of things I guess having Task be the intended entrypoint for all sleeping (custom clock or not) makes sense, and then I agree the APIs should have parity. We would then probably want a tolerance parameter in the proposed version above though, something like:

extension Task {
  /// Suspends the current task for the given duration on the provided clock, within a tolerance.
  /// If no tolerance is specified then the system may adjust the deadline
  /// to coalesce CPU wake-ups to more efficiently process the wake-ups in
  /// a more power efficient manner.
  ///
  /// Exact semantics of how the sleep is performed depend on the passed clock argument.
  ///
  /// This function doesn't block the underlying thread.
  ///
  ///       try await Task.sleep(for: .seconds(3), clock: .custom)
  ///
  /// - Parameter duration: The requested duration to wait.
  /// - Parameter tolerance: The maximum allowed difference between requested and actual wait duration.
  /// - Parameter clock: The clock on which the sleep shall be performed.
  @available(SwiftStdlib 5.7, *)
  @_alwaysEmitIntoClient
  public static func sleep<C: Clock>(for duration: C.Duration, tolerance: C.Duration? = nil, clock: C) async throws {
    try await sleep(until: clock.now.advanced(by: duration), tolerance: tolerance, clock: clock)
  }
}
2 Likes

Good point, yes thatโ€™d be the right signature to add.

Why is the parity of Task.sleep important for learning and discovery? The existing API vending the most common use on Task.sleep can and should document related advanced APIs however they are spelt.

If the argument instead is that the sleep APIs are instead best presented on Task, then your proposed additions could be accepted and the Clock.sleep additions proposed above rejected without harming learning and discovery.

Iโ€™d agree with @MPLewis that having duplicative APIs is undesirable and actively to be avoided. Put another way, having one API be not even a composition but a respelling of another doesnโ€™t pass the bar for an addition to the standard library.

4 Likes

Hmm yeah those are fair points to consider.

At some point it perhaps comes down to preference "where" the most APIs should be.

Perhaps you're right that if people indeed MUST use a custom clock, then that is the important part, and clock.sleep actually puts more focus on it, and I can notice that Indeed I'm using "the right sleep method" :thinking:

I was kind of thinking of Clock as an implementation detail -- because that's where all things delegate to in the end. Now I'm actually not entirely sure anymore, I'll need to think some more about it, thanks for the alternative viewpoints :slight_smile:

I would say my brain comes from the idea that the task is being told to sleep, hence task.sleep(). I don't usually think in terms of a clock being able to do something, i.e. it being the thing sleeping the thread. So, the spelling Task.sleep() feels more natural to me. But I'm sure other people could just as reasonably argue the opposite.

10 Likes
@available(SwiftStdlib 5.7, *)
@_alwaysEmitIntoClient
public static func sleep<C: Clock>(for duration: C.Duration, tolerance: C.Duration? = nil, clock: C = .continuous) async throws {
  try await sleep(until: clock.now.advanced(by: duration), tolerance: tolerance, clock: clock)
}

To me this seems like a good modification that we should take; it grants the advanced progressive disclosure case as well as the easy to use simple case.

I would claim that since it keeps in the spirit of the original proposal we should accept that as a modification addendum.

Usages would expand to the following versions:

try await Task.sleep(for: .seconds(3))
try await Task.sleep(for: .seconds(3), tolerance: .milliseconds(10))
try await Task.sleep(for: .seconds(3), clock: myFancyClock)
try await Task.sleep(for: .seconds(3), tolerance: .milliseconds(10), clock: myFancyClock)

Which for all of their use cases seem quite reasonable to me.

4 Likes

This is a slight sidebar to the review, but because one of the proposal's primary motivations is making clock existentials more usable, it's probably worth sharing:

While the proposal makes it possible to directly sleep using an any Clock<Duration> existential, current limitations in Swift prevent any Clock<Duration> from being used more broadly, like in Async Algorithms' timer, debounce, and throttle APIs.

We can work around this by introducing a concrete AnyClock type, and figured it'd be worthwhile to share that solution, but look forward to the day when async sequences can be opaque, and/or when more existential-opening limitations are relaxed.

6 Likes

Do you also agree that Task.sleep(until:) and Task.sleep(for:) should have the same default clock?

Just to make it official: because fruitful discussion is still happening, I'm extending the official review period through the 15th of November. If the proposal authors would like to incorporate any of the suggested changes, please put together a PR and send me a note.

4 Likes

I think Ben is absolutely correct, and this explanation is far more approachable than trying to explain what it means for a Clock to sleep().

4 Likes

But no matter what, Clock must have a sleep method, as it is the point of the protocol. So are we suggesting that Clock have a sleep(until:) and then for some reason Task has sleep(for:clock:) and sleep(until:clock:)?

I don't see anyway around having both sleep(for:) and sleep(until:) defined on both Clock and Task just for consistency and symmetry.

It seems that if you care about controlling your clock dependency in your code, then you can make use of clock.sleep. And if you don't care about controlling the dependency, you can just use Task.sleep.

Following that guideline also gives you the shortest possible code at the call site:

// When you care about controlling the clock dependency
try await clock.sleep(for: .seconds(1))
// vs
try await Task.sleep(for: .seconds(1), clock: clock)

// When you do not care about controlling the clock dependency
try await ContinuousClock().sleep(for: .seconds(1))
// vs
try await Task.sleep(for: .seconds(1))
2 Likes

The implicit assumption here is that all Task.sleep does is call Clock.sleep. I donโ€™t think that assumption is necessarily correct.

It is not unheard of for types to vend APIs that are intended to be implemented but not called directly by clients. Maybe thereโ€™s a better name for Clock.sleep, or a better way for Clock implementers to provide the necessary time measurement.

Edit: how about Clock.alarm(after:)? SIGALRM provides precedent.

I'm assuming that because the Clock proposal was accepted nearly a year ago and a lot of these kinds of things were already discussed then.

I think we should have symmetry so yes I think that is reasonable.

3 Likes