swiftmultithreadinggrand-central-dispatchstructured-concurrency

DispatchQueue.main.asyncAfter equivalent in Structured Concurrency in Swift?


In GCD I just call:

DispatchQueue.main.asyncAfter(deadline: .now() + someTimeInterval) { ... }

But we started to migrate to Structured Concurrency.

I tried the following code:

extension Task where Failure == Error {
    static func delayed(
        byTimeInterval delayInterval: TimeInterval,
        priority: TaskPriority? = nil,
        operation: @escaping @Sendable () async throws -> Success
    ) -> Task {
        Task(priority: priority) {
            let delay = UInt64(delayInterval * 1_000_000_000)
            try await Task<Never, Never>.sleep(nanoseconds: delay)
            return try await operation()
        }
    }
}

Usage:

Task.delayed(byTimeInterval: someTimeInterval) {
    await MainActor.run { ... }
}

But it seems to be an equivalent to:

DispatchQueue.global().asyncAfter(deadline: .now() + someTimeInterval) {
    DispatchQueue.main.async { ... }
}

So in case with GCD the resulting time interval is equal to someTimeInterval but with Structured Concurrency time interval is much greater than the specified one. How to fix this issue?

Minimal reproducible example

extension Task where Failure == Error {
    static func delayed(
        byTimeInterval delayInterval: TimeInterval,
        priority: TaskPriority? = nil,
        operation: @escaping @Sendable () async throws -> Success
    ) -> Task {
        Task(priority: priority) {
            let delay = UInt64(delayInterval * 1_000_000_000)
            try await Task<Never, Never>.sleep(nanoseconds: delay)
            return try await operation()
        }
    }
}

print(Date())
Task.delayed(byTimeInterval: 5) {
    await MainActor.run {
        print(Date())
        ... //some
    }
}

When I compare 2 dates from the output they differ much more than 5 seconds.


Solution

  • In the title, you asked:

    DispatchQueue.main.asyncAfter equivalent in Structured Concurrency in Swift?

    Extrapolating from the example in SE-0316, the literal equivalent is just:

    Task { @MainActor in
        try await Task.sleep(for: .seconds(5))
        foo()
    }
    

    Or, if calling this from an asynchronous context already, if the routine you are calling is already isolated to the main actor, introducing unstructured concurrency with Task {…} is not needed:

    try await Task.sleep(for: .seconds(5))
    await foo()
    

    Unlike traditional sleep API, Task.sleep does not block the caller, so often wrapping this in an unstructured task, Task {…}, is not needed (and we should avoid introducing unstructured concurrency unnecessarily). It depends upon the text you called it. See WWDC 2021 video Swift concurrency: Update a sample app which shows how one might use MainActor.run {…}, and how isolating functions to the main actor frequently renders even that unnecessary.


    You said:

    When I compare 2 dates from the output they differ much more than 5 seconds.

    I guess it depends on what you mean by “much more”. E.g., when sleeping for five seconds, I regularly would see it take ~5.2 seconds:

    let start = ContinuousClock.now
    try await Task.sleep(for: .seconds(5))
    print(start.duration(to: .now))                           // 5.155735542 seconds
    

    So, if you are seeing it take much longer than even that, then that simply suggests you have something else blocking that actor, a problem unrelated to the code at hand.

    However, if you are just wondering how it could be more than a fraction of a second off, that would appear to be the default tolerance strategy. As the concurrency headers say:

    The tolerance is expected as a leeway around the deadline. The clock may reschedule tasks within the tolerance to ensure efficient execution of resumptions by reducing potential operating system wake-ups.

    If you need less tolerance, specify it like so:

    let start = ContinuousClock.now
    try await Task.sleep(for: .seconds(5), tolerance: .zero)
    print(start.duration(to: .now))                           // 5.001445416 seconds
    

    Or, the Clock API:

    let clock = ContinuousClock()
    let start = ContinuousClock.now
    try await clock.sleep(until: .now + .seconds(5), tolerance: .zero)
    print(start.duration(to: .now))                           // 5.001761375 seconds
    

    Needless to say, the whole reason that the OS has tolerance/leeway in timers is for the sake of power efficiency, so one should only restrict the tolerance if it is absolutely necessary. Where possible, we want to respect the power consumption on our customer’s devices.

    This API was introduced in iOS 16, macOS 13. For more information see WWDC 2022 video Meet Swift Async Algorithms. If you are trying to offer backward support for earlier OS versions and really need less leeway, you may have to fall back to legacy API, wrapping it in a withCheckedThrowingContinuation and a withTaskCancellationHandler.


    As you can see above, the leeway/tolerance question is entirely separate from the question of which actor it is on.

    But let us turn to your global queue question. You said:

    But it seems to be an equivalent to:

    DispatchQueue.global().asyncAfter(deadline: .now() + someTimeInterval) {
       DispatchQueue.main.async { ... }
    }
    

    Generally, when you run Task {…} from an actor-isolated context, that is a new top-level unstructured task that runs on behalf of the current actor. But delayed is not actor-isolated. And, starting with Swift 5.7, SE-0338 has formalized the rules for methods that are not actor isolated:

    async functions that are not actor-isolated should formally run on a generic executor associated with no actor.

    Given that, it is fair to draw the analogy to a global dispatch queue. But in the author’s defense, his post is tagged Swift 5.5, and SE-0338 was introduced in Swift 5.7.