swiftconcurrencydelaysleep

iOS Swift Task.sleep accuracy


I was looking into a cancellable async delay in Swift which is backwards compatible to iOS 15:

Task {
    let before = Date.timeIntervalSinceReferenceDate
    
    let seconds: TimeInterval = 100
    print("> sleeping for \(seconds) seconds...")
    
    try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000))
    
    let after = Date.timeIntervalSinceReferenceDate
    
    print(String(format: "> slept for %0.3f seconds", after - before))
}

This solution using Task.sleep works fine, but it seems to be rather inaccurate for longer delays.

Running on iOS 16 simulator yields this:

> sleeping for 20.0 seconds...
> slept for 21.332 seconds

> sleeping for 60.0 seconds...
> slept for 63.995 seconds

> sleeping for 100.0 seconds...
> slept for 106.662 seconds

Is there a better way to achieve this?


Solution

  • To enjoy more accurate sleep duration, one would specify a leeway/tolerance. In iOS 16+, you have sleep(for:tolerance:clock:):

    try await Task.sleep(for: .seconds(seconds), tolerance: .zero)
    

    As suggested by Bram, if you need to support iOS 15, you can write your own rendition using, for example, a DispatchSourceTimer. But, specify a setCancelHandler to detect/handle the cancellation of the timer:

    @available(iOS,     deprecated: 16.0, renamed: "Task.sleep(for:tolerance:clock:)")
    @available(macOS,   deprecated: 13.0, renamed: "Task.sleep(for:tolerance:clock:)")
    @available(watchOS, deprecated: 9.0,  renamed: "Task.sleep(for:tolerance:clock:)")
    @available(tvOS,    deprecated: 16.0, renamed: "Task.sleep(for:tolerance:clock:)")
    func sleep(for interval: DispatchTimeInterval, leeway: DispatchTimeInterval) async throws {
        let queue = DispatchQueue(label: (Bundle.main.bundleIdentifier ?? "sleep") + ".timer", qos: .userInitiated)
        let timer = DispatchSource.makeTimerSource(queue: queue)
    
        try await withTaskCancellationHandler {
            try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
                guard !Task.isCancelled else {
                    continuation.resume(throwing: CancellationError())
                    return
                }
    
                timer.schedule(
                    deadline: .now() + interval,
                    repeating: .never,
                    leeway: leeway
                )
    
                timer.setEventHandler {
                    continuation.resume()
                }
    
                timer.setCancelHandler {
                    continuation.resume(throwing: CancellationError())
                }
    
                timer.activate()
            }
        } onCancel: {
            timer.cancel()
        }
    }
    
    extension DispatchTimeInterval {
        static var zero: DispatchTimeInterval { .nanoseconds(0) }
    }
    

    And you would call it like so:

    try await sleep(for: .seconds(10), leeway: .zero)
    

    This throws CancellationError if the task is cancelled (like the Task.sleep API).

    Also, if you promote the minimum target OS at some future date to a version that supports the tolerance variation, the above will produce a warning, suggesting the use of the contemporary API.


    As an obligatory aside, the reason that timers and sleep API even have a leeway/tolerance is to afford the coalescing of events to minimize power drain on the device. So one might be best advised to only narrow the tolerance where the app truly requires that, with the recognition of the battery/power implications.