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.
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.