async/await and structured concurrency · advanced

Unstructured tasks: Task, Task.detached and task-locals

What Task {} inherits, what Task.detached sheds, who owns an unstructured lifetime — and the ambient context that follows the task tree.

12 min read12 min practice0/2 exercises5 recall cards

By the end you will be able to

  • Say exactly what Task {} inherits, what Task.detached sheds, and what neither joins
  • Reach for @concurrent before Task.detached, and defend the rare legitimate detached
  • Own unstructured lifetimes — store the handle, cancel on teardown, spot the zombie loop
  • Thread ambient context with @TaskLocal, and explain why thread-locals cannot
Guess firstAnswering before you read makes the explanation stick — even when you get it wrong.

Compliance wants a trace ID on every log line an operation produces — across awaits, actor hops and child tasks — and you are not adding a traceID: parameter to thirty function signatures. Which mechanism actually works?

The escape hatch, completed

The async/await lesson introduced Task { } as the unstructured escape hatch and told you to prefer .task { } in SwiftUI. This lesson is the rest of that story, because "unstructured" is a precise claim with three parts: nothing waits for it (no parent suspends on its completion), nothing cancels it (it is outside the cancellation tree), and you hold the only reference to it (the Task handle is the entire ownership story). Watch the first part happen:

Nothing waits: the function returns, the script ends, the task is still going.

And a subtler timing fact that trips people in interviews and in production alike — creating a task enqueues it; the current code keeps running:

Predict, then runA wrong prediction you have committed to is worth more than a right answer you read.

No awaits in the top-level code. What order do the three prints appear in?

print("1")
Task {
    print("3")
}
print("2")

The inheritance table

Task { } is unstructured but not contextless. What each spawning tool carries over is the interview table worth knowing cold:

actor contextprioritytask-localsjoins cancellation treewho awaits it
child task (async let, group)inheritsinheritsinheritsyes — cancelled with parentthe parent, always
Task { }inheritsinheritsinherits (snapshot)nowhoever keeps the handle
Task.detached { }nonenonenonenowhoever keeps the handle

The first column is the one that rewrites intuitions:

Check yourself

Code review: a @MainActor view model shows jank during a huge JSON decode, and the fix on offer is wrapping the decode in Task { } — "moves it off the main thread". What actually happens?

Task.detached: when, and why usually not

So what is Task.detached for, if @concurrent handles "get off my actor"? The honest answer — and interviewers ask it in exactly this shape ("when would you use it, and why usually not?") — is: **when inheriting any context would be wrong.**

  • Independent subsystems with app lifetimes. A metrics flusher or cache compactor started at launch, owned by nobody's screen, deliberately running at its own fixed priority: regardless of what code happened to start it.
  • Deliberately context-free work. A logger's own I/O must not carry the request's task-locals (the log pipeline would tag its internal writes with user trace IDs), and cleanup work must not run at .userInitiated just because a button tap scheduled it.

That's roughly the whole list. Everything else that looks like a detached use case decomposes into @concurrent (off-actor), Task { } (concurrent-with, same context), or a child task (structured — the default you fall back into whenever the parent does care about the result). In review, an unexplained Task.detached earns the same comment as an unexplained force-unwrap: "justify it in writing, or use the tool that keeps the guarantees."

Who owns the lifetime

Unstructured means you are the lifetime management. The pattern is always the same trio — store, cancel, check:

@MainActor @Observable
final class TickerModel {
    private var streamTask: Task<Void, Never>?

    func start() {
        streamTask?.cancel()                    // idempotent restarts — no doubled streams
        streamTask = Task {
            for await quote in quoteFeed() {    // the AsyncAlgorithms pipeline
                if Task.isCancelled { break }
                apply(quote)
            }
        }
    }

    func stop() {
        streamTask?.cancel()
        streamTask = nil
    }
}

Three sharp edges around this shape, each a production story:

  • Cancellation is cooperative. cancel() sets a flag; the loop dies because it checks (Task.isCancelled, Task.checkCancellation(), or a Task.sleep/URLSession call that throws on cancellation — remembering the async-await lesson's split: CancellationError from sleeps, URLError(.cancelled) from networking). Delete the checks and cancel() becomes a polite suggestion to a zombie loop.
  • A task retains its captures until it finishes. Strong self in a short-lived task is fine — the "cycle" dissolves on completion. Strong self in a for-await-forever stream loop keeps the model alive after every screen that showed it is gone: the memory lesson's leak, in concurrency clothing. Long-lived loops take [weak self], or better, a lifetime that something owns —
  • SwiftUI's .task { } is this whole class written for you: tied to the view's identity, cancelled on disappear. The stored-handle pattern is for lifetimes views don't own — models, services, subsystems.

Task-locals: ambient context that follows the tree

Back to the pretest's trace ID. The naive fix threads a context parameter through every signature — including functions that never use it, which is how codebases grow six-parameter functions nobody can call. The mechanism Swift actually provides is a task-local: static storage, bound for a scope, visible to everything the scope runs:

enum Tracing {
    @TaskLocal static var traceID: String?
}

func handle(_ request: Request) async {
    await Tracing.$traceID.withValue(request.id) {
        await fetchBalance()          // every log line in this subtree sees the ID
    }                                 // scope ends -> previous value restored
}

func log(_ message: String) {
    print("[\(Tracing.traceID ?? "-")] \(message)")     // read from anywhere beneath
}

Two properties make this correct where globals and thread-locals were not. It is per-task-tree: concurrent requests each bind their own value, no interleaving, and child tasks plus Task { } snapshot it at creation (Task.detached, true to the table, starts blank). And it is scoped, not assigned: withValue restores the previous binding on exit — even nested rebinding unwinds correctly. The runnable below is that exact semantic, spelled out so you can watch the restore happen:

withValue's contract, modelled: bind for a scope, nested scopes shadow, exit always restores.

The rendering line is the point: after the nested req-402 scope closed, the outer binding came back. That restore discipline is what makes task-locals safe for reentrant, concurrent code — and it's why the real API only lets you bind via withValue's scope, never set-and-forget.

Where task-locals earn their keep — and where they don't: they are the right tool for cross-cutting observability context (trace IDs, the current request, log metadata) and for scoped test overrides (community dependency-injection libraries such as swift-dependencies are built on exactly this mechanism, which the architecture lesson's DI seam anticipated). They are the wrong tool for business inputs: an amount or an account ID arriving ambiently makes every call site a lie. Rule of thumb — if a function's behaviour depends on it, pass it; if only its telemetry does, bind it.

Implement withValue's restore discipline

Write the code

Build the scoped-binding semantics of @TaskLocal.withValue: bind for the scope, allow nesting, and always restore on exit — even when the work throws.

Solution unlocks after 3 attempts

Own the ticker's lifetime

Write the code

Model the store–cancel–check trio: a controller that starts a stream task, drops quotes after stop(), and restarts cleanly. (Real cancellation is cooperative — your receive is the loop-body check.)

Solution unlocks after 3 attempts
Explain itSaved on this device. Never graded.

Write the code-review comment for this PR, from memory: a teammate "fixed" scroll jank by changing a @MainActor view model's refresh() to wrap its decode in Task.detached(priority: .high). Cover: why the jank may improve but the fix is still wrong (three things detached shed, and which one bites the pretest's trace ID), what you'd write instead in Swift 6.2, and the one situation where you'd approve a detached task — stated so precisely that the author knows this isn't it.

Checkpoint

You can now:

  • Recite the inheritance table — and use it to predict where a task body actually runs
  • Reject Task.detached with a better alternative, or justify the rare legitimate one
  • Own unstructured lifetimes: store, cancel, check — and name the retained-loop leak
  • Bind ambient context with @TaskLocal and explain the scope-restore contract

Next up: SwiftUI performance — what all this streaming does to your render loop, and how to see it.

How well do you know this now? Rating yourself honestly, then being tested on it, is how you find out where your intuition is wrong.