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

2.2k Views Asked by At

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.

1

There are 1 best solutions below

4
On BEST ANSWER

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, consider using the new 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.

I might be inclined to make this detached behavior explicit and reach for a detached task (“an unstructured task that’s not part of the current actor”):

extension Task where Failure == Error {
    /// Launch detached task after delay
    ///
    /// - Note: Don’t use a detached task if it’s possible to model the
    /// operation using structured concurrency features like child tasks.
    /// Child tasks inherit the parent task’s priority and task-local storage,
    /// and canceling a parent task automatically cancels all of its child
    /// tasks. You need to handle these considerations manually with
    /// a detached task.
    ///
    /// You need to keep a reference to the detached task if you want
    /// to cancel it by calling the Task.cancel() method. Discarding your
    /// reference to a detached task doesn’t implicitly cancel that task,
    /// it only makes it impossible for you to explicitly cancel the task.

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

IMHO, using a detached task makes the behavior explicit and unambiguous. And I would advise in-line documentation that conveys the exact same warnings/caveats that detached documentation does. The application developer should know what they are signing up for when introducing detached tasks.


You said:

In GCD I just call:

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

But we started to migrate to Structured Concurrency.

If you really want something that does precisely that, you could do:

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

That isolates delayedOnMain to the main actor, as well as the operation. Then you can do things like:

@MainActor
class Foo {
    var count = 0

    func bar() async throws {
        Task.delayedOnMain(byTimeInterval: 5) {
            self.count += 1
        }
    }
}

That way, no MainActor.run {…} is required at the call point.

That having been said, rather than coming up with a direct analog of DispatchQueue.main.asyncAfter, like above, you might see if you can refactor that out completely. One of the goals with Swift concurrency is simplify our logic and entirely eliminate escaping closures altogether.

We cannot advise on how to best refactor the calling point without seeing more details there, but it is usually pretty easy. But this would be a separate question.