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:
Extrapolating from the example in SE-0316, the literal equivalent is just:
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: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 useMainActor.run {…}
, and how isolating functions to the main actor frequently renders even that unnecessary.You said:
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:
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:
If you need less tolerance, consider using the new
Clock
API: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 awithTaskCancellationHandler
.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: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. Butdelayed
is not actor-isolated. And, starting with Swift 5.7, SE-0338 has formalized the rules for methods that are not actor isolated: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”):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:
If you really want something that does precisely that, you could do:
That isolates
delayedOnMain
to the main actor, as well as theoperation
. Then you can do things like: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.