DispatchQueue.asyncAfter(deadline:) in Structured Concurrency?

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