Async/Await and Tasks

This section documents the Swift 6 language mode as shipped by Swift 6.3, as published in The Swift Programming Language at docs.swift.org, which is the reference these pages are written and verified against. 6.4-beta-only features are always flagged as such — never presented as baseline.

This content was generated with the assistance of AI and should be verified against docs.swift.org before being relied on in production.

This section’s bibliography lists the reference material consulted while preparing these pages.

Swift’s concurrency model replaces callback-based asynchrony with async/await: a function that may suspend declares itself async, and every point where it can suspend is marked await at the call site, so a reader can see exactly where control might hand off to another piece of work without hunting through nested closures.

Asynchronous Functions and await

func fetchUserName(id: String) async -> String {
    try? await Task.sleep(for: .milliseconds(500))   // a suspension point: the current task may yield here
    return "user-\(id)"
}

func printGreeting() async {
    let name = await fetchUserName(id: "42")          // `await` marks every call that might suspend
    print("Hello, \(name)!")
}

An async function runs synchronously from the caller’s point of view — it does not spawn a new thread by itself — but it may suspend at points marked await, handing the thread back to the system so other work can run while the awaited operation completes; when the awaited value is ready, the function resumes, possibly on a different thread than the one it suspended on. Only another async context can call an async function directly with await; ordinary synchronous code must instead hand off into one, most commonly by creating a Task (see below).

Calling async Code from Synchronous Code

struct GreetingButton {
    func tapped() {                       // an ordinary synchronous method, e.g. a button action
        Task {
            await printGreeting()          // starts an unstructured Task to bridge into async code
        }
    }
}

Synchronous code cannot await directly — it has no suspension mechanism of its own — so the standard bridge is to wrap the async call in a Task { }, which starts running immediately and inherits the priority and actor context of the code that created it. The synchronous method returns right away without waiting for the task to finish; see Task and Structured Concurrency below for how a caller instead awaits a task’s result when it needs one.

Asynchronous Sequences: AsyncSequence, for await, and AsyncStream

struct Countdown: AsyncSequence {
    typealias Element = Int
    let start: Int

    struct AsyncIterator: AsyncIteratorProtocol {
        var current: Int
        mutating func next() async -> Int? {
            guard current > 0 else { return nil }
            try? await Task.sleep(for: .milliseconds(200))
            defer { current -= 1 }
            return current
        }
    }

    func makeAsyncIterator() -> AsyncIterator { AsyncIterator(current: start) }
}

for await tick in Countdown(start: 3) {
    print(tick)                            // prints 3, 2, 1, each after a suspension
}

// AsyncStream: bridge a push-based producer (e.g. a delegate callback) into an AsyncSequence
let stream = AsyncStream<Int> { continuation in
    var value = 0
    let timer = DispatchSource.makeTimerSource()
    timer.schedule(deadline: .now(), repeating: 1)
    timer.setEventHandler {
        value += 1
        continuation.yield(value)
        if value == 3 { continuation.finish() }
    }
    timer.resume()
}

for await value in stream {
    print("stream:", value)
}

AsyncSequence is the asynchronous counterpart of Sequence: its next() is async, so producing each element may itself suspend, and for await iterates it exactly like an ordinary for loop iterates a Sequence, except each iteration is itself an await point. AsyncStream (and its throwing counterpart AsyncThrowingStream) gives an off-the-shelf AsyncSequence backed by a continuation that a push-based producer — a delegate callback, a timer, a notification handler — calls into with yield(_:) and finish(), which is the idiomatic way to adapt callback-style APIs into for await without writing a custom AsyncIteratorProtocol conformance.

Calling async Functions in Parallel with async let

func fetchUserName(id: String) async -> String { "user-\(id)" }
func fetchUserAge(id: String) async -> Int { 30 }

func loadProfile(id: String) async -> String {
    async let name = fetchUserName(id: id)     // starts running immediately, concurrently with what follows
    async let age = fetchUserAge(id: id)       // starts running immediately too

    return await "\(name), age \(age)"          // both awaits happen here; the two calls already ran in parallel
}

async let binds the result of an async call to a constant that begins running immediately, concurrently with whatever code follows it, rather than waiting to be awaited the way a plain await on its own line would; the value is only actually awaited — and its result observed, or its error propagated for a throwing call — the first time the constant is used. It is the lightest-weight way to run a small, fixed number of independent async calls in parallel; a dynamic or unbounded number of parallel child tasks instead needs a task group (next).

Task and Structured Concurrency

func fetchAllUserNames(ids: [String]) async -> [String] {
    await withTaskGroup(of: String.self) { group in
        for id in ids {
            group.addTask { await fetchUserName(id: id) }   // each child task runs concurrently
        }
        var names: [String] = []
        for await name in group {                            // collects results as they complete, any order
            names.append(name)
        }
        return names
    }
}

func fetchAllUserNamesOrThrow(ids: [String]) async throws -> [String] {
    try await withThrowingTaskGroup(of: String.self) { group in
        for id in ids {
            group.addTask { await fetchUserName(id: id) }
        }
        return try await group.reduce(into: []) { $0.append($1) }   // first thrown child error propagates
    }
}

withTaskGroup(of:body:) and its throwing counterpart withThrowingTaskGroup(of:body:) create a structured scope of child tasks: group.addTask { } spawns each child, they all run concurrently, and the group itself cannot return until every child has either finished or been cancelled — structured concurrency’s core guarantee is that a parent scope can never outlive its children, unlike a bare background thread that can leak past the function that started it. Iterating the group with for await collects each child’s result as it completes, not necessarily in the order the children were added; in the throwing variant, the first child error encountered is rethrown and cancels the remaining children.

Task Cancellation

func longRunningWork() async throws {
    for step in 1...100 {
        try Task.checkCancellation()          // throws CancellationError if the task was cancelled
        if Task.isCancelled { break }          // or check without throwing, e.g. inside non-throwing code
        try await Task.sleep(for: .milliseconds(10))
        print("step \(step)")
    }
}

let task = Task { try await longRunningWork() }
task.cancel()                                 // cooperative: longRunningWork() must itself check and react

await withTaskCancellationHandler {
    try? await longRunningWork()
} onCancel: {
    print("cancelled -- release any external resource here")   // runs synchronously, possibly on another thread
}

Cancellation in Swift’s concurrency model is cooperative: calling cancel() on a task only flags it as cancelled, it does not forcibly stop it — the task’s own code must periodically call Task.checkCancellation() (which throws CancellationError) or read Task.isCancelled and react. withTaskCancellationHandler(operation: onCancel:) additionally registers a synchronous callback that fires as soon as cancellation is requested, useful for releasing an external resource (a socket, a file handle) that the cancelled operation’s own cooperative check might not reach in time.

6.4-beta-only: withTaskCancellationShield is an in-development addition, still changing as of Swift 6.4 (beta), that lets a specific region of an operation opt out of an already-requested cancellation for its duration — useful for a short cleanup step that must run to completion even after the surrounding task has been cancelled. Its exact shape is not yet final; consult the current Swift Evolution proposal list before relying on it.

Unstructured and Detached Tasks, Priorities, and Naming

let unstructured = Task(priority: .userInitiated) {   // inherits the current actor context, not the parent scope
    await printGreeting()
}

let detached = Task.detached(priority: .background) {  // no inherited actor context or priority propagation
    await printGreeting()
}

Task(name: "profile-refresh") {                        // task naming, for debugging and instruments
    await printGreeting()
}

Task { } creates an unstructured task: unlike a task-group child, it is not tied to the lifetime of any enclosing scope, but it still inherits the priority and actor/isolation context of the code that created it. Task.detached { } goes further and inherits neither — it is the right tool only when a truly independent unit of work is needed, since it forgoes the cancellation propagation and context inheritance that make ordinary Task { } and task groups easier to reason about; prefer structured concurrency (task groups, async let) or a plain Task { } unless detachment is specifically required. Priorities (.high, .userInitiated, .medium, .low, .utility, .background) are hints to the scheduler, not guarantees; a task’s optional name: aids debugging in Instruments and crash reports.

Task-Local Values

enum RequestContext {
    @TaskLocal static var requestID: String = "none"
}

func logCurrentRequest() {
    print("handling request \(RequestContext.requestID)")
}

await RequestContext.$requestID.withValue("abc-123") {
    logCurrentRequest()                     // prints "handling request abc-123"
    Task {                                   // child tasks created within the scope inherit the bound value
        logCurrentRequest()                  // also prints "handling request abc-123"
    }
}

A @TaskLocal static property behaves like thread-local storage adapted to structured concurrency: its value is bound only for the duration of a withValue(_:operation:) scope, and any task — including child tasks — created within that scope inherits the bound value automatically, which makes it well suited for propagating request IDs, trace/span identifiers, or other request-scoped context through a call tree without threading an explicit parameter through every function signature.

Bridging Completion Handlers with Continuations

func legacyFetch(id: String, completion: @escaping (Result<String, Error>) -> Void) {
    DispatchQueue.global().asyncAfter(deadline: .now() + 0.2) {
        completion(.success("user-\(id)"))
    }
}

func fetch(id: String) async throws -> String {
    try await withCheckedThrowingContinuation { continuation in
        legacyFetch(id: id) { result in
            continuation.resume(with: result)   // must resume exactly once, on every code path
        }
    }
}

withCheckedContinuation(:) and withCheckedThrowingContinuation(:) bridge a completion-handler-based API into async/await by suspending the current task until the continuation’s resume(returning:) / resume(throwing:) / resume(with:) is called from inside the completion handler. The "checked" variants trap at runtime if the continuation is resumed zero times or more than once, which catches the single most common bridging bug during development; withUnsafeContinuation/withUnsafeThrowingContinuation drop that runtime check for a small performance win once the bridging code is proven correct.

Fanning Out and Collecting Results with a Task Group

sequenceDiagram participant Caller participant Group as TaskGroup participant C1 as Child Task 1 participant C2 as Child Task 2 participant C3 as Child Task 3 Caller->>Group: withTaskGroup { ... } Group->>C1: addTask { fetch(id: 1) } Group->>C2: addTask { fetch(id: 2) } Group->>C3: addTask { fetch(id: 3) } par concurrently C1-->>Group: result 1 (fastest) and C3-->>Group: result 3 and C2-->>Group: result 2 (slowest) end Group-->>Caller: for await collects results as they arrive (1, 3, 2) Note over Group,Caller: group only returns once every child has finished

The child tasks above run concurrently and complete in whatever order their work actually finishes — here, child 1 finishes first and child 2 last — and for await over the group yields each result in that completion order, not the order addTask added them; the group as a whole cannot return from withTaskGroup until every child, including any still running when an earlier one completes, has finished or been cancelled.

See Also

  • Actors, Isolation and Sendable — the isolation domains a task’s code runs in, and the Sendable requirements on values crossing between tasks.

  • Dispatch and Legacy Concurrency — Grand Central Dispatch and Operation/OperationQueue, and how to wrap them for async/await.

  • Error Handling — async throws and error propagation across await and task-group boundaries.

  • Closures — @Sendable closures and capture rules for closures passed to Task { } and task groups.

References

TSPL: Concurrency (Defining and Calling Asynchronous Functions; Asynchronous Sequences; Calling Asynchronous Functions in Parallel; Tasks and Task Groups; Task Cancellation); stdlib → Concurrency.