Concurrency
Concurrency lets your app do several things at once — fetch data from the
network while keeping the screen responsive, or crunch images in parallel.
Swift bakes this into the language with async/await,
structured tasks, and actors. In Swift 6, the compiler goes one step further:
strict concurrency is on by default, so whole classes of
data-race bugs are caught while you type, not at 2 a.m. in production.
The problem concurrency solves
Some work takes time: downloading a file, reading a database, resizing a photo. If you do that work on the main thread, the UI freezes until it finishes. The old fix was completion handlers — nested closures that ran "later" and were easy to get wrong (forgotten calls, double calls, errors on the wrong thread). Swift's concurrency model replaces that pyramid of callbacks with code that reads top-to-bottom but runs asynchronously.
// The old way: nested completion handlers
fetchUser(id: 42) { user in
fetchAvatar(user.avatarURL) { image in
resize(image) { thumbnail in
DispatchQueue.main.async {
imageView.image = thumbnail // easy to forget the hop to main
}
}
}
}
Callback nesting — hard to read, easy to get the threading wrong.
async/await
A function marked async can suspend: it can pause,
let the thread do other work, and resume later. You call an async function
with await, which marks the spot where a suspension may happen.
The function that calls it must itself be async (or run inside
a task).
func loadThumbnail(id: Int) async throws -> Image {
let user = try await fetchUser(id: id)
let image = try await fetchAvatar(user.avatarURL)
return await resize(image)
}
The same logic as the callback pyramid — but linear and readable.
async and throws compose: write
async throws in the signature, and try await at
the call site. Each await is a possible suspension point —
after resuming, you may even be on a different thread, so never assume
thread identity across an await.
Importantly, await does not block a thread. While a task is
suspended, the underlying thread is free to run other tasks — which is what
keeps the UI smooth without spawning piles of OS threads.
Calling async functions from sync code
You can't await from an ordinary synchronous function. To cross
the boundary — say from a SwiftUI button action — wrap the call in a
Task:
Button("Load") {
Task {
let image = try? await loadThumbnail(id: 42)
// update state here
}
}
Async sequences
Just as for-in walks a regular collection, for await
walks an asynchronous sequence — values that arrive over
time, such as lines streaming from a file or events from a socket. The loop
suspends between elements and resumes when the next one is ready.
let url = URL(string: "https://example.com/log.txt")!
for try await line in url.lines {
print(line) // prints each line as it streams in
if line.contains("ERROR") { break }
}
Iterating an async sequence with for try await.
You build your own by conforming to AsyncSequence, but most of
the time you'll consume ones the system gives you (URL.lines,
NotificationCenter.notifications, AsyncStream).
Running async code in parallel with async let
Awaiting calls one after another runs them sequentially. When the
calls are independent, that's wasted time. async let starts a
child task immediately and lets you await its result later — so
several pieces of work overlap.
func loadProfile() async throws -> Profile {
async let user = fetchUser(id: 42)
async let posts = fetchPosts(userID: 42)
async let friends = fetchFriends(userID: 42)
// all three downloads run concurrently; we wait for all here
return try await Profile(user: user, posts: posts, friends: friends)
}
Three independent requests overlap instead of running back-to-back.
Each async let is a child of the current task. If you never
await one, Swift cancels and awaits it implicitly when the
scope ends — no leaked work.
Tasks and task groups
A task is a unit of asynchronous work. async let
is convenient when you know the number of child tasks up front. When the
count is dynamic — process every item in an array — use a
task group, which lets you add children in a loop and
collect their results.
func loadThumbnails(ids: [Int]) async throws -> [Int: Image] {
try await withThrowingTaskGroup(of: (Int, Image).self) { group in
for id in ids {
group.addTask { // each child runs concurrently
(id, try await loadThumbnail(id: id))
}
}
var result: [Int: Image] = [:]
for try await (id, image) in group {
result[id] = image // gather results as they finish
}
return result
}
}
A task group fans out a variable number of child tasks and gathers their results.
Structured vs. unstructured tasks
The examples above are structured concurrency: child tasks
(async let, task-group children) live inside a clear scope. The
parent can't return until its children finish, errors propagate up, and
cancellation flows down automatically. The shape of your code mirrors the
shape of the concurrency.
Sometimes you need work whose lifetime isn't tied to the current scope — for
example, kicking off a download from a button tap. That's an
unstructured task, created with Task { }. You
get a handle you can await or cancel later, but
you're responsible for managing it.
// Unstructured: inherits actor context & priority, runs independently
let handle = Task {
try await loadThumbnail(id: 42)
}
// Detached: does NOT inherit context — use sparingly
let detached = Task.detached(priority: .background) {
rebuildSearchIndex()
}
let image = try await handle.value // await the result later
| Aspect | Structured (async let / group) | Unstructured (Task / Task.detached) |
|---|---|---|
| Lifetime | Bound to enclosing scope | Independent of scope |
| Cancellation | Propagates automatically | Manual (handle.cancel()) |
| Error propagation | Bubbles up to parent | Surfaces only when you await .value |
| Context inheritance | Inherits | Task inherits; Task.detached does not |
Task cancellation
Cancellation in Swift is cooperative: cancelling a task
doesn't forcibly stop it — it sets a flag, and your code is expected to
check that flag and wind down. Many built-in async APIs check for you and
throw CancellationError.
func process(_ items: [Item]) async throws {
for item in items {
try Task.checkCancellation() // throws if cancelled
if Task.isCancelled { return } // or check the Bool and bail quietly
await handle(item)
}
}
let job = Task { try await process(bigList) }
job.cancel() // requests cancellation; the task observes it at the next check
Cancellation is cooperative — check isCancelled or call checkCancellation().
Tip: In structured concurrency, cancelling a parent task automatically cancels all of its children. You rarely cancel individual child tasks by hand.
Actors and isolation
Sharing mutable state between concurrent tasks is the classic source of data
races. An actor solves this: it's a reference type that
protects its own mutable state by guaranteeing only one task touches that
state at a time. Its stored properties and methods are
actor-isolated — reachable from outside only with
await.
actor BankAccount {
private var balance = 0
func deposit(_ amount: Int) {
balance += amount // safe: only one task runs this at a time
}
func currentBalance() -> Int { balance }
}
let account = BankAccount()
await account.deposit(100) // cross-actor call needs await
let total = await account.currentBalance()
An actor serializes access to its mutable state, eliminating races by construction.
Inside the actor's own methods you reach balance directly,
without await, because you're already isolated to that actor.
From outside, every access is a potential suspension point, hence
await.
Watch out: Don't reintroduce a race across an
await. Between reading and writing an actor's value, another
task may have run. Prefer a single isolated method that does the
read-modify-write atomically rather than awaiting a getter, computing, then
awaiting a setter.
The main actor and @MainActor
UIKit and SwiftUI must be touched only on the main thread. Swift models the
main thread as a global actor called MainActor. Annotate a
type, method, or property with @MainActor to guarantee it runs
there — the compiler enforces it, so you never need a manual
DispatchQueue.main.async hop again.
@MainActor
final class ProfileViewModel: ObservableObject {
@Published var name = ""
func load() async {
let user = try? await fetchUser(id: 42) // runs off-main during the await
name = user?.name ?? "Unknown" // back on main — safe to update UI state
}
}
Everything in this class is guaranteed to run on the main actor.
Note how the await suspends and the network call happens off the
main actor, but assigning name resumes back on main — the
annotation makes that hop automatic and checked.
Sendable types
For the compiler to prove safety, it needs to know which values are safe to
pass between concurrency domains (across tasks, into an actor, over an
await). Those values conform to the Sendable
protocol. Value types built from Sendable members are
Sendable automatically; reference types must justify it.
// Value type with Sendable members → automatically Sendable
struct User: Sendable {
let id: Int
let name: String
}
// A class is Sendable only if it's safe — e.g. immutable + final
final class Config: Sendable {
let apiKey: String
init(apiKey: String) { self.apiKey = apiKey }
}
// Closures crossing concurrency boundaries are marked @Sendable
func run(_ work: @Sendable @escaping () -> Void) { /* ... */ }
Actors are implicitly Sendable; mutable classes that aren't will raise a compiler error when shared.
Strict concurrency in Swift 6
The headline change in Swift 6 is that data-race safety is checked
at compile time, by default. The compiler tracks isolation and
Sendable conformance across every task and actor boundary. If a
non-Sendable value could be touched from two places at once, you
get an error — not a crash discovered later.
- Code that previously "worked" but had hidden races now fails to compile until fixed.
- Migration is gradual: you can adopt Swift 6 mode per module, and Swift 5 mode offers warnings via
-strict-concurrency=completeto ease the transition. - The fixes are usually structural — mark a type
Sendable, move shared state into an actor, or annotate UI code@MainActor.
Note: Think of strict concurrency as a type system for threads. Just as Swift's optionals turned "nil crashes" into compile-time questions, strict concurrency turns "data races" into compile-time questions.
Wrapping up
Swift concurrency gives you readable asynchronous code (async/await),
safe parallelism (async let, task groups), clear lifetimes
(structured tasks with cooperative cancellation), and race-free shared state
(actors, @MainActor, Sendable). In the Swift 6 era,
the compiler verifies all of it for you. Start by writing linear
async code, reach for actors when you share mutable state, and
let the compiler guide you toward safety.