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: |
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
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
Sendablerequirements on values crossing between tasks. -
Dispatch and Legacy Concurrency — Grand Central Dispatch and
Operation/OperationQueue, and how to wrap them forasync/await. -
Error Handling —
async throwsand error propagation acrossawaitand task-group boundaries. -
Closures —
@Sendableclosures and capture rules for closures passed toTask { }and task groups.