DispatchQueue.main.asyncAfter equivalent in Structured Concurrency in Swift?
Asked Answered
S

1

16

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.

Spherule answered 26/5, 2023 at 12:40 Comment(10)
Unrelated but you can use sleep(for:) and work directly with secondsOverscore
"but with Structured Concurrency time interval is much greater than the specified one" Can you show a minimal reproducible example? And why do you think it is equivalent to that?Forgave
@JoakimDanielson I can't just call sleep on main thread. And if I call it on background thread then the time interval becomes longer than expectedSpherule
You completely misinterpreted my comment, it was just a remark that you can use another variant of the sleep functionOverscore
@Forgave The minimal reproducible example is extension code + code from usage section. What do you mean?Spherule
Can you show a situation where structured concurrency produces a different output? Right now it seems like you are claiming that the time interval "feels" much greater with Swift concurrency, and well, everyone can "feel" whatever they want. I certainly don't feel the time interval is "much greater".Forgave
@Forgave I just call print(Date()) twice - before Task.delayed and inside MainActor.run and compare the time. The same is for DispatchQueue. It is obvious and is not related to this question. It just confirms that they are not equivalentSpherule
Well edit the question and show that code, and their outputs, then based on the numbers, you can show that the time interval is much greater with Swift concurrency. That's what a minimal reproducible example is.Forgave
And yes, of course they are not equivalent. These are very different APIs. You shouldn't be finding "equivalents". You should not consider GCD at all, when using Swift concurrency. Think about what problem you want to solve, and solve that.Forgave
Running your code in a playground results in no difference for me between dispatch queues and tasks, it still takes 5 seconds. If you're seeing big differences then there must be something else going on. Your minimal reproducible example prints two dates five seconds apart for me.Incongruent
G
24

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.

Gavelkind answered 26/5, 2023 at 17:44 Comment(6)
It’s interesting that you capture operation in the “precisely” main.asyncAfter code. I thought of something similar regarding marking everything @MainActor, but didn’t think of capturing operation. What is the purpose of doing that?Forgave
Yeah, closures get their concurrency context from where they were created, not where they are called. (SE-0306 says, “A closure formed within an actor-isolated context is actor-isolated if it is non-@Sendable, and non-isolated if it is @Sendable.”) SE-0316 clarifies that one can actor-isolate a closure with a global actor qualifier, even explicitly contemplating this in lieu of DispatchQueue.main.async {…}.Gavelkind
Today I get 5-6 seconds with Task.delayed(byTimeInterval: 5) { await MainActor.run { ... } } and it is ok. Previously it had been ~20 seconds. Possibly It was a simulator bug.Spherule
The standard tolerance (at least for GCD timer coalescing) is 10%, up to one minute, though it’s not documented AFAIK for Task.sleep.Gavelkind
Why are we sleeping in the Main thread? Not clear to me.Massy
@DeborshiSaha – We can do it with Task.sleep, because that is non-blocking, unlike legacy sleep and Thread.sleep API.Gavelkind

© 2022 - 2024 — McMap. All rights reserved.