DispatchQueue.asyncAfter(deadline:) in Structured Concurrency?

What's the Structured Concurrency equivalent of DispatchQueue.asyncAfter(deadline:)? I'm writing a SwiftUI app and need to something in a short while as a result of a button press. That something needs to happen on the main queue.

1 Like

I generally use one of the Task.sleep(…) methods for this.

That something needs to happen on the main queue.

… within a main actor bound async function.

Share and Enjoy

Quinn “The Eskimo!” @ DTS @ Apple

1 Like

Would that look like this (just BSing here)?

Task {
    Task.sleep(…)
    await { @MainActor async in
        <do the thing>
    }()
}
1 Like

Could be simpler like:

Task { @MainActor in
    try await Task.sleep(…)
    <do the thing>
}
4 Likes

I don't think I want to do that, right? It'll block the main thread, won't it?

1 Like

No, Task.sleep won't block. It just puts that task on hold until the time has passed (or task is cancelled), letting main thread perform other work.

2 Likes

Oh! I didn't understand that that's how that works. Let me try that.

UPDATE: Still not as succinct as DispatchQueue.main.asyncAfter(), but that seems to work, thank you.

Stepping through it in Xcode is sure weird, though.

1 Like

Important terminology nitpick: starting a new task with Task { … } (or Task.detached) is not structured concurrency.

Structured concurrency implies creating one or more child tasks by using a task group or async let. The main benefits of structured concurrency are IMO (from most to least obvious):

  • Cancellation propagation. When a parent task gets canceled, all of its child tasks will automatically get canceled too.

  • Less "spaghettification" of your concurrent code because the visual structure of the code maps directly to the parent-child task relationships. Swift guarantees that all child tasks will have finished by the end of the scope in which they were created. So a child task can never outlive its parent task.

  • It makes it harder to do "fire-and-forget" concurrency. DispatchQueue.async { … } is a typical "fire-and-forget" pattern: the calling code doesn't get notified when the async block finishes or errors unless you write the notification glue code manually (by passing in a completion handler).

    Structured concurrency more or less forces you to inspect the return values or errors from your child tasks. This can make it a bit more unwiedly to actually write structured concurrency code (Task { … } is shorter), but the benefits are often worth it.*

    * Important caveat: structured concurrency requires that you are already in an async context – you can't start a child task from a sync context. So sometimes you truly need to start at least one top-level Task { … } that can act as the parent for your child tasks. This discussion on Mastodon by @max_desiatov and others has some good pointers how to handle this cleanly.


Here's how a solution with async let could look like:

@MainActor
func doWorkOnMainActor() -> Int {
    return 42
}

func doSomething() async {
    async let childTaskResult = {
        try await Task.sleep(for: .seconds(2))
        return await doWorkOnMainActor()
    }()

    // Do something else concurrently
    // …

    // Explicitly wait for the child task to finish.
    // If you don't do this, the child task will still be silently awaited
    // before the function returns.
    do {
        print("main actor work finished with \(try await childTaskResult)")
    } catch {
        print("task was canceled")
    }
}

Finally, Task { … } may still be fine for your use case if you're truly after fire-and-forget semantics. But I'd suggest keeping the distinction between structured and unstructured concurrency in mind, it's super important.

16 Likes

Big +1 to this. IMO the analogy between structured and unstructured programming in general holds here: Task.init and Task.detached are like goto. Isolate their use to special places where you can't avoid them, preferring structured alternatives by default instead.

3 Likes

The situation I run into constantly is handling user interaction in SwiftUI. They click a button, I gotta do a bunch of stuff, then update state. I don't know of any way to do that other than Task or DispatchQueue.async. I called it "structured concurrency" because I thought that's what all the new concurrency stuff was called.

Yes, this is tricky, and I think Apple should address this with specific documentation/recommendations in their frameworks. I hinted at a possible alternative in my previous post:

The idea here is that a subsystem of your app (this could be a view model or view controller, or more generally a longer-lived object that manages the state for a part of the app) maintains some kind of queue for incoming events. This can be modeled with an AsyncStream. The subsystem also starts a single long-running Task { … } that reads events off the queue and processes them by using structured concurrency (e.g. a task group). The job of your button handler is then to synchronously submit an event to the queue.

Again, I'm not saying this is the right solution for every problem. Sometimes starting a Task is the pragmatic thing to do. But it should be a conscious decision weighing the pros and cons (e.g. no cancellation propagation).

I suspect this is a very common misconception in the community.

5 Likes

Well, from a descriptivist perspective it is the correct term, because indeed it's what many people have & are using. I think that ship has sailed, even.

Task unequivocally is part of Structured Concurrency, as a critical implementation element, and all uses of Task are at least gateways to Structured Concurrency, so it's not a big leap to use the term only a little more loosely to refer to the whole category.

1 Like

A trick I’ve found to handle this delayed user interaction involves the completion handler of SwiftUI’s Transaction. I use this code to trigger an action once a sheet is dismissed.

var close = Transaction(animation: .default)
close.addAnimationCompletion {
    // what would normally be in DispatchQueue.asyncAfter(…)
    refreshTimeline()
}

withTransaction(close) {
    presentedSheet = nil
}

I’ve used CATransaction in a similar way to react to a UIRefreshControl’s end refreshing animation. This isn’t a perfect solution, but it avoids using any Task or DispatchQueue shenanigans with fixed time delays.

3 Likes

Why is await required in the return expression above?

Because the same return expression in the code below causes a warning.

func simpler () {
    Task { @MainActor in
        try await Task.sleep (for: .seconds(5))
        return await doWorkOnMainActor() // Warning: No 'async' operations occur within 'await' expression
    }
}

Since doWorkOnMainActor is not async, the await is there for the hop to the main actor, which is not required in your example since you’ve already isolated the task to the main actor explicitly.

2 Likes

That's a huge leap and is incorrect. It's like saying that "jump to address" CPU instruction is a part of functional programming. Yes, it's a critical implementation element on any real CPU, but that doesn't make it any more functional. Please refer to this WWDC video about Swift's structured concurrency, the link points directly to the slide clarifying that Task.init and Task.detached don't belong to structured concurrency.

If you want to include both structured and unstructured concepts, just use plain "concurrency" word without an adjective. "Structured" and "unstructured" adjectives are there specifically to clarify whether someone is including Task in their use of concurrency or not.

5 Likes

"Swift Concurrency" as a term might, in the right context, suffice to convey the intent. But it's just as technically inaccurate. GCD can also be used in Swift to achieve concurrency. So can NSThread. Or POSIX threads. Etc.

The key is what works in context; what conveys the intended meaning. Unlike those other examples, Swift <thing we're talk about> doesn't have an unambiguous name, to my knowledge. Even Apple documentation uses varied terminology.

Tangentially, a better term might have been "Hierarchical Concurrency", anyway. Arguably the main thing that's built into this system is the underlying tree of Tasks with (in some uses) automatic propagation of cancellation. As opposed to bare dispatch queue tasks or threads or whatever that have no such built-ins.

1 Like

For reference, the term "structured concurrency" is an established term, although it's relatively young. It was invented (I think) by Martin SĂşstrik in 2016 for his C library libdill: Martin SĂşstrik, Structured Concurrency (2016-02). Another very influential inspiration to Swift's model is Nathaniel J. Smith, Notes on structured concurrency, or: Go statement considered harmful (2018-04). The Kotlin folks came up with a similar approach around the same time (independently, as far as I know) and later also named it structured concurrency after discovering Nathaniel Smith's work: Roman Elizarov, Structured concurrency in Kotlin (2019-07).

The word structured is a direct reference to structured programming of the 1950s/1960s. The central ideas of structured programming are:

  • Scoped lifetimes of variables
  • Sequential execution: the code is executed in the order in which it's written.
  • Execution flow can branch or split, but it will eventually merge, e.g. at the end of a loop or switch statement.
  • No arbitrary jumps (gotos). In a language without gotos, when you call a function, you know (a) control will eventually return to the line after the call site, and (b) the function will have finished executing at this point. If goto exists, anything can happen.

These ideas are all second nature to us today, but they weren’t back then.

The goal of structured concurrency is to make it possible to use these same rules for concurrent code:

  • async/await as a language feature is arguably a structured concurrency feature because it allows us to write concurrency code with the same structured control flow constructs we're used to: sequential execution, if/else, loops, try/throw.
  • async let and Task groups allow us to extend the same concept to multiple concurrently executing tasks. The fundamental rule here is that child tasks can't outlive the scope they're created in (like local variables) and that child tasks can't outlive their parent task (same as a called function must finish executing before the function that called it ends).

Task { … } and Task.detached don't follow structured programming rules, so we should call them unstructured tasks or unstructured concurrency.

(Apologies if you know all this, but maybe it's helpful for others.)

14 Likes

The true equivalent to DispatchQueue.asyncAfter(deadline:qos:flags:execute:) is Task.sleep(until:tolerance:clock:), because suspending a task for a fixed amount of time doesn't take into account the time it would take for the task to start executing after it's scheduled. Because of this, by the time the task suspends for a fixed amount of time, that amount may be invalidated by the task's scheduling delay. On the other hand, DispatchQueue.asyncAfter(deadline:qos:flags:execute:) doesn't wait for a specific amount of time, it waits until a fixed point in time (which may be calculated to be fixed amount of time after the time when the task is scheduled). Unlike Task.sleep(for:tolerance:clock:), Task.sleep(until:tolerance:clock:) does exactly that.

3 Likes