A comprehensive, hands-on guide to async/await, structured concurrency, actors, Sendable, and the rest of Swift's modern concurrency model. This guide goes deep — from the motivation behind the design to the corners that trip up experienced engineers — with iOS-flavored examples throughout.
Before we write a single line of code, it's worth being clear about what concurrency is, what problem it solves, and why Swift adopted such a specific model when other languages already had async/await.
Computers do two kinds of work: computation and waiting. Computation means actually executing instructions — adding numbers, running loops, transforming data. Waiting means asking some other system to do something — read a file, fetch a web page, wait for a user to tap a button — and twiddling thumbs until the answer comes back.
If your program does these things sequentially, every wait blocks the entire program. Imagine an iOS app that fetches a photo from the network. If that fetch takes 800ms and you do it on the main thread, your UI freezes for 800ms. Buttons don't respond. Animations stutter. The user thinks the app crashed.
The whole point of concurrency is to make waiting productive. While one piece of work is waiting on the network, another piece can be running. Your UI thread keeps drawing frames. Your data layer keeps processing. The CPU stays busy.
These two terms are often confused. They mean related but distinct things:
A concurrent program may or may not run in parallel. An iOS app might have ten concurrent network requests, but they're all waiting — the CPU is barely doing anything. That's concurrency without parallelism. A scientific computation that splits a matrix multiplication across eight cores is parallelism.
Swift's concurrency model handles both, but the language design is overwhelmingly oriented around the first case: I/O-bound, latency-driven workloads where the goal is responsiveness, not raw throughput.
Swift had concurrency before async/await — Grand Central Dispatch (GCD), OperationQueue, completion handlers, DispatchSemaphore, NSLock, Combine. None of this went away. So why add another layer?
The honest answer: every existing approach had compounding problems that the language couldn't help with. Some of them:
Completion handlers create the pyramid of doom. Sequential async work nests deeper and deeper:
func loadProfile(userID: String,
completion: @escaping (Result<Profile, Error>) -> Void) {
fetchUser(id: userID) { result in
switch result {
case .failure(let error): completion(.failure(error))
case .success(let user):
fetchAvatar(url: user.avatarURL) { result in
switch result {
case .failure(let error): completion(.failure(error))
case .success(let avatar):
fetchPosts(for: user.id) { result in
switch result {
case .failure(let error): completion(.failure(error))
case .success(let posts):
completion(.success(Profile(user: user,
avatar: avatar,
posts: posts)))
}
}
}
}
}
}
}
Each level adds error handling that has to be threaded through manually. Forget one branch, and the completion handler is never called — your caller waits forever. This is unbelievably common in real code.
GCD threading is invisible to the type system. A function that hops to a background queue and calls back has no signature distinguishing it from one that doesn't. A class meant to be used from a single queue has no way to tell the compiler that. Every misuse becomes a runtime bug.
Cancellation is bolted on. GCD has DispatchWorkItem.cancel(), but cancellation doesn't propagate. If you start three queue tasks and want to cancel them all when the user navigates away, you have to wire it manually. Combine has cancel(), but composing cancellation across multiple chains is fiddly.
Data races are invisible until they bite. Two queues touching the same Array is a race. There's no compile-time check. You ship it, and weeks later a customer has a crash log you can't reproduce.
Swift Concurrency was designed to fix these, all at once, with type-system support. The features we'll cover in this guide aren't independent — they fit together as a single coherent model.
Swift Concurrency rests on four ideas that the rest of this guide explores:
Each of these on its own is useful. Together they let the compiler catch entire categories of bugs that used to be runtime mysteries. Swift 6 turns the warnings into errors, making correctness the default.
Before we get into syntax, hold this picture in your head:
Everything else is detail. We'll fill in the detail systematically — starting with what life looked like before, so you appreciate what async/await actually changed.
You can write modern Swift Concurrency without ever having seen GCD or Combine, but the existing world isn't going away. You'll encounter these APIs in every real codebase. You'll bridge old code into new code. You'll need to understand why a callback-based API is harder to compose than an async one. So a brief tour.
The lowest level. You can spawn a thread directly:
let thread = Thread {
print("hello from thread")
}
thread.start()
Nobody writes code at this level anymore on Apple platforms — you'd have to manage the thread's lifecycle, synchronization, communication, all by hand. But it's worth knowing it exists, because every higher-level mechanism eventually maps to threads underneath.
GCD is Apple's libdispatch — a C library exposed to Swift as the Dispatch module. It introduced queues as the unit of work, abstracting threads. You hand a closure to a queue, and GCD finds a thread to run it on.
import Dispatch
DispatchQueue.global(qos: .userInitiated).async {
let data = expensiveComputation()
DispatchQueue.main.async {
updateUI(with: data)
}
}
Two queues here: a global background queue for the work, and DispatchQueue.main for the UI update. The pattern of "do work in background, hop to main to update UI" was for years the bread-and-butter of iOS development.
GCD has serial queues (one task at a time) and concurrent queues (many at once), and you can build your own:
let myQueue = DispatchQueue(label: "com.example.work")
myQueue.async { /* serial */ }
let parallelQueue = DispatchQueue(label: "com.example.parallel",
attributes: .concurrent)
parallelQueue.async { /* one of many */ }
Serial queues were the standard way to make a class thread-safe before actors:
final class Counter {
private let queue = DispatchQueue(label: "counter")
private var _value = 0
func increment() {
queue.async { self._value += 1 }
}
func value(completion: @escaping (Int) -> Void) {
queue.async { completion(self._value) }
}
}
This works, but compare it to an actor (chapter 18) — it's verbose, the API is callback-based, and there's no compiler help if you accidentally read _value directly from outside.
DispatchSemaphore and synchronization primitivesGCD exposes semaphores, locks, barriers — the classics:
let lock = NSLock()
var shared: [String] = []
queue1.async {
lock.lock(); defer { lock.unlock() }
shared.append("a")
}
queue2.async {
lock.lock(); defer { lock.unlock() }
shared.append("b")
}
Forget the lock(), or forget the unlock(), or hold the lock across an await somewhere down the chain — you've got a deadlock or a race. The compiler can't help.
OperationQueue and OperationA higher-level API on top of GCD that adds dependencies, priorities, and cancellation as first-class concepts:
let queue = OperationQueue()
let opA = BlockOperation { print("A") }
let opB = BlockOperation { print("B") }
opB.addDependency(opA)
queue.addOperations([opA, opB], waitUntilFinished: false)
Operations were the precursor to structured concurrency in spirit — dependencies form a DAG, cancellation can propagate, you can subclass Operation to encapsulate complex async work. They're still useful for image-loading pipelines and similar coordination, but most code has moved on.
The dominant pattern of pre-2021 Swift. Functions take a closure to call when they finish:
func fetchUser(id: String, completion: @escaping (Result<User, Error>) -> Void) {
URLSession.shared.dataTask(with: makeURL(id)) { data, response, error in
if let error = error {
completion(.failure(error))
return
}
guard let data = data else {
completion(.failure(MyError.noData))
return
}
do {
let user = try JSONDecoder().decode(User.self, from: data)
completion(.success(user))
} catch {
completion(.failure(error))
}
}.resume()
}
The pyramid-of-doom problem we saw earlier comes from sequencing these. The other big problem: it's easy to forget to call the completion handler in some branch. The function silently never finishes, and the caller hangs forever. There's no compiler check.
Apple's reactive framework, introduced in iOS 13. Values flow through pipelines of operators:
import Combine
var cancellables: Set<AnyCancellable> = []
URLSession.shared.dataTaskPublisher(for: url)
.map(\.data)
.decode(type: User.self, decoder: JSONDecoder())
.receive(on: DispatchQueue.main)
.sink(receiveCompletion: { completion in
if case .failure(let error) = completion {
print("error: \(error)")
}
}, receiveValue: { user in
self.user = user
})
.store(in: &cancellables)
Combine is genuinely useful for stream-shaped problems — UI bindings, debouncing, throttling, combining multiple async sources. But for "fetch one thing, then do something with it," it's overkill, and the Cancellable storage discipline is its own form of bookkeeping.
Combine still exists and isn't deprecated. SwiftUI uses it under the hood for @Published and ObservableObject. But for new async work, async/await is what you reach for.
Look at what these APIs share. Every one of them:
try/throw flow into a parallel error system (callbacks, Result, Combine's Failure).Swift Concurrency unifies all of these. Async functions read top-to-bottom. Errors use try/throw. Cancellation propagates through a tree of tasks automatically. The compiler enforces thread safety. You'll still see GCD and Combine in real code, and you'll bridge them when you have to (chapters 14, 26, 27), but you won't reach for them first anymore.
Let's get our hands on the keyboard. Two new keywords: async marks a function that can suspend, and await marks the points where suspension might happen.
func sayHello() async -> String {
return "hello"
}
async goes after the parameter list and before the return arrow. This function is async, but it doesn't actually suspend anywhere — it just returns a value. That's legal. An async function is allowed to be able to suspend, not required to actually do it.
To call it:
let greeting = await sayHello()
print(greeting)
That's it. await says "this call might suspend; I'm prepared for that." The compiler requires await at every call to an async function. You can't accidentally call async code without acknowledging it.
But there's a catch: where does this await happen? You can't write it at the top level of a file in most contexts, and you can't write it inside a regular synchronous function. await is only legal inside another async function, or inside a Task { } block, or in a few other async contexts (like @main or top-level scripts). We'll get to how to bridge sync→async in chapter 6.
A function that does actual asynchronous work — fetching JSON from an endpoint:
func fetchUserName(id: String) async throws -> String {
let url = URL(string: "https://api.example.com/users/\(id)")!
let (data, _) = try await URLSession.shared.data(from: url)
let user = try JSONDecoder().decode(User.self, from: data)
return user.name
}
Read this top-to-bottom. Build a URL. Fetch data — this is the suspension point, marked with await and try (it can also throw). Decode. Return. No completion handler, no nesting, no Result type. The error handling uses normal try/throw. The async control flow is invisible — you write it as if it were sync, and the compiler handles the suspension.
Call it:
do {
let name = try await fetchUserName(id: "42")
print(name)
} catch {
print("failed: \(error)")
}
This is structurally identical to synchronous error-handling code. Only the await keyword tips you off that there's any concurrency happening at all.
Let's be precise about the language semantics, because the casual mental model can mislead you.
async is a function modifier. An async function has a different type than a non-async one — () -> Int and () async -> Int are different types. Async functions can only be called from async contexts. The compiler tracks this through every layer.
await marks a potential suspension point. It's not a guarantee that the function will suspend — many async calls run to completion without ever yielding the thread. The keyword is a marker for the reader and the compiler: "the world might change between here and the next line, because the runtime could run other tasks before resuming this one."
That's the most important consequence of await: across an await, anything could have happened. Any other task on the same actor could have run. State you read before could have changed. We'll come back to this when we get to actor reentrancy (chapter 20).
You can chain async calls naturally:
func loadProfile(userID: String) async throws -> Profile {
let user = try await fetchUser(id: userID)
let avatar = try await fetchAvatar(url: user.avatarURL)
let posts = try await fetchPosts(for: user.id)
return Profile(user: user, avatar: avatar, posts: posts)
}
Compare to the pyramid-of-doom version from chapter 1. Same logic, written linearly. The errors propagate via try. The function is async, so it returns its value the way a sync function does.
But notice: these three fetches happen sequentially. We await fetchUser, then fetchAvatar, then fetchPosts. If they don't depend on each other, we're wasting time. If each takes 200ms, the whole thing takes 600ms when it could take 200. We'll fix that in chapter 9 with async let.
Properties can be async too:
extension User {
var avatar: UIImage {
get async throws {
let (data, _) = try await URLSession.shared.data(from: avatarURL)
return UIImage(data: data) ?? UIImage()
}
}
}
let img = try await user.avatar
Initializers can be async:
final class ImageLoader {
let cache: [URL: Data]
init(prefetching urls: [URL]) async throws {
var cache: [URL: Data] = [:]
for url in urls {
let (data, _) = try await URLSession.shared.data(from: url)
cache[url] = data
}
self.cache = cache
}
}
let loader = try await ImageLoader(prefetching: [url1, url2])
Subscripts can be async too. The general rule: anywhere you can write a function or computed accessor, you can mark it async if it might suspend.
Three things to internalize:
async from sync code without bridging through Task or similar.async function inside another async function needs await.await doesn't mean "suspend"; it means "this point is allowed to suspend."The fourth thing — the most important thing — comes from the third: state can change across an await. If you read a value before an await, don't trust that it's still that value after. We'll be saying this repeatedly. It's the heart of why concurrent code is hard, and the heart of why actors and reentrancy matter.
Let's spend a chapter on await, because most concurrency bugs are misunderstandings of what it actually does.
A task (we'll formalize this in chapter 7) is the unit of concurrent work. Think of it as a lightweight thread, but with much less overhead — Swift can have thousands of tasks where you'd never have thousands of threads.
When a task hits an await and the operation behind it isn't ready yet (the network response hasn't come, the file isn't read, the actor it's calling is busy), the task suspends. Suspending means:
This is why async functions are so cheap: an idle async function doesn't consume a thread. Ten thousand awaiting network requests is fine. Ten thousand blocked threads would crush the system.
Here's the consequence that surprises people coming from threads:
func doStuff() async {
let beforeID = Thread.current
await Task.sleep(for: .seconds(1))
let afterID = Thread.current
// beforeID may not equal afterID
}
After a suspension, you might be on a different thread. The runtime is free to resume your task wherever it likes. Don't tie your code to a specific thread unless you've put it on an actor (which is the right way to express that requirement — chapter 22).
This is also why pthread_self() checks and thread-local storage from the C world don't work the way you'd expect with Swift Concurrency. The "current thread" is an implementation detail, not part of the model.
Read this carefully:
let a = await fetchA() // takes 200ms
let b = await fetchB() // takes 200ms
let c = await fetchC() // takes 200ms
// total: 600ms
Each await waits for its operation to complete before the next line runs. They run sequentially, even though they could run concurrently if they don't depend on each other. The async function is still a linear sequence of statements.
This is good. It means you read async code top-to-bottom like sync code. Concurrency only happens when you explicitly ask for it (chapter 9 and 10).
You can have several awaits in a single expression:
let combined = await fetchA() + (await fetchB())
The compiler evaluates left to right, so this is the same as:
let aVal = await fetchA()
let bVal = await fetchB()
let combined = aVal + bVal
You only need to write await once per statement even if there are multiple async calls — the keyword applies to the whole expression. So this is also legal:
let combined = await (fetchA() + fetchB())
Many style guides prefer separating them into multiple lines for clarity.
try await and await tryBoth are legal but the convention is try await:
let user = try await fetchUser(id: "42")
It reads as "try to wait for fetchUser." The reverse order, await try, is legal but unusual. Pick one and be consistent — most codebases use try await.
If a function is async but doesn't throw, you only need await. If it throws but isn't async, you only need try. If both, both:
func sync() throws -> Int { ... } // try only
func asyncNoThrow() async -> Int { ... } // await only
func asyncThrow() async throws -> Int { ... } // try await
A function marked async is able to suspend, but doesn't have to. Some async functions never actually yield — they just have async signatures because they call into other async code that might. The compiler conservatively requires await, but at runtime, control may not actually leave your function.
This matters for performance: an async function that never suspends has only modest overhead. The "fast path" through async code is reasonably efficient.
await is allowedYou can write await only inside an async context:
async function or methodTask { }Task.detached { }for await loopasync let binding's right-hand side@main async entry pointsTrying to write await from a regular sync function is a compile error: "'async' call in a function that does not support concurrency." We'll cover bridging in chapter 6.
You don't need to know this to use Swift Concurrency, but it helps demystify the magic.
When the compiler sees an async function, it transforms it into a state machine. Each await becomes a potential exit point. The state machine keeps track of "where am I in this function," "what local variables do I hold," and "what should I do when I resume."
When you call an async function, the runtime allocates a small structure (the task's continuation) holding this state. When the function suspends, control returns to the scheduler. When the awaited thing finishes, the scheduler puts the task back into the run queue. A worker thread picks it up and resumes the state machine from where it left off.
The "cooperative thread pool" Swift Concurrency uses has, by default, one thread per CPU core. Tasks share these threads — they're not 1:1 with kernel threads. This is why blocking inside an async task is so destructive: you'd be holding a precious thread that could otherwise serve thousands of concurrent tasks.
Don't block. Specifically:
// BAD: blocks the cooperative thread
func wrong() async -> Data {
let semaphore = DispatchSemaphore(value: 0)
var result: Data = Data()
URLSession.shared.dataTask(with: url) { data, _, _ in
result = data!
semaphore.signal()
}.resume()
semaphore.wait() // <-- blocks!
return result
}
This blocks one of the worker threads in the cooperative pool. If enough tasks do this, you starve the pool — pending async work that would have unblocked the semaphore can't run because all threads are stuck waiting. You can deadlock the entire runtime.
The right way is to bridge the callback API into async (chapter 14), or use URLSession's built-in async API: try await URLSession.shared.data(from: url).
Other things to never do in an async task: hold a DispatchQueue.sync, call Thread.sleep, call any pthread blocking primitive. If you need to wait, await. If you need to delay, try await Task.sleep(...).
We touched on this in chapter 3. Async functions can throw. The combined keyword is async throws, and you call them with try await:
func decodeUserFromServer(id: String) async throws -> User {
let url = makeURL(id: id)
let (data, response) = try await URLSession.shared.data(from: url)
guard let httpResponse = response as? HTTPURLResponse,
(200..<300).contains(httpResponse.statusCode) else {
throw NetworkError.badStatus
}
return try JSONDecoder().decode(User.self, from: data)
}
Errors flow naturally:
do {
let user = try await decodeUserFromServer(id: "42")
print(user.name)
} catch NetworkError.badStatus {
print("server returned an error")
} catch is DecodingError {
print("response shape was unexpected")
} catch {
print("other failure: \(error)")
}
This is the same do/catch/throw you'd write in sync code. No Result types. No completion-handler-with-Result-parameter. The error system is unified.
Swift 6 added typed throws — you can declare which error type a function can throw:
enum FetchError: Error {
case notFound
case network(URLError)
}
func fetch(id: String) async throws(FetchError) -> Data {
// ...
}
When you call this, the catch can narrow:
do {
let data = try await fetch(id: "42")
} catch .notFound {
// FetchError.notFound (the enum case is inferred)
} catch .network(let urlError) {
// ...
}
Typed throws are useful but not always the right call — if the function might evolve to throw new error kinds, untyped throws is more flexible. Apple's frameworks generally still use untyped throws.
rethrows works with async, the same as it does in sync code:
func map<T, U>(_ items: [T], _ transform: (T) async throws -> U) async rethrows -> [U] {
var result: [U] = []
for item in items {
let mapped = try await transform(item)
result.append(mapped)
}
return result
}
This function is async rethrows — it throws if and only if the closure throws. If you pass a non-throwing closure, the call site doesn't need try:
let lengths = await map(["a", "ab", "abc"]) { $0.count } // no try
let parsed = try await map(strings) { try parse($0) } // try needed
When a task is cancelled (chapter 11), Swift expresses this by throwing CancellationError. Code that wants to be cooperatively cancellable should call try Task.checkCancellation() periodically:
func longComputation() async throws -> [Int] {
var result: [Int] = []
for i in 0..<1_000_000 {
try Task.checkCancellation() // throws CancellationError if cancelled
result.append(expensiveStep(i))
}
return result
}
If the task running this is cancelled, checkCancellation throws CancellationError, the function unwinds, and the caller's do/catch can detect it:
do {
let result = try await longComputation()
} catch is CancellationError {
print("cancelled")
}
We'll see more cancellation patterns in chapter 11.
try? and try!Both work with async:
let user = try? await fetchUser(id: "42") // Optional<User>
let definitelyUser = try! await fetchUser(id: "42") // crash on error
Same semantics as sync. try? turns a throw into nil. try! traps. Use try! only when you're absolutely certain the call can't fail in this context, and document why.
You can have async throwing functions that return Void:
func saveProfile(_ profile: Profile) async throws {
let data = try JSONEncoder().encode(profile)
try await persistData(data)
}
try await saveProfile(myProfile)
No return value, just side effects. Common pattern.
Async functions can only be called from async contexts. But your application starts in a sync context — main(), view-controller methods, button-tap handlers, SwiftUI view bodies. How do you bridge?
Task { } — the most common bridgeA Task { ... } block creates a new top-level task. You can call async code inside it. It returns immediately to the caller — the work continues independently.
button.addTarget(self, action: #selector(didTap), for: .touchUpInside)
@objc func didTap() {
Task {
let user = try? await fetchUser(id: "42")
await MainActor.run {
self.label.text = user?.name
}
}
}
Reading this top to bottom: the user taps a button. The sync handler didTap runs. Inside, we kick off a Task. The handler returns immediately. The task runs, awaits the fetch, hops to the main actor, updates the label.
Task { } inherits some context from where it's created — the actor (often MainActor if you're inside one), the priority, task-local values. We'll dig into this in chapter 7.
Task.detached { } — no inheritanceSometimes you don't want to inherit context. Task.detached creates a task that doesn't inherit the current actor or task-local values:
Task.detached(priority: .background) {
let report = await generateAnnualReport()
await archive(report)
}
Use this when you genuinely want isolation from the calling context. Most of the time, plain Task { } is what you want.
@main and async entry pointsYour app's entry point can be async. With @main:
@main
struct MyApp {
static func main() async throws {
let server = try await Server.start(port: 8080)
try await server.serveForever()
}
}
async throws on main() is allowed. The whole program is then driven by an async function from the start.
In SwiftUI:
@main
struct MyApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
The body isn't async, but inside views you have plenty of ways to kick off async work — .task { } modifiers (chapter 28), Task { } blocks, etc.
MainActor.assumeIsolatedA new mechanism (Swift 5.9) for asserting at runtime that you're already on the main actor:
@MainActor
func updateUI() { /* ... */ }
func someUnknownContext() {
MainActor.assumeIsolated {
updateUI() // OK because we asserted
}
}
This is for callbacks from frameworks that you know deliver on the main thread but don't have an @MainActor annotation on their callback type. Use sparingly — it's a runtime check, not a compile-time one.
It is technically possible to use a DispatchSemaphore to block a thread until an async task completes. Don't. As we discussed in chapter 4, blocking a cooperative thread is destructive. If you find yourself wanting "just give me the value synchronously," you almost certainly need to make the surrounding context async too.
The one legitimate exception is in tests, and even there you should prefer XCTest's async test support (chapter 29).
A standard idiom:
func didTap() {
Task {
do {
try await saveDocument()
} catch {
logger.error("save failed: \(error)")
await showAlert("Could not save")
}
}
}
The task is fire-and-forget — we don't await it from didTap. But we still handle errors inside. Without the do/catch, an error would propagate out of the task body and disappear silently.
If you want to cancel the task later (the user navigates away, taps cancel, etc.), store it:
private var loadTask: Task<Void, Never>?
func didTap() {
loadTask?.cancel() // cancel any previous one
loadTask = Task {
do {
try await loadEverything()
} catch {
// handle
}
}
}
deinit {
loadTask?.cancel()
}
A Task<Void, Never> is a task that returns Void and can't throw. The two type parameters are the success type and the failure type. Task<User, Error> is a task returning User that might throw.
We'll come back to cancellation in chapter 11.
In a .swift file run as a script (not a package, not an Xcode project), you can use await at the top level:
// main.swift
let data = try await URLSession.shared.data(from: URL(string: "https://example.com")!).0
print(data)
This is convenient for tools and scripts. In an iOS app, you don't have a top-level script context — you go through Task { } or @main.
We've been using Task casually. Time to formalize it.
A Task represents a piece of asynchronous work. Every async function call happens inside some task — the task is the runtime entity that holds the work's state, manages its priority, tracks cancellation, and delivers its result.
Tasks have:
Error or Never)When you write Task { ... }, you're creating a top-level task. When you call an async function from within a task, you don't create a new task — the call runs as part of the current task.
Two kinds of tasks:
Top-level tasks are created with Task { ... } or Task.detached { ... }. They have no parent. They run independently, and the caller doesn't automatically wait for them.
Child tasks are created within structured concurrency primitives — async let or task groups. They have a parent task that owns them. The parent can't finish until all children are done. Cancellation propagates from parent to children automatically.
This guide spends a lot of time on child tasks, because they're the safer, more compositional default. Top-level tasks are the bridge from sync to async; child tasks are the unit of fine-grained concurrent work.
Task.value and Task.resultA Task is a handle to its work. You can await its result:
let task = Task {
return try await fetchUser(id: "42")
}
let user = try await task.value
task.value awaits the task's result and rethrows any error. task.result gives you a Result<Success, Failure> instead:
let result = await task.result
switch result {
case .success(let user): print(user)
case .failure(let error): print(error)
}
task.result doesn't throw — the success/failure is in the Result.
This is occasionally useful, but mostly you write try await task.value because it composes naturally with the rest of your async code.
Task.isCancelled and Task.checkCancellation()Inside a task, you can check whether you've been cancelled:
Task {
for i in 0..<1000 {
if Task.isCancelled { return } // or: try Task.checkCancellation()
await process(i)
}
}
Task.isCancelled is a static property. It checks the currently running task — Swift Concurrency knows which task you're in. This is also true of Task.checkCancellation() and Task.currentPriority.
Task.yield()A way to voluntarily give up the thread without actually waiting on anything:
for i in 0..<1_000_000 {
process(i)
if i.isMultiple(of: 10_000) {
await Task.yield()
}
}
This is for long CPU-bound loops on a cooperative thread. Without yielding, you'd hold the thread until the loop ends, blocking other tasks. With periodic Task.yield(), the runtime can pull other tasks in to run.
For most code you don't need yield. But if you have a pure-CPU async function that runs for a long time, sprinkle it in.
Task.sleepSuspend the current task for a duration:
try await Task.sleep(for: .seconds(2))
try await Task.sleep(for: .milliseconds(500))
try await Task.sleep(until: .now + .seconds(3), clock: .continuous)
The for: form takes a Duration. The until: form takes a deadline and a clock.
Task.sleep is cancellation-aware: if the task is cancelled while sleeping, sleep throws CancellationError immediately rather than waiting out the duration. This is what you want — there's no point in waiting if the work has been cancelled.
There was an older API, Task.sleep(nanoseconds:), that you'll see in pre-iOS-16 code. Same idea, less ergonomic units. Prefer Duration when targeting iOS 16+.
A Task value is a handle — you can hold onto it, pass it around, cancel it, await its result. Common patterns:
final class ViewModel {
private var loadTask: Task<Void, Never>?
func startLoading() {
loadTask = Task {
await load()
}
}
func stopLoading() {
loadTask?.cancel()
}
}
A Task<Success, Failure> — note the generics — knows what it returns and what it can throw. Task<Void, Never> is a fire-and-forget task. Task<User, Error> is a task that produces a User or throws.
Task.detached in detailDetached tasks don't inherit anything from their creation context:
.medium)Task.detached(priority: .background) {
let report = try await generateReport()
try await archive(report)
}
The use case is "I'm in some context that doesn't represent what this work should be." If you're inside a high-priority UI task and want to kick off a low-priority background job that shouldn't get the UI's priority, detached makes sense.
In practice, plain Task { } covers 90% of cases. Reach for detached when you have a reason.
A subtle but important point: if you write Task { ... } inside a method and the object is deallocated, the task keeps running:
final class Loader {
func start() {
Task {
await self.expensive()
}
}
func expensive() async { /* ... */ }
deinit { print("deinit") }
}
var loader: Loader? = Loader()
loader?.start()
loader = nil
// Loader's deinit may run before the Task's body completes,
// because the Task captures `self` strongly.
The closure passed to Task { ... } captures self strongly by default. The task holds onto self until it completes. If you want the task to release its reference when the owner goes away, capture weakly:
Task { [weak self] in
guard let self else { return }
await self.expensive()
}
This is exactly the same dance as completion-handler callbacks: balance strong and weak captures based on lifetime requirements.
This is the most important idea in Swift Concurrency. Without it, you have async/await — a nice tool for sequencing work. With it, you have a discipline that makes concurrent code as predictable as sequential code.
A child task's lifetime is bounded by its parent's scope. The parent cannot return until all children have finished. If the parent is cancelled, all children are cancelled. If a child throws, the parent can decide whether to propagate the throw, suppress it, or cancel siblings.
That's it. Everything else is mechanism for expressing this.
Without structured concurrency, async tasks are easy to leak. You start a Task { } somewhere, forget to track it, and it runs until completion regardless of what the caller is doing. The caller has no way to know it's still running. Cancellation has to be wired manually. Lifetimes are an unmodeled aspect of your program.
With structured concurrency, the syntactic structure of your code maps to the structure of the concurrent work. If a function returns, you know all of its concurrent work has finished or been cancelled. There's no leak. There's no orphan task running off in the background.
Swift gives you two ways to start child tasks:
async let — the lightweight, compile-time-bounded form. Best for a fixed number of concurrent operations whose values you'll combine.We'll cover both in detail in chapters 9 and 10.
Task { } and Task.detached { } are not structured concurrency primitives. They create top-level tasks that aren't bounded by any scope. Their lifetime is independent of where they were started. They're the explicit escape hatch from structured concurrency, used to bridge from sync code or to explicitly start an unbounded task.
This is why the guidance is: prefer structured concurrency. Use Task { } only when you genuinely need an unstructured top-level task — bridging from sync, or kicking off background work whose lifetime you'll manage by hand.
Imagine fetching a user and their avatar:
// Unstructured (don't do this casually):
func loadProfile() async throws -> Profile {
var user: User?
var avatar: UIImage?
Task { user = try? await fetchUser() }
Task { avatar = try? await fetchAvatar() }
// wait somehow??
return Profile(user: user!, avatar: avatar!) // good luck
}
This is unworkable. The two inner tasks aren't bounded by the function's scope. The function will return immediately after starting them, with user and avatar still nil. Crash.
// Structured (this works):
func loadProfile() async throws -> Profile {
async let user = fetchUser()
async let avatar = fetchAvatar()
return try await Profile(user: user, avatar: avatar)
}
The two async let bindings are child tasks of the current function. The function won't return until both have completed (or one has thrown). They run concurrently. The await on the right of Profile(...) is what actually waits for them.
This is structured concurrency in action: clean, bounded, automatic.
If the parent of these tasks is cancelled, both children are cancelled too — automatically, with no extra code:
let task = Task {
return try await loadProfile() // creates child tasks internally
}
// later:
task.cancel() // children of loadProfile() get cancelled too
This propagation is transitive. Cancel the top-level task, and cancellation flows down through every layer of structured concurrency to the leaves. No manual cancellation token wiring.
If fetchUser throws inside an async let, the function loadProfile will throw the same error from the await. The other child task (fetchAvatar) is automatically cancelled to avoid wasting work.
func loadProfile() async throws -> Profile {
async let user = fetchUser() // suppose this throws
async let avatar = fetchAvatar() // gets cancelled
return try await Profile(user: user, avatar: avatar)
// ^ the throw from `user` exits this function;
// `avatar` is cancelled before the function returns
}
This is graceful failure. No half-done work. No partial state hanging around.
Structured concurrency is the model. async/await is the syntax. Tasks are the runtime entity. Together they give you concurrency that composes — you can take a structured async function and put it inside another, and the lifetimes nest cleanly. Outputs come back. Errors come back. Cancellation flows down.
The two specific tools for using it — async let and task groups — are the next two chapters.
async let is the simplest structured concurrency primitive. It binds a name to a value that is being computed in a child task, and you can use that name to await the result later.
async let user = fetchUser(id: "42")
async let posts = fetchPosts(for: "42")
let combined = try await (user, posts)
When this code runs:
async let user = fetchUser(id: "42") starts a child task running fetchUser. It does not await. The task starts. Control continues.async let posts = fetchPosts(for: "42") starts another child task running fetchPosts. Both tasks are now running concurrently.try await (user, posts) waits for both to complete and combines their results into a tuple.The two fetches run in parallel. If each takes 200ms, the total wait is ~200ms, not 400.
async let x = foo() looks like a let binding. It nearly is. The differences:
x doesn't have type T — it's special syntax. Reading it requires await.You can think of async let x = foo() as roughly equivalent to:
let xTask = Task { foo() }
// later:
let x = await xTask.value
Except the structured version is automatically bounded by scope and propagates cancellation correctly.
async letsYou don't have to await them all at once:
async let user = fetchUser(id: "42")
async let posts = fetchPosts(for: "42")
let userVal = try await user // wait for user
print("got user: \(userVal.name)")
let postsVal = try await posts // wait for posts
print("got \(postsVal.count) posts")
You can also await the same async let multiple times — but only the first time actually waits; subsequent reads return the already-computed value.
async let is most natural when the operations are independent. If one depends on another, write it sequentially:
let user = try await fetchUser(id: "42")
async let posts = fetchPosts(for: user.id)
async let avatar = fetchAvatar(url: user.avatarURL)
return try await Profile(user: user, posts: posts, avatar: avatar)
Here user is fetched first (sequentially), then we kick off posts and avatar concurrently using its values, then combine.
If you don't await an async let, what happens when the function returns?
The compiler enforces that you await (or implicitly cancel) every async let before the enclosing scope ends. Specifically: any async let that hasn't been awaited is implicitly cancelled and awaited at the end of the scope.
func experiment() async {
async let result = expensiveWork()
// ... we never await result
return // implicit: cancel `result`, await its termination
}
The function does block briefly at the end while the child task tears itself down — this is what makes the concurrency structured. There are no orphans.
In practice, this means you should always actually use the async lets you start. Starting one and not awaiting it is wasted work and slightly slows the function's exit.
If an async let's right-hand side throws, the error is delivered when you await that binding:
async let user = throwingFetch() // throws
do {
let u = try await user
} catch {
// error from throwingFetch lands here
}
If you have multiple async lets and one throws when you await it, the others are still child tasks of the enclosing scope. They'll be implicitly cancelled when the function unwinds:
func loadAll() async throws -> (User, Posts) {
async let user = fetchUser() // suppose this throws
async let posts = fetchPosts()
return try await (user, posts) // throw exits here; posts gets cancelled
}
The try await (user, posts) evaluates the tuple by awaiting both. The compiler awaits user first; if it throws, the throw propagates. Before propagating, the runtime cancels and awaits posts. Clean exit.
async letasync let is for a fixed, known-at-compile-time set of concurrent operations. You can't put it inside a loop:
// you might want to write:
for id in ids {
async let result = fetch(id) // ERROR: 'async let' in loop
}
The compiler won't let you, because the lifetimes wouldn't be properly nested. For dynamic concurrency, you use task groups (chapter 10).
async let is the right tool when:
For everything else — dynamic counts, processing results as they arrive, or producing a single computed value from many inputs — task groups are the right tool.
A nice realistic example:
struct FullProfile {
let user: User
let posts: [Post]
let followers: Int
let following: Int
let avatar: UIImage
}
func loadFullProfile(userID: String) async throws -> FullProfile {
async let user = fetchUser(id: userID)
async let posts = fetchPosts(for: userID)
async let followers = fetchFollowerCount(for: userID)
async let following = fetchFollowingCount(for: userID)
async let avatar = fetchAvatar(for: userID)
return try await FullProfile(
user: user,
posts: posts,
followers: followers,
following: following,
avatar: avatar
)
}
Five concurrent network calls, structured cleanly. If the user navigates away before the function returns, cancelling the parent task will cancel all five children. If any throws, the others are cancelled and the error propagates out. Hard to get more concise.
async let handles a fixed set of concurrent operations. Task groups handle a dynamic set: any number of children, started and processed at runtime.
let results = await withTaskGroup(of: Int.self) { group in
for i in 0..<10 {
group.addTask {
return await compute(i)
}
}
var total = 0
for await value in group {
total += value
}
return total
}
What's happening:
withTaskGroup(of: Int.self) opens a scope with a group that produces Int values.group.addTask { ... } to spawn child tasks. Each runs concurrently.AsyncSequence — for await value in group yields each child's result as it completes.The full call returns whatever the closure returns — Int (the sum) in this case.
Task groups don't preserve task-creation order. The first task to finish is the first value yielded by the iteration:
await withTaskGroup(of: String.self) { group in
group.addTask {
try? await Task.sleep(for: .seconds(3))
return "slow"
}
group.addTask {
try? await Task.sleep(for: .seconds(1))
return "fast"
}
for await result in group {
print(result) // "fast", then "slow"
}
}
This is exactly what you want for many problems: process whatever's ready first, don't wait on the slowest.
withTaskGroup(of: T.self) declares that every child task returns T. If you need different return types, use a sum type:
enum FetchResult {
case user(User)
case posts([Post])
}
await withTaskGroup(of: FetchResult.self) { group in
group.addTask { .user(await fetchUser()) }
group.addTask { .posts(await fetchPosts()) }
for await result in group {
switch result {
case .user(let u): print("user: \(u)")
case .posts(let p): print("posts: \(p.count)")
}
}
}
withTaskGroup is for non-throwing children. For throwing, use withThrowingTaskGroup:
let urls: [URL] = [...]
let datas = try await withThrowingTaskGroup(of: Data.self) { group in
for url in urls {
group.addTask {
let (data, _) = try await URLSession.shared.data(from: url)
return data
}
}
var allData: [Data] = []
for try await data in group {
allData.append(data)
}
return allData
}
If any child throws, that error is delivered when you iterate (for try await). The other children are not automatically cancelled — but you can cancel them explicitly:
do {
for try await data in group {
allData.append(data)
}
} catch {
group.cancelAll() // cancel any remaining children
throw error
}
Or simpler, the moment you throw out of the withThrowingTaskGroup body, all remaining children are cancelled automatically. So a try-rethrow pattern is:
try await withThrowingTaskGroup(of: Data.self) { group in
for url in urls {
group.addTask { try await fetch(url) }
}
var results: [Data] = []
for try await data in group {
results.append(data)
}
return results
}
If the iteration throws, the group's body exits with an error, and the group cancels remaining children before the withThrowingTaskGroup call returns.
If you don't need the results — say you're firing off a bunch of side-effecting tasks — there's withDiscardingTaskGroup:
await withDiscardingTaskGroup { group in
for event in events {
group.addTask { await log(event) }
}
// implicitly waits for all to finish
}
This is more memory-efficient than a regular task group when you don't iterate results, because it doesn't accumulate them.
There's also withThrowingDiscardingTaskGroup which throws if any child throws, cancelling all others.
addTask vs addTaskUnlessCancelledaddTask always adds the child task. addTaskUnlessCancelled only adds if the group hasn't been cancelled — useful in loops where you want to stop spawning new work once cancellation has started:
for url in urls {
let added = group.addTaskUnlessCancelled {
try await fetch(url)
}
if !added { break }
}
cancelAll()You can cancel all remaining children at any point:
for try await result in group {
if result.isBadEnoughToStop {
group.cancelAll()
break
}
process(result)
}
The cancelAll is cooperative — children that aren't checking cancellation will run to completion, but well-written child tasks will throw CancellationError on their next checkpoint.
Task groups don't have a built-in concurrency limit. If you addTask 10,000 times, you start 10,000 children. For some workloads (network requests against a rate-limited API) this is bad.
A common pattern is a "running count" with manual coordination:
func fetchAll(urls: [URL], maxConcurrent: Int) async throws -> [Data] {
try await withThrowingTaskGroup(of: (Int, Data).self) { group in
var iterator = urls.enumerated().makeIterator()
var inflight = 0
while inflight < maxConcurrent, let (i, url) = iterator.next() {
group.addTask { (i, try await fetch(url)) }
inflight += 1
}
var results: [(Int, Data)] = []
while let next = try await group.next() {
results.append(next)
inflight -= 1
if let (i, url) = iterator.next() {
group.addTask { (i, try await fetch(url)) }
inflight += 1
}
}
return results.sorted { $0.0 < $1.0 }.map(\.1)
}
}
Here we keep at most maxConcurrent tasks running. As one finishes, we start another. We carry the index along to preserve order, then sort at the end.
For more complex scheduling, swift-async-algorithms provides composable utilities, or you build a small actor-based scheduler.
Use task groups when:
Use async let when you have a small fixed set of independent operations.
A subtle but useful detail: TaskGroup and ThrowingTaskGroup conform to AsyncSequence. Inside the body, you can use for await value in group (or for try await). Outside the body, the group doesn't exist anymore.
You can also call group.next() directly:
while let result = try await group.next() {
process(result)
}
This is equivalent to the for-loop and sometimes useful when you want explicit control.
Cancellation is one of the highest-value features of Swift Concurrency. Used well, it makes your code responsive and resource-efficient. Used poorly, you get tasks that "should have stopped" but didn't, wasting CPU and battery.
Cancellation is cooperative. The runtime sets a flag on a task. The task's code is responsible for checking that flag and stopping. There is no preemptive "kill the task" — that would leave resources, locks, and state in unpredictable states.
The flag-setting happens in two ways:
task.cancel() on a top-level Task handleThe flag-checking happens in three ways:
Task.isCancelled returns true after cancellationtry Task.checkCancellation() throws CancellationError if cancelledA lot of standard-library async APIs check cancellation for you:
Task.sleep throws CancellationError immediately on cancellationURLSession.data(from:) and friends throw CancellationError (wrapped in URLError.cancelled historically)So in many cases you don't need to write any cancellation code yourself — the things you await propagate cancellation themselves.
For your own long loops, sprinkle in checks:
func processLargeFile(url: URL) async throws -> Result {
let lines = url.lines // AsyncSequence of String
var processed = 0
for try await line in lines {
try Task.checkCancellation()
processed += 1
// ... work on line
}
return .success(processed: processed)
}
for try await already checks cancellation on each iteration via the AsyncSequence machinery, so the explicit checkCancellation() is belt-and-suspenders. For pure CPU loops without await, you'll want explicit checks:
func crunch(_ data: [Int]) throws -> Int {
var sum = 0
for i in 0..<data.count {
if i.isMultiple(of: 1000) {
try Task.checkCancellation()
}
sum += data[i] * data[i]
}
return sum
}
Note: crunch here isn't even async — but Task.checkCancellation() works in any context that's currently part of a task. If called outside a task, it silently does nothing.
withTaskCancellationHandlerSometimes you need to perform cleanup or signal an external system when cancellation happens — not when you check, but immediately at the moment cancellation occurs. That's withTaskCancellationHandler:
func waitForUserInput() async throws -> String {
return try await withTaskCancellationHandler {
// body
try await readUserInput()
} onCancel: {
// runs immediately when the task is cancelled
cancelInputReading()
}
}
The onCancel block runs synchronously when cancellation is signaled — possibly from a different thread. It can't be async. It's typically used to call a cancel() method on a third-party API, or signal a callback-style operation to bail out.
This is the bridge mechanism for adapting non-cancellation-aware APIs into the structured world. We'll see it again in chapter 14.
A task that's been cancelled is asked to stop, but it can continue running. It's a hint, not a kill. Code that ignores Task.isCancelled will run to completion regardless.
This means: there's no guarantee that a cancelled task will stop quickly. If you cancel a task that's busy with CPU-heavy code that doesn't check, you'll wait for it to finish. The fix is to write code that checks regularly.
In structured concurrency, cancellation flows automatically:
let parent = Task {
async let a = doA()
async let b = doB()
return try await (a, b)
}
parent.cancel()
// `a` and `b` (child tasks) are also cancelled
The runtime sets the flag on the parent and walks the tree of children, setting the flag on each. The child tasks will throw CancellationError (from their own awaits and checks) and unwind.
In task groups, cancelling the parent cancels the group, which cancels all current children. New addTask calls still add children — they won't run anything that respects cancellation, but the task is created. Use addTaskUnlessCancelled to skip adding work when the group is cancelled.
Detached tasks aren't part of any tree, so cancelling some "parent" doesn't reach them. You have to hold the task handle and call cancel() yourself:
let work = Task.detached {
await heavyJob()
}
// later:
work.cancel()
This is part of why detached tasks are the explicit escape hatch — you're opting out of the propagation.
CancellationErrorA common mistake:
func work() async {
do {
try await something()
} catch {
// ignore - probably fine
}
}
If something() was cancelled, the try throws CancellationError, you swallow it, and the function continues. You've now decoupled cancellation from the work. The next await might check again and bail, but the cancellation message has been muffled.
Better:
func work() async throws {
do {
try await something()
} catch is CancellationError {
throw CancellationError() // re-throw to propagate
} catch {
// handle other errors
}
}
Or, cleaner: don't catch errors you don't know how to handle. Let cancellation propagate.
Swift doesn't ship a built-in timeout helper, but you can build one with task groups (we saw this in the Swift language tutorial; here's a slightly more polished version):
struct TimeoutError: Error {}
func withTimeout<T: Sendable>(
_ duration: Duration,
operation: @Sendable @escaping () async throws -> T
) async throws -> T {
try await withThrowingTaskGroup(of: T.self) { group in
group.addTask {
try await operation()
}
group.addTask {
try await Task.sleep(for: duration)
throw TimeoutError()
}
// first to finish wins
let result = try await group.next()!
group.cancelAll()
return result
}
}
// usage:
do {
let data = try await withTimeout(.seconds(5)) {
try await fetchSomething()
}
} catch is TimeoutError {
print("too slow")
}
The trick: race the operation against a sleep. Whichever finishes first wins. cancelAll cancels the loser.
A common pattern in view models:
@MainActor
final class SearchViewModel: ObservableObject {
@Published var results: [Item] = []
private var searchTask: Task<Void, Never>?
func search(query: String) {
searchTask?.cancel()
searchTask = Task {
// tiny debounce
try? await Task.sleep(for: .milliseconds(300))
if Task.isCancelled { return }
do {
let items = try await api.search(query: query)
if Task.isCancelled { return }
self.results = items
} catch is CancellationError {
// expected; do nothing
} catch {
self.results = []
}
}
}
}
Each new query cancels the previous task. The 300ms sleep gives a debounce — if you cancel before that elapses, you avoid the network call entirely. The if Task.isCancelled { return } checks after the sleep and after the network call ensure we don't update UI with stale results.
This idiom is everywhere in real iOS code.
Cancellation is cooperative. It propagates through structured concurrency for free. It doesn't propagate through detached tasks. Code that wants to be a good citizen checks cancellation at sensible points — at the start of long loops, before expensive work, between awaited steps. Code that ignores cancellation is the source of "why is my app still running stuff after I navigated away" bugs.
A task can have a priority, hinting to the scheduler how urgent it is. Higher-priority tasks run before lower-priority ones when threads are scarce.
public enum TaskPriority {
case userInitiated // user is waiting; run ASAP
case high // alias for userInitiated
case medium // normal work
case `default` // alias for medium
case low // background work
case utility // alias for low
case background // truly background
}
There are five distinct priority levels, with some aliases. From the runtime's perspective, the scheduler tries to run higher-priority tasks first.
When creating a task:
Task(priority: .userInitiated) {
await fetchUser()
}
Task.detached(priority: .background) {
await archiveOldLogs()
}
Priorities you don't specify inherit from the current task (for Task { }) or default to .medium (for Task.detached).
For addTask in a task group:
group.addTask(priority: .background) { ... }
Without a priority parameter, the child inherits the group's priority.
Crucially, when a task awaits another task — say, an actor call — Swift can raise the priority of the actor's work to match the awaiting task. This is priority escalation and prevents priority inversion (where a low-priority task holding an actor blocks a high-priority task waiting for it).
You usually don't need to think about this. The runtime does it. But it's why setting priorities is a useful hint and not just decoration.
let pri = Task.currentPriority
This returns the priority of the currently running task.
For most apps, default priorities are fine. Where you'd reach for explicit priorities:
.background.utility.userInitiatedFor UI-driven apps, the scheduler is good enough that getting priorities subtly wrong rarely matters in practice. For server-side Swift or compute-heavy code, getting priorities right can substantially affect throughput and latency.
A common mistake: assuming priorities are guarantees. They're not. A .userInitiated task is preferred over a .background task, but if all threads are busy, your .userInitiated task waits. If you need real-time guarantees, you don't have them in user-space code; you need a different architecture (audio I/O on its own thread, etc.).
Treat priorities as hints to the scheduler about your intentions. Express the structure correctly and let the runtime do its job.
Task-local values are like thread-local storage, but for tasks. They flow through structured concurrency: a child task inherits its parent's task-local values.
You declare task-locals with the @TaskLocal property wrapper, on a static property of an enum or other type:
enum RequestContext {
@TaskLocal static var requestID: String = "unknown"
@TaskLocal static var user: User?
}
To read:
print(RequestContext.requestID)
To set, you can't write RequestContext.requestID = "abc". Instead, you bind it for a scope:
await RequestContext.$requestID.withValue("abc-123") {
await processRequest()
}
Inside the closure (and any tasks spawned from inside it), RequestContext.requestID is "abc-123". Outside, it's back to the default.
Common use cases:
Distributed tracing. Attach a request ID to every async operation a request kicks off, so logs across all the work for one request can be correlated.
extension Logger {
func logWithContext(_ message: String) {
log("[req=\(RequestContext.requestID)] \(message)")
}
}
// Server entry point:
func handleRequest(req: Request) async {
await RequestContext.$requestID.withValue(req.id) {
await actuallyHandle(req)
}
}
Now anywhere downstream, no matter how deep, RequestContext.requestID is available.
Authentication context. The current user, their permissions, propagated implicitly through every async call chain.
Test environments. Setting a "fake clock" or "in-memory database" task-local for the scope of a test, so production code doesn't need a parameter for it.
Task-locals flow through async let, task groups, and child tasks created via Task { } from inside an enclosing task. They do not flow through Task.detached { }.
await RequestContext.$requestID.withValue("req-123") {
Task {
print(RequestContext.requestID) // "req-123"
}
Task.detached {
print(RequestContext.requestID) // "unknown" (default)
}
}
This is by design. Task.detached explicitly opts out of context inheritance, including task-locals.
Task-locals are cheap to read but slightly more expensive to bind than a regular variable. Don't use them as a substitute for normal parameter passing — only for things that genuinely need to flow through deep async stacks.
You can't have non-Sendable types in task-locals if you want concurrency safety. The compiler enforces this. For mutable shared context, use an actor; for read-only flowing context, task-locals are perfect.
enum Logger {
@TaskLocal static var prefix: String = ""
static func log(_ msg: String) {
print("\(prefix)\(msg)")
}
}
func processOrders() async throws {
await Logger.$prefix.withValue("[order-pipeline] ") {
try await loadAndProcess()
}
}
func loadAndProcess() async throws {
Logger.log("starting load")
// outputs: "[order-pipeline] starting load"
// ... rest of pipeline, all logs prefixed
}
The pipeline functions don't take a logger or prefix parameter. The task-local handles it.
Task-locals give you implicit context that flows through your async stack the way thread-locals flow through threads — but cleanly, scoped, and Sendable-checked. Use them for cross-cutting concerns (tracing, auth, logging context). Don't use them as a back door for plain mutable state.
Reality check: half the world's APIs use callbacks. Apple's frameworks have hundreds of delegate-based and completion-handler-based methods. The sooner you can wrap them in async/await, the sooner you can write straight-line code.
The bridge mechanism is the continuation. You use withCheckedContinuation (or its checked-throwing or unchecked variants) to capture the moment of suspension, hold a token, and resume the suspension when the callback fires.
func legacyFetch(completion: @escaping (Data?, Error?) -> Void) {
// ... old callback API
}
func modernFetch() async throws -> Data {
return try await withCheckedThrowingContinuation { continuation in
legacyFetch { data, error in
if let error = error {
continuation.resume(throwing: error)
} else if let data = data {
continuation.resume(returning: data)
} else {
continuation.resume(throwing: MyError.unknown)
}
}
}
}
Read this carefully:
withCheckedThrowingContinuation. It suspends the current task and gives us a continuation object.legacyFetch, passing it a callback.continuation.resume(...) to deliver the result.await resumes with that result.The continuation is a one-shot baton. You must call resume exactly once on it, in exactly one path. Calling it zero times deadlocks the task. Calling it twice traps.
continuation.resume(returning: someValue) // success
continuation.resume(throwing: someError) // failure
continuation.resume(with: result) // takes Result<T, Error>
continuation.resume() // for Void continuations
For non-throwing continuations (withCheckedContinuation), only resume(returning:) is available.
withCheckedContinuation and withCheckedThrowingContinuation use a checked continuation. The runtime tracks whether resume has been called. If it hasn't been called by the time the continuation is deallocated, the runtime traps with a clear error: "Continuation never resumed!" — saving you from a hung task.
withUnsafeContinuation and withUnsafeThrowingContinuation skip the check. Slightly faster, but if you forget to resume, you get a hang with no error. Use checked variants while developing; switch to unchecked only if profiling shows the check matters (extremely rare).
By default, a continuation knows nothing about cancellation. If the task is cancelled while awaiting the continuation, the continuation will still wait for the callback. To bridge cancellation, combine with withTaskCancellationHandler:
func cancellableLegacyFetch() async throws -> Data {
let request = LegacyAPI.Request()
return try await withTaskCancellationHandler {
try await withCheckedThrowingContinuation { cont in
request.start { data, error in
if let error = error {
cont.resume(throwing: error)
} else {
cont.resume(returning: data!)
}
}
}
} onCancel: {
request.cancel() // propagate cancellation to legacy API
}
}
The onCancel block is called synchronously when cancellation hits. We signal the legacy API to cancel itself. The callback will then fire (with an error or empty data), and the continuation gets resumed normally. Clean exit.
Delegates are harder than completion handlers because the callback might fire multiple times (one delegate, many calls). For one-shot operations, you can bridge:
class LocationFetcher: NSObject, CLLocationManagerDelegate {
private let manager = CLLocationManager()
private var continuation: CheckedContinuation<CLLocation, Error>?
func currentLocation() async throws -> CLLocation {
return try await withCheckedThrowingContinuation { cont in
self.continuation = cont
manager.delegate = self
manager.requestLocation()
}
}
func locationManager(_ manager: CLLocationManager,
didUpdateLocations locations: [CLLocation]) {
guard let location = locations.first else { return }
continuation?.resume(returning: location)
continuation = nil
}
func locationManager(_ manager: CLLocationManager,
didFailWithError error: Error) {
continuation?.resume(throwing: error)
continuation = nil
}
}
Notice we nil out the continuation after resuming. If the delegate fires again (which it may, since location updates are streams), the second call sees nil and skips — saving us from the double-resume trap.
For streaming delegate APIs, you don't want a single continuation; you want AsyncStream (chapter 16).
NotificationCenter has an async API now (.notifications(named:) returns an AsyncSequence), but if you need to bridge custom notification flows:
func waitForFirstNotification(_ name: Notification.Name) async -> Notification {
await withCheckedContinuation { cont in
var observer: NSObjectProtocol?
observer = NotificationCenter.default.addObserver(
forName: name, object: nil, queue: nil
) { note in
if let observer = observer {
NotificationCenter.default.removeObserver(observer)
}
cont.resume(returning: note)
}
}
}
The captured observer lets us self-remove on the first fire.
Same idea — capture the observation, resume the continuation, invalidate the observation on first fire:
func waitFor<T: NSObject, V>(
_ object: T,
keyPath: KeyPath<T, V>,
matching predicate: @escaping (V) -> Bool
) async -> V {
await withCheckedContinuation { cont in
var observation: NSKeyValueObservation?
observation = object.observe(keyPath, options: [.new]) { _, change in
if let new = change.newValue, predicate(new) {
observation?.invalidate()
cont.resume(returning: new)
}
}
}
}
Calling resume twice. This is the big one. The compiler can't catch it. Rigorous bookkeeping with optional continuations (set to nil after first resume) is your defense.
Forgetting to call resume on some path. Equally bad — the calling task hangs. Walk through every code path in the closure and verify resume is called exactly once.
Capturing self strongly when self might be released. Always think about lifetimes inside continuation closures. The continuation may keep self alive longer than expected.
Using a continuation for streaming. Continuations are one-shot. Streams are streams. We'll cover those in chapter 16.
Continuations are the universal adapter from callback-based to async-based APIs. Use checked variants. Resume exactly once. Combine with withTaskCancellationHandler for cancellation. For streams, use AsyncStream (next chapter).
async/await deals with single values produced asynchronously. But many real sources produce sequences of values over time — file lines as they're read, web socket messages, sensor updates, user keystrokes. AsyncSequence is the abstraction for these.
AsyncSequence isAsyncSequence is to async iteration what Sequence is to sync iteration. The protocol:
protocol AsyncSequence {
associatedtype Element
associatedtype AsyncIterator: AsyncIteratorProtocol
where AsyncIterator.Element == Element
func makeAsyncIterator() -> AsyncIterator
}
protocol AsyncIteratorProtocol {
associatedtype Element
mutating func next() async throws -> Element?
}
The key difference: next() is async. Each iteration may suspend and wait for the next value. When the sequence is exhausted, next() returns nil.
You iterate with for await:
for await line in someAsyncSequence {
print(line)
}
If the iterator throws (some AsyncSequences are throwing), use for try await:
for try await line in url.lines {
process(line)
}
The loop suspends on each iteration while the sequence produces the next value, then resumes with the value.
A handful of built-in async sequences in Foundation and friends:
// File lines
for try await line in url.lines {
print(line)
}
// File bytes
for try await byte in url.resourceBytes {
process(byte)
}
// URL bytes
let (bytes, _) = try await URLSession.shared.bytes(from: url)
for try await byte in bytes {
process(byte)
}
// Notifications (newer iOS APIs)
for await notification in NotificationCenter.default.notifications(named: .myEvent) {
handle(notification)
}
These are all native AsyncSequence types, not bridges over callback APIs.
AsyncSequence has many of the same operators as Sequence:
// map, filter, reduce
let upperLines = url.lines.map { $0.uppercased() }
let errorLines = url.lines.filter { $0.contains("ERROR") }
let total = try await url.lines.reduce(0) { acc, line in acc + line.count }
// prefix, dropFirst
let firstFive = url.lines.prefix(5)
let skipFirstThree = url.lines.dropFirst(3)
// contains, allSatisfy, first(where:)
let hasError = try await url.lines.contains { $0.hasPrefix("ERROR") }
let firstError = try await url.lines.first { $0.hasPrefix("ERROR") }
// collect into array
let allLines = try await Array(url.lines)
These all return new AsyncSequences (or terminating values for the eager operators).
Note that operators like map are lazy — they produce values as iteration consumes them, just like in a regular Sequence. They don't pre-compute.
AsyncSequence iteration is cancellation-aware: if the consuming task is cancelled, the next next() call will typically throw CancellationError, ending the loop. This is true of all the standard sequences. For your own custom sequences, you should respect cancellation explicitly.
You can implement the protocol directly, but it's verbose. Here's a counter that yields integers with a delay:
struct DelayedCounter: AsyncSequence {
typealias Element = Int
let limit: Int
struct AsyncIterator: AsyncIteratorProtocol {
var current = 0
let limit: Int
mutating func next() async -> Int? {
guard current < limit else { return nil }
try? await Task.sleep(for: .milliseconds(100))
defer { current += 1 }
return current
}
}
func makeAsyncIterator() -> AsyncIterator {
AsyncIterator(limit: limit)
}
}
for await n in DelayedCounter(limit: 5) {
print(n)
}
In practice, you almost never write this. You use AsyncStream (next chapter) instead, which gives you the same capability with less ceremony.
Whenever you have more than one value arriving over time:
For single async values, use async/await directly. For sequences, use AsyncSequence.
AsyncStream is the practical builder for AsyncSequences. You give it a closure that produces values via a continuation, and it gives you back an AsyncSequence you can iterate.
let stream = AsyncStream<Int> { continuation in
continuation.yield(1)
continuation.yield(2)
continuation.yield(3)
continuation.finish()
}
for await value in stream {
print(value) // 1, 2, 3
}
The closure runs at construction time? Not exactly — the closure is captured and runs when the stream is iterated. But the values yielded are buffered, so you can yield before iteration starts.
This is the killer use case. Wrapping a delegate-based, push-style API:
extension MotionManager {
var accelerationUpdates: AsyncStream<CMAcceleration> {
AsyncStream { continuation in
self.startAccelerometerUpdates(to: .main) { data, _ in
guard let data = data else { return }
continuation.yield(data.acceleration)
}
continuation.onTermination = { _ in
self.stopAccelerometerUpdates()
}
}
}
}
// usage:
for await accel in motionManager.accelerationUpdates.prefix(100) {
process(accel)
}
The flow:
onTermination to clean up — stop the motion manager when iteration ends (consumer breaks, gets cancelled, etc.).onTermination is the cleanup hook. Without it, you'd leak the motion manager subscription forever.
continuation.yield(value) // produce a value
continuation.yield(with: result) // for AsyncThrowingStream: yield value or finish-with-error
continuation.finish() // end the stream
continuation.finish(throwing: error) // for AsyncThrowingStream
continuation.onTermination = { reason in ... }
The yield calls don't suspend. They enqueue values into the stream's buffer. The consumer pulls them off.
The onTermination callback runs when:
You can check the reason parameter (.cancelled or .finished) to differentiate.
By default, AsyncStream buffers values without limit. If your producer is faster than your consumer, the buffer grows. You can change this:
AsyncStream<Int>(bufferingPolicy: .bufferingNewest(10)) { continuation in
// keeps only the most recent 10 yielded values
}
AsyncStream<Int>(bufferingPolicy: .bufferingOldest(10)) { continuation in
// keeps the first 10; drops new ones once full
}
AsyncStream<Int>(bufferingPolicy: .unbounded) { continuation in
// default; never drops
}
For event streams where you only care about the latest, .bufferingNewest(1) is great — the consumer sees the most recent value when ready.
AsyncStream.makeStream()A convenience for separating producer and consumer:
let (stream, continuation) = AsyncStream.makeStream(of: Int.self)
// Producer:
Task {
for i in 0..<10 {
try? await Task.sleep(for: .milliseconds(100))
continuation.yield(i)
}
continuation.finish()
}
// Consumer:
for await n in stream {
print(n)
}
This is convenient when the producer and consumer are in different scopes — you can stash the continuation in a property and call yield from anywhere.
AsyncThrowingStreamSame as AsyncStream, but the consumer's iteration can throw:
let stream = AsyncThrowingStream<Data, Error> { continuation in
legacyAPI.start { data, error in
if let error = error {
continuation.finish(throwing: error)
} else if let data = data {
continuation.yield(data)
}
}
continuation.onTermination = { _ in legacyAPI.stop() }
}
do {
for try await chunk in stream {
process(chunk)
}
} catch {
print("stream error: \(error)")
}
finish(throwing:) ends the stream with an error. The consumer's try await rethrows it.
Single subscriber. AsyncStream is generally a single-subscriber primitive. If you iterate the same stream from two places, the values are split between them — you don't get duplicates. For multicast, you need an actor or a pub-sub mechanism.
Backpressure. AsyncStream's buffering policy is the only built-in backpressure mechanism. If you need stricter producer/consumer coordination, look at swift-async-algorithms or build a small actor-based mediator.
Combining streams. Not built in. Use swift-async-algorithms (next chapter) for merge, zip, combineLatest, etc.
extension UIControl {
func eventStream(for event: UIControl.Event) -> AsyncStream<Void> {
AsyncStream { continuation in
let target = TargetForwarder { continuation.yield(()) }
self.addTarget(target, action: #selector(TargetForwarder.fire),
for: event)
continuation.onTermination = { [weak self] _ in
self?.removeTarget(target, action: #selector(TargetForwarder.fire),
for: event)
}
// keep target alive
objc_setAssociatedObject(self, &targetKey, target, .OBJC_ASSOCIATION_RETAIN)
}
}
}
private final class TargetForwarder: NSObject {
let block: () -> Void
init(_ block: @escaping () -> Void) { self.block = block }
@objc func fire() { block() }
}
// usage:
Task {
for await _ in button.eventStream(for: .touchUpInside).prefix(5) {
await handleTap()
}
}
This wraps a UIKit target/action into an AsyncStream. The lifecycle is right: when the consumer stops iterating, we remove the target.
AsyncStream is the bridge between push-based sources (delegates, observers, callbacks, notifications) and pull-based async iteration. The contract: yield values, finish when done, cleanup in onTermination. It's the most-used tool for adapting legacy APIs into the modern world after withCheckedContinuation.
The standard library's AsyncSequence operators cover the basics. For real composition — merging, zipping, throttling, debouncing, chunking — you reach for the swift-async-algorithms package.
In your Package.swift:
.package(url: "https://github.com/apple/swift-async-algorithms.git", from: "1.0.0")
And as a dependency on your target.
A non-exhaustive tour:
merge — interleave values from multiple sources:
import AsyncAlgorithms
let merged = merge(streamA, streamB, streamC)
for await value in merged {
print(value)
}
The result yields values from any source as they arrive.
zip — pair up values, one from each source:
let zipped = zip(numbers, letters)
for await pair in zipped {
print(pair) // (1, "a"), (2, "b"), ...
}
Stops when the shorter sequence ends.
combineLatest — produce a tuple of the latest values from each source:
let combined = combineLatest(textField, dropdown)
for await (text, choice) in combined {
updateUI(text: text, choice: choice)
}
Useful for UI: any change in any source produces a new combined value.
debounce — only emit a value if no newer one arrives within a duration:
let debounced = textField.values.debounce(for: .milliseconds(300))
for await text in debounced {
await search(query: text)
}
Classic search-as-you-type pattern.
throttle — emit at most one value per interval:
let throttled = mouseMoves.throttle(for: .milliseconds(16))
chunked — group values into arrays:
let chunks = events.chunks(ofCount: 10)
for await batch in chunks {
await ingest(batch) // process 10 at a time
}
Or chunk by time:
let timedChunks = events.chunked(byTime: .milliseconds(500))
removeDuplicates — like Combine's, drop consecutive duplicates:
let deduped = values.removeDuplicates()
AsyncChannel — a multi-producer / multi-consumer point-to-point channel with backpressure:
let channel = AsyncChannel<Int>()
Task {
for i in 0..<10 {
await channel.send(i)
}
channel.finish()
}
for await value in channel {
print(value)
}
channel.send suspends the producer until a consumer is ready — actual backpressure.
The standard library is conservative about what to include. Async algorithms is where the broader, opinionated tools live. If your code is doing nontrivial async stream composition, you almost certainly want the package.
@MainActor
final class SearchBox: ObservableObject {
@Published var query = ""
@Published var results: [Result] = []
func observe() async {
let queries = self.$query.values
let debounced = queries.debounce(for: .milliseconds(300))
let nonEmpty = debounced.filter { !$0.isEmpty }
for await q in nonEmpty {
do {
self.results = try await api.search(q)
} catch {
self.results = []
}
}
}
}
The pipeline: each character typed updates $query. After 300ms of no new keystrokes, debounce emits. We filter empty strings. We hit the search API. We update results.
This is concise enough that it almost reads like a description of the behavior.
For straight-line async code, you don't need this package. For pipelines — multiple sources, time-based filtering, backpressure — it's invaluable. Read the package's README for the full operator list. Many algorithms you'd reach for in Combine have direct equivalents here.
We've talked about tasks, structured concurrency, and async sequences. Now we need to address mutable shared state — the deepest source of concurrency bugs.
You have a class that holds mutable state. Multiple tasks touch it.
final class Counter {
var value = 0
func increment() {
value += 1
}
}
let counter = Counter()
Task { for _ in 0..<10_000 { counter.increment() } }
Task { for _ in 0..<10_000 { counter.increment() } }
// later: print(counter.value)
What's value after both tasks finish? In sequential code, 20,000. In concurrent code with a class? Anything from somewhere around 11,000 to 19,999. The value += 1 operation is read-modify-write. Two tasks can read the same value, both increment locally, and both write back — losing one increment.
In Swift 6 with strict concurrency, this code doesn't compile (passing a non-Sendable class across task boundaries is an error). Pre-Swift-6, it compiles and silently produces wrong answers.
The pre-modern fix was a serial dispatch queue or a lock. The modern fix is an actor.
actor Counter {
var value = 0
func increment() {
value += 1
}
}
Drop-in syntax change: class → actor. The semantics are different:
await.let counter = Counter()
Task { for _ in 0..<10_000 { await counter.increment() } }
Task { for _ in 0..<10_000 { await counter.increment() } }
Now we get exactly 20,000. The actor's runtime ensures that the read-modify-write of value += 1 happens atomically with respect to other actor methods.
Code can be in one of three isolation states relative to an actor:
nonisolated), property accessors, etc. They can access stored properties directly.await a call to an actor method. The compiler enforces this.nonisolated. Marked code on the actor that doesn't need isolation — typically because it only touches let properties or the actor's spawn-time-fixed fields.The isolation rule means: from outside, you can't read counter.value synchronously. You'd have to call await counter.value (treating the property as an async getter), or call a method that returns it.
Even a method that doesn't await anything is async-from-outside, because reaching the actor takes a hop:
actor BankAccount {
private var balance: Double = 0
func deposit(_ amount: Double) {
balance += amount // synchronous *inside* the actor
}
}
let account = BankAccount()
await account.deposit(100) // async from outside
deposit doesn't have async in its declaration. But because it's an actor method, callers outside the actor must await it. The "async-ness" comes from crossing the actor boundary, not from the function's body.
A call from one actor method to another stays on the actor — no await:
actor BankAccount {
private var balance: Double = 0
func deposit(_ amount: Double) {
balance += amount
logTransaction(amount) // direct call, no await
}
private func logTransaction(_ amount: Double) {
print("deposited \(amount); balance now \(balance)")
}
}
Both methods are isolated to the same actor. Calling between them doesn't cross any boundary; it's just a function call.
If you make a property non-private, you can read it from outside, but only via await:
actor BankAccount {
var balance: Double = 0
// ...
}
let account = BankAccount()
let b = await account.balance // async property read
In practice, encapsulation usually wins: keep stored state private, expose methods that compute and return safe values.
Actor initializers run synchronously and not on the actor (because the actor doesn't exist until init returns). Inside init, you can directly assign to stored properties without await:
actor Cache {
private var storage: [String: Data]
init(seed: [String: Data] = [:]) {
self.storage = seed
}
}
Initializers can be async if you need to do async work during construction:
actor LoadedCache {
private var storage: [String: Data]
init() async throws {
let urls: [URL] = [...]
var loaded: [String: Data] = [:]
for url in urls {
loaded[url.absoluteString] = try await fetch(url)
}
self.storage = loaded
}
}
let cache = try await LoadedCache()
Every actor is implicitly Sendable. You can pass an actor reference between tasks freely — the actor itself protects its state, so sharing the reference is safe. (We'll cover Sendable in detail in chapter 23.)
actor types behave like classes — they're reference types, allocated on the heap, with reference identity. Two variables pointing to the same actor refer to the same instance.
But unlike classes, you can't subclass an actor. There's no class Foo: Bar analog for actors. Inheritance and isolation interact in ways that get complicated; Swift's design is to keep actors flat.
You can have actors conform to protocols:
protocol Storage {
func get(_ key: String) async -> Data?
func set(_ key: String, _ value: Data) async
}
actor InMemoryStorage: Storage {
private var dict: [String: Data] = [:]
func get(_ key: String) async -> Data? { dict[key] }
func set(_ key: String, _ value: Data) async { dict[key] = value }
}
The protocol's methods are async (since they'll be called across actor boundaries). The actor's implementations use the same async signature, even though internally they don't actually await anything. This is fine.
In one paragraph: actors give you mutable shared state without data races, without manual locking, without the right-pattern-or-it-crashes brittleness of DispatchQueue discipline, and with compiler enforcement that you can't accidentally bypass the protection. That's a lot of value for one keyword.
What they don't solve: high-frequency contention. If a thousand tasks are hammering one actor's methods, they queue up. The actor processes one at a time. For most application code, this is fine. For tight inner loops on shared state, you might need finer-grained patterns.
Actor isolation is a compile-time concept. The compiler tracks where every piece of code is allowed to run, and rejects code that would cross a boundary unsafely.
For any line of code, the compiler knows its isolation:
nonisolated), and code reached from inside such methods.@MainActor. We'll cover global actors in chapter 22.The compiler enforces transitions:
await.await and value safety (Sendable).Stored properties of an actor are isolated to it. Computed properties on an actor are isolated unless marked nonisolated:
actor User {
let id: UUID // immutable; can be read directly from outside
private var name: String // isolated; outside code needs await
var description: String {
"User \(id) named \(name)" // isolated (touches name)
}
nonisolated var idString: String {
id.uuidString // nonisolated (only touches let id)
}
}
let u = User(...)
print(u.id) // OK: let, can be read sync
print(u.idString) // OK: nonisolated
print(await u.description) // requires await
Note: let properties of Sendable type can be read from outside without await. They're effectively nonisolated since they're immutable.
A method without explicit annotation on an actor is actor-isolated:
actor Counter {
var value = 0
func get() -> Int { value } // actor-isolated
nonisolated func describe() -> String {
"a counter" // nonisolated; can't read value here
}
}
Calling describe() from outside doesn't need await:
let c = Counter()
print(c.describe()) // sync; OK
print(await c.get()) // async; await required
If describe() tried to read value, the compiler would refuse: "actor-isolated property 'value' can not be referenced from a non-isolated context."
A method on an actor is implicitly async-from-outside, regardless of whether its body is async:
actor Foo {
func sync() -> Int { 42 } // body is sync, but...
func async() async -> Int { 43 }
}
let f = Foo()
let a = await f.sync() // outside: still need await
let b = await f.async()
Inside the actor, sync() is sync — you can call it without await. Outside, both require await because both cross the actor boundary.
Closures inherit isolation from where they're created — usually:
@MainActor
func setup() {
let doWork = {
// this closure is @MainActor-isolated, because it was created in a @MainActor context
}
}
But you can override:
@MainActor
func setup() {
let doWork = { @MainActor in
// explicitly @MainActor
}
let detached = { @Sendable in
// Sendable closure; not actor-isolated
}
}
If you have a reference to one actor and call a method on another:
actor A {
let other: B
init(other: B) { self.other = other }
func doSomething() async {
await other.help()
}
}
actor B {
func help() {
// ...
}
}
A.doSomething is isolated to A. The call to other.help() crosses from A's isolation to B's, which requires await. The compiler enforces it.
When you pass a closure from one isolation context to another, the compiler checks Sendable:
actor Worker {
func process(_ item: Item, callback: @Sendable (Result) -> Void) {
let result = compute(item)
callback(result)
}
}
The @Sendable constraint says: this closure can be called from a different concurrency context safely. Without that, the compiler would reject closures that capture non-Sendable values.
Sometimes you need to assert in code: "I'm definitely on this actor right now":
actor MyActor {
func mustBeMine() {
Self.preconditionIsolated() // traps if not actor-isolated
}
}
Or assert that you're not:
nonisolated func mustNotBeOnMain() {
MainActor.assertNotIsolated()
}
These are runtime checks, useful in code that bridges between concurrency systems.
Actor isolation is a static property of every line of your code, tracked by the compiler. Crossing actor boundaries requires await and Sendable values. Inside the actor, you have free access to its state; outside, every access goes through await. Get this right and the compiler will catch race conditions before they ship.
This is the one chapter you should re-read after a few weeks of writing actor code. The misunderstanding here causes bugs that are infuriating to debug.
When an actor's method awaits something, the actor is not blocked. It doesn't sit idle. The runtime says "this method is suspended; let me see what other work I have for this actor" and runs other queued calls.
This is good for performance — actors don't waste their executor on idle awaits. But it has a sharp consequence: state can change across an await.
actor ImageCache {
private var cache: [URL: Data] = [:]
func image(for url: URL) async throws -> Data {
if let cached = cache[url] {
return cached
}
let data = try await download(url) // suspension point!
cache[url] = data
return data
}
private func download(_ url: URL) async throws -> Data {
let (data, _) = try await URLSession.shared.data(from: url)
return data
}
}
What's wrong? Two tasks call image(for: url) simultaneously with the same URL.
Task 1: enters image(for:), sees nothing in cache, hits try await download(url), suspends.
Task 2: enters image(for:) while Task 1 is suspended, sees nothing in cache (Task 1 hasn't written it yet), hits try await download(url), suspends.
Both downloads happen. Both eventually complete. Both write to the cache. We did the network request twice.
The bug isn't a data race — the actor protects cache from concurrent mutation. The bug is that across the await, another caller saw the actor's pre-modification state.
actor ImageCache {
private var cache: [URL: Data] = [:]
private var inFlight: [URL: Task<Data, Error>] = [:]
func image(for url: URL) async throws -> Data {
if let cached = cache[url] { return cached }
if let pending = inFlight[url] {
return try await pending.value
}
let task = Task { try await self.download(url) }
inFlight[url] = task
defer { inFlight[url] = nil }
let data = try await task.value
cache[url] = data
return data
}
private func download(_ url: URL) async throws -> Data {
let (data, _) = try await URLSession.shared.data(from: url)
return data
}
}
Now: Task 1 enters, doesn't find a cache hit, doesn't find an in-flight, creates a Task to download, stores it in inFlight, and awaits it. Task 2 enters while Task 1 is suspended, sees the in-flight task, awaits its value. Both end up with the same data, only one download happens.
The general principle: anything you check or compute before an await may be stale after it. If your invariants depend on the pre-await state, you need to either re-check or hold something across the suspension that captures the obligation.
A general pattern:
actor SomeResource {
private var state: State = .idle
func operate() async {
guard state == .idle else { return } // check 1
state = .working
await someAsyncStep() // suspension!
// here, state could have changed via reentrancy
guard state == .working else { return } // check 2
state = .idle
}
}
Without check 2, another method could have taken over state while you were suspended.
Reentrancy is not always a bug. In many cases, it's exactly what you want. An actor that takes a long time to handle one request shouldn't block all other requests — the next request can start while the first is awaiting its I/O. That's the design.
The bug is when your specific logic assumes "no one else has modified my actor's state across this await." If you have that assumption, you need the recheck.
It would solve the bug above! But it would also let one slow request block the actor for an arbitrary time — and now imagine a long chain of awaiting calls, all blocking each other. You'd get the equivalent of priority inversion bugs that are notoriously hard to fix.
The tradeoff Swift's actors make: reentrancy is on by default, you bear the responsibility for invariants that span suspension points. Future Swift may add non-reentrant actors as an option; for now, you handle it with patterns like in-flight tracking.
Don't try to "lock" an actor by holding state. Other tasks can still execute other methods on the same actor while you're awaiting. There's no mutual exclusion across awaits.
If you genuinely need a critical section that spans an await, you typically restructure: extract the critical part into a synchronous helper, do the awaiting outside, then re-enter the actor. Or put the awaited work in a different actor and have your "main" actor only check/update synchronously.
Patterns that should make you check for reentrancy issues:
awaits in the middle of a logical operationWhen you spot one, ask yourself: "if another task slipped in here while I was suspended, would that break my logic?" If yes, fix it.
Across an await, anything could have happened. The actor is yours during synchronous execution; it's not yours during awaits. Code defensively. Re-check invariants. Track in-flight work explicitly. When in doubt, structure to do as little awaiting as possible inside critical sections.
Actor isolation is strict by default. nonisolated is the escape hatch when you legitimately need code on an actor that doesn't require isolation.
Two main cases:
1. Conformance to synchronous protocols. If a protocol requires a synchronous method, an actor can't conform with an isolated method (which is async-from-outside). You mark the conforming method nonisolated:
actor User: CustomStringConvertible {
let id: UUID
private var name: String
nonisolated var description: String {
"User \(id)"
}
init(id: UUID, name: String) {
self.id = id; self.name = name
}
}
CustomStringConvertible.description is a sync getter. We can't make it isolated; it would need to be async. So we mark it nonisolated and only access immutable state inside (the let id).
2. Pure utility methods on the actor that don't touch isolated state. If a method doesn't need actor isolation — say it computes something from its arguments only — make it nonisolated so callers don't have to await.
actor MathProvider {
private var cache: [Int: Int] = [:]
nonisolated func square(_ x: Int) -> Int {
x * x
}
func memoizedSquare(_ x: Int) -> Int {
if let cached = cache[x] { return cached }
let result = x * x
cache[x] = result
return result
}
}
square doesn't touch cache. It's nonisolated. Callers can call it directly.
A nonisolated method (or property) on an actor:
var properties of the actor)let properties of Sendable type)nonisolated methods on the actor freelyawaitactor Account {
let id: UUID
private var balance: Double = 0
nonisolated func describe() async -> String {
let bal = await balance // need to await isolated state
return "Account \(id) balance=\(bal)"
}
}
If describe is nonisolated but reads balance, it must await like any external caller.
nonisolated(unsafe)A more dangerous variant that says "let this var be nonisolated even though it's mutable; I promise I've handled synchronization":
actor LegacyBridge {
nonisolated(unsafe) var legacyHandle: OpaquePointer?
}
Use this only when you have a genuine reason — typically wrapping a C library where the storage is truly thread-safe but Swift can't tell. It's @unchecked Sendable for individual properties.
nonisolated asyncYou can have nonisolated async methods that look like ordinary actor methods but aren't isolated:
actor Service {
nonisolated func describe() async -> String {
"a service" // could await stuff here, just can't touch isolated state
}
}
The use case: a method that performs some async work using its own resources or external services, without needing the actor's lock. Reduces contention on the actor.
A common pattern is having a public synchronous nonisolated façade that delegates to async isolated methods via Task:
actor DataStore {
private var dict: [String: String] = [:]
func get(_ key: String) -> String? { dict[key] }
func set(_ key: String, _ value: String) { dict[key] = value }
}
extension DataStore {
nonisolated func setAsync(_ key: String, _ value: String) {
Task { await self.set(key, value) }
}
}
Now you can call store.setAsync(...) from sync code — it kicks off a task to perform the actual set. (This is fire-and-forget; you don't see when it finishes.)
nonisolated is the surgical tool for marking specific methods on an actor as "doesn't need isolation." Use it for protocol conformance to sync protocols, for pure utility methods, and for situations where you want to avoid the actor hop. Avoid nonisolated(unsafe) unless you really know what you're doing.
So far our actors have been instances — you have an actor Counter and you create a Counter(). Global actors are different: they're singleton actor-like things that any code can be isolated to, anywhere in your codebase. The most important one is @MainActor.
@MainActor — the main threadUIKit and AppKit have a hard rule: UI code must run on the main thread. That's been true forever. Pre-async, you'd DispatchQueue.main.async { ... } to make sure. Now you express it in the type system:
@MainActor
final class HomeViewController: UIViewController {
@IBOutlet weak var titleLabel: UILabel!
func updateTitle(_ text: String) {
titleLabel.text = text // OK; we're on the main actor
}
}
The @MainActor annotation makes the entire class run on the main actor. Every method, every property — they're all isolated to the main actor. Calls from other isolation contexts must await.
You don't have to mark the whole class — you can be granular:
final class HomeViewController: UIViewController {
@MainActor
func updateTitle(_ text: String) {
titleLabel.text = text
}
func loadData() async {
// not main-actor isolated by default
let data = try? await fetch()
await updateTitle(data?.title ?? "")
}
}
Marking individual methods is more tedious but more flexible — you avoid forcing all methods onto the main actor when only some need it.
You can mark individual stored properties:
final class Foo {
@MainActor var displayedTitle: String = ""
nonisolated func doWork() async {
await MainActor.run {
displayedTitle = "ready"
}
}
}
The property requires main-actor access. Read or write from another context, and you must await.
MainActor.runHop onto the main actor for a closure:
func process() async {
let data = await heavyComputation()
await MainActor.run {
// we're isolated to main actor here
self.label.text = data.summary
}
}
MainActor.run is convenient when most of your function is non-isolated but you need a brief main-actor block. It's the modern equivalent of DispatchQueue.main.async { ... }.
MainActor.run returns whatever the closure returns (and rethrows what it throws), so you can use it inline:
let title = await MainActor.run { self.titleLabel.text ?? "" }
MainActor.assumeIsolatedSometimes you know you're on the main thread (because the framework guarantees it for some callback type), but the compiler doesn't:
func someSyncCallback() {
MainActor.assumeIsolated {
// tells the compiler "we're on main actor; trust me"
updateUI()
}
}
This is a runtime check. If you're wrong (you're not actually on the main thread), it traps. Use sparingly — when bridging from APIs that promise main-thread delivery but lack @MainActor annotation.
@MainActor is the most important global actor, but you can define your own. A global actor is declared with @globalActor:
@globalActor
actor DatabaseActor {
static let shared = DatabaseActor()
}
You provide the shared instance — there's only one. Now you can mark code with @DatabaseActor:
@DatabaseActor
func saveUser(_ user: User) throws {
// runs on the DatabaseActor's executor; serialized with all other @DatabaseActor work
}
@DatabaseActor
final class Repository {
var connection: Connection
// ...
}
This is useful when you have a class of operations that should be serialized — all database writes, all logging, all analytics — without making them members of an actor instance.
Concrete cases:
For an iOS app, you'll mostly use @MainActor and the occasional database/logging custom actor. For server-side Swift, custom global actors come up more.
If you put @MainActor on a class, its members are all main-actor isolated, and so are subclass members. If a subclass wants to override with different isolation, that's restrictive. Generally you make the whole hierarchy MainActor-isolated or none of it.
SwiftUI's View body, @State, @Binding, @StateObject, @ObservedObject, and @Environment are all main-actor-isolated by default. The View protocol's body getter is implicitly @MainActor. So your view code is on the main actor without you doing anything.
struct ContentView: View {
@State private var count = 0
var body: some View {
// this whole closure is @MainActor
Button("Tap (\(count))") {
count += 1 // synchronous, OK on main actor
}
}
}
When you do async work from a SwiftUI view, you typically use the .task { } modifier or a Task { } block, and the task inherits main-actor isolation by default.
@MainActor is the headline global actor — it makes UI code provably correct at compile time. Custom global actors give you serialization on a per-domain basis (DB, logging, etc.). Use MainActor.run for brief hops; use @MainActor annotations for code that should always live there.
Actors solve mutable shared state. But there's a related question: when you pass a value between concurrency contexts (between tasks, into a closure for another task, across an actor boundary), is that value safe to send?
Swift's answer: the Sendable protocol. If a type is Sendable, it's safe to share between concurrency contexts. If it isn't, the compiler refuses to let you cross.
Sendable meansSendable is a marker protocol — it has no methods or properties. Conformance is a promise about thread safety.
public protocol Sendable {}
A type is Sendable when sharing it between concurrent tasks can't cause a data race. Concretely:
struct, enum) with all-Sendable stored properties get conformance for free.actor types are always Sendable (they enforce isolation themselves).final classes whose only stored properties are let of Sendable types can conform.Sendable without @unchecked — they need manual locking.Most standard library types are Sendable:
Int, Double, ...)String, CharacterBoolURL, Data, Date (Foundation)UUIDSendableOptional<T> — if T is SendableSendable@SendableSo most everyday code passes Sendable values without thinking about it.
For your own structs and enums, the compiler synthesizes Sendable automatically:
struct Point { // implicitly Sendable
let x: Double
let y: Double
}
enum Status { // implicitly Sendable
case idle
case running
case done(result: String)
}
Both of these are Sendable because all their fields are Sendable. You don't need to write the conformance.
If you want to be explicit (or you want to make a public type's Sendable status clear in API):
struct Point: Sendable {
let x: Double
let y: Double
}
If a struct has a non-Sendable stored property, conformance isn't synthesized:
class Logger { /* not Sendable */ }
struct Service {
let logger: Logger // non-Sendable property
}
// Service is not Sendable
You can't pass a Service between tasks. The fix is to make Logger Sendable (turn it into an actor, or make it final-with-immutable-state), or remove it from Service.
A final class with only immutable Sendable properties can conform:
final class Configuration: Sendable {
let url: URL
let timeout: TimeInterval
let retries: Int
init(url: URL, timeout: TimeInterval, retries: Int) {
self.url = url
self.timeout = timeout
self.retries = retries
}
}
Two requirements:
final — no subclasses, since a subclass could add mutable state.let and Sendable.The compiler verifies these. Adding a var to Configuration would break the conformance.
Generic types are Sendable when their parameters are:
struct Pair<A, B> {
let first: A
let second: B
}
extension Pair: Sendable where A: Sendable, B: Sendable {}
You write the conditional conformance, the compiler checks. Now Pair<Int, String> is Sendable, but Pair<Int, Logger> (with our non-Sendable Logger) isn't.
Without Sendable, the compiler can't tell whether a value crossing tasks is safe. Without that check, race conditions are silent. With it, the compiler refuses code like:
class MutableBag {
var items: [String] = []
}
let bag = MutableBag()
Task {
bag.items.append("a") // Swift 6: ERROR
}
Task {
bag.items.append("b")
}
In Swift 6, "non-Sendable type 'MutableBag' cannot be used in concurrently-executing code" — fail to compile, before the bug ships.
If you want code to be data-race-free at compile time, design your types around Sendable. Use value types (struct, enum) where possible. Use final class with immutable properties when you need reference semantics with sharing. Use actors when you need mutable shared state. Avoid mutable classes that get passed between tasks.
This is a substantial mindset shift from pre-Swift-6 code, where mutable classes are the default. Swift 6 isn't asking you to use only structs — it's asking you to be deliberate about where mutable shared state lives.
Closures are values too. They can be passed between tasks. They can capture local variables. So they need their own Sendable story.
@Sendable on a closure meansA closure marked @Sendable is one that can safely be called from any concurrency context. The compiler checks the closure's captures: each captured value must be Sendable.
func runConcurrently(_ work: @Sendable @escaping () -> Void) {
Task { work() }
}
let n = 42
runConcurrently {
print(n) // OK: captures n by value, Int is Sendable
}
var counter = 0
runConcurrently {
counter += 1 // ERROR: captures `counter` reference, not Sendable
}
The first closure captures n (an Int, value type) by value. Sendable. The second tries to capture and mutate a local var — that's a reference to mutable state, not Sendable.
Task { } requires @SendableThe closure passed to Task { ... } must be @Sendable:
Task {
// body must be Sendable
}
This means the captures must be Sendable. If you're inside an actor method, capturing self is a special case — we'll get to that.
Closures capture by reference for var and by value for let (with optimizations). Inside a @Sendable closure, you can:
let constants of Sendable type — finevar of Sendable value type — copied by value, you get a snapshotlet immutableValue = 42 // Sendable let
var localVar = 5 // local var, Sendable type
let bag = MutableBag() // non-Sendable
Task {
print(immutableValue) // OK
localVar += 1 // ERROR in Swift 6: capturing var
bag.items.append("x") // ERROR: bag is not Sendable
}
To capture a value-type var by value (snapshot), use a capture list:
var localVar = 5
Task { [localVar] in
print(localVar) // OK: captured by value
}
@MainActor closuresIf a closure is annotated @MainActor, it's not just @Sendable — it's specifically isolated to the main actor:
let work: @MainActor () -> Void = {
updateUI() // can be a non-Sendable, main-actor-isolated function
}
@Sendable is contagiousIf you have an async function that takes a closure:
func process(_ items: [Int], using transform: @Sendable (Int) -> Int) async {
// ...
}
Callers must pass a @Sendable closure. The compiler enforces this through the usual rules.
self in actor methodsIf an actor method passes a closure to a Task, capturing self is fine — the actor itself is Sendable. But you generally hop off the actor before doing the work in the closure:
actor Worker {
func process() {
Task {
// captures self (the actor reference)
await self.actuallyDoIt()
}
}
func actuallyDoIt() async {
// ...
}
}
This works because self is the actor, which is Sendable. But notice the closure's body is not isolated to self — it's a non-isolated context. That's why we need await self.actuallyDoIt().
In a non-Sendable context (regular class, sync method), creating a closure that becomes Sendable later involves capture checks at the point of crossing:
class ViewModel {
var counter = 0 // not Sendable
func startWork() {
Task {
// ERROR: capturing self.counter (non-Sendable)
self.counter += 1
}
}
}
In Swift 6, this is rejected. The fix depends on intent: if counter should be mutated from concurrent contexts, make ViewModel an actor. If you want main-thread mutation, mark the class @MainActor. If you want copy-on-capture, capture explicitly.
Sendable closures and noescapeIf a closure is @Sendable but doesn't escape, the rules are slightly more lenient — there's a sub-feature called "non-escaping Sendable" or "region-based isolation" that we'll see in a later chapter.
A @Sendable closure can travel safely between tasks. The compiler enforces by checking captures. Use [capture] lists when you need to snapshot a value var. Actors and Sendable types are fine to capture; non-Sendable mutable state is not. As with Sendable types, this is mostly a discipline once you internalize it: structure your code so concurrent boundaries see Sendable values.
Swift introduced Sendable and the actor model in Swift 5.5 (2021). But making it the default — making non-Sendable values across boundaries a compile error — was deferred. Swift 5.x has strict concurrency checking as opt-in. Swift 6 makes it the default.
The Swift compiler supports three levels of concurrency checking:
To opt in pre-Swift-6, in Package.swift:
.target(name: "MyTarget", swiftSettings: [
.enableExperimentalFeature("StrictConcurrency")
])
Or in Xcode: Build Settings → Strict Concurrency Checking → Complete.
Common diagnoses you'll hit:
Non-Sendable types crossing actor or task boundaries.
class Image { /* not Sendable */ }
@MainActor
func showImage(_ image: Image) {}
Task {
let img = await loadImage() // returns non-Sendable Image
await showImage(img) // ERROR: passing non-Sendable across actor
}
Fix: make Image Sendable (final + immutable, or actor), or convert to a value type, or do the work inside the actor.
Closures capturing mutable variables.
var count = 0
Task {
count += 1 // ERROR
}
Fix: capture by value ([count]), make count an actor's state, or restructure to not need shared mutation.
Globals that are mutable.
var globalCache: [String: Data] = [:] // ERROR in strict mode
Globals with var and a non-Sendable type fail. Fix:
@MainActor var globalCache: [String: Data] = [:] // isolated to main actor
// or
let globalCache = LockedCache() // own synchronization
actor GlobalCache {
static let shared = GlobalCache()
private var dict: [String: Data] = [:]
func get(_ key: String) -> Data? { dict[key] }
func set(_ key: String, _ value: Data) { dict[key] = value }
}
Singleton class instances that are mutable. Same as globals — needs isolation or an actor.
For an existing codebase, turn on warnings first. You'll see hundreds of them in any non-trivial app. Work through them in clusters — by file or feature.
Common fixes:
@MainActor.final and add : Sendable.@unchecked Sendable with locking — but document it.nonisolated(unsafe) or move to non-shared scope.The fixes often surface design problems. A class that's "mostly immutable but has a mutable cache" probably wants an actor or @MainActor. A "global config" with a mutable property wants either to be immutable (config loaded once at startup, never mutated) or to be an actor.
In Swift 6 mode (set in Package.swift via swiftLanguageVersions: [.v6] or in Xcode), the warnings become errors. Code with concurrency issues fails to compile. Many of Apple's own frameworks took years to be ready for this.
Hard-won correctness. Once your code compiles cleanly under strict concurrency, classes of bugs that used to ship to production are gone:
var from two threads without synchronizationThese bugs used to manifest as occasional crashes, weird state, or wrong values. They'd ship in shipped apps and reproduce only on certain devices, only sometimes. Strict concurrency catches them before they reach the simulator.
Migration time. New mental models. Frustration with compile errors that say "you have a data race here" when you were absolutely sure you didn't.
But the answer to "how long does this take" is "less than chasing those bugs in production for years would." Get the type-safety up front; reap the benefit forever.
@unchecked SendableWhen you genuinely need a class to be Sendable but the compiler can't verify it (you're using a lock, or wrapping a thread-safe C library), you write @unchecked Sendable:
final class LockedCache: @unchecked Sendable {
private let lock = NSLock()
private var storage: [String: Data] = [:]
func get(_ key: String) -> Data? {
lock.lock(); defer { lock.unlock() }
return storage[key]
}
func set(_ key: String, _ value: Data) {
lock.lock(); defer { lock.unlock() }
storage[key] = value
}
}
You're telling the compiler "trust me, this is thread-safe." Usually you should reach for an actor instead — but not always. Locks have lower overhead than actor hops; for very hot paths, locked classes are sometimes the right call. Just be very careful, and document the synchronization invariant.
sending parameters (Swift 5.9+)A newer feature: a parameter marked sending transfers ownership into the function. The compiler tracks that the caller no longer uses it, so a non-Sendable value can be safely sent:
func process(_ value: sending NonSendableThing) async {
// we're allowed to take this across actor boundaries
// because the caller is forbidden to keep using it
}
let thing = NonSendableThing()
await process(thing)
// thing is no longer usable here
print(thing) // ERROR: 'thing' was transferred
This is more advanced — you'll see it in library code. For most app code, designing around Sendable types covers your needs.
A related new feature: the compiler analyzes the region a non-Sendable value lives in (which tasks/actors can see it) and lets you transfer it between regions if no one else can access it.
You don't typically write any new syntax for this. You benefit when the compiler accepts code that was previously a hard error — typically code where you create a non-Sendable value, pass it to one task, and don't keep using it locally.
Strict concurrency is the future, and it's already today. Plan for it. Learn to think in Sendable types and actors. The bugs you're forced to confront at compile time were already in your code; the compiler just made them visible. Address them once and your code is dramatically more reliable.
A lot of real iOS code is GCD and completion handlers. You won't migrate it all at once, but you'll migrate it piece by piece. Here are the common patterns and their async/await replacements.
Before:
func fetchUser(id: String, completion: @escaping (Result<User, Error>) -> Void) {
URLSession.shared.dataTask(with: makeURL(id)) { data, _, error in
if let error = error {
completion(.failure(error))
return
}
guard let data = data else {
completion(.failure(MyError.noData))
return
}
do {
let user = try JSONDecoder().decode(User.self, from: data)
completion(.success(user))
} catch {
completion(.failure(error))
}
}.resume()
}
After:
func fetchUser(id: String) async throws -> User {
let (data, _) = try await URLSession.shared.data(from: makeURL(id))
return try JSONDecoder().decode(User.self, from: data)
}
Same logic, fraction of the code. You replace:
completion: @escaping (Result<T, Error>) -> Void → async throws -> Tcompletion(.failure(error)) → throw errorcompletion(.success(value)) → return valueURLSession.shared.dataTask(with:completion:).resume() → try await URLSession.shared.data(from:)You can maintain a callback API as a thin wrapper around an async one:
func fetchUser(id: String) async throws -> User { /* ... */ }
func fetchUser(id: String, completion: @escaping (Result<User, Error>) -> Void) {
Task {
do {
let user = try await fetchUser(id: id)
completion(.success(user))
} catch {
completion(.failure(error))
}
}
}
Now old callers keep working while new code uses async. Eventually you can deprecate and remove the callback wrapper.
Before:
DispatchQueue.global(qos: .userInitiated).async {
let result = expensiveComputation()
DispatchQueue.main.async {
self.label.text = result
}
}
After:
Task {
let result = await expensiveComputation()
await MainActor.run {
self.label.text = result
}
}
Or, if self is @MainActor and the computation is on a non-isolated function:
Task {
let result = await expensiveComputation()
self.label.text = result // already on main actor
}
The old way to make a class thread-safe:
final class Counter {
private let queue = DispatchQueue(label: "counter")
private var _value = 0
func increment(completion: @escaping (Int) -> Void) {
queue.async {
self._value += 1
let v = self._value
DispatchQueue.main.async {
completion(v)
}
}
}
func value(completion: @escaping (Int) -> Void) {
queue.async {
let v = self._value
DispatchQueue.main.async { completion(v) }
}
}
}
The new way:
actor Counter {
private var value = 0
func increment() -> Int {
value += 1
return value
}
func currentValue() -> Int {
value
}
}
Callers go from counter.increment { v in print(v) } to let v = await counter.increment(). Vastly simpler.
Before:
func waitForReady() {
let sema = DispatchSemaphore(value: 0)
legacyAPI.onReady { sema.signal() }
sema.wait() // synchronous wait — bad in async context
}
After:
func waitForReady() async {
await withCheckedContinuation { cont in
legacyAPI.onReady { cont.resume() }
}
}
The semaphore-blocking pattern was always sketchy (it ties up a thread). The continuation approach is suspension-based and doesn't block.
async letBefore:
let group = DispatchGroup()
var results: [Result] = []
let lock = NSLock()
for url in urls {
group.enter()
fetch(url) { data in
lock.lock()
results.append(.success(data))
lock.unlock()
group.leave()
}
}
group.notify(queue: .main) {
handleAll(results)
}
After:
let results = await withTaskGroup(of: Result.self) { group in
for url in urls {
group.addTask {
do {
let data = try await fetch(url)
return .success(data)
} catch {
return .failure(error)
}
}
}
return await group.reduce(into: [Result]()) { $0.append($1) }
}
await MainActor.run { handleAll(results) }
Actually shorter and obviously correct.
Before:
let timer = DispatchSource.makeTimerSource(queue: .main)
timer.schedule(deadline: .now(), repeating: 1.0)
timer.setEventHandler {
print("tick")
}
timer.resume()
// later: timer.cancel()
After:
let timer = AsyncStream<Date> { continuation in
let task = Task {
while !Task.isCancelled {
continuation.yield(Date())
try? await Task.sleep(for: .seconds(1))
}
continuation.finish()
}
continuation.onTermination = { _ in task.cancel() }
}
for await date in timer.prefix(10) {
print("tick \(date)")
}
Before:
let queue = OperationQueue()
let download = AsyncOperation { /* ... */ }
let process = AsyncOperation { /* ... */ }
let save = AsyncOperation { /* ... */ }
process.addDependency(download)
save.addDependency(process)
queue.addOperations([download, process, save], waitUntilFinished: false)
After:
Task {
let downloaded = try await download()
let processed = try await process(downloaded)
try await save(processed)
}
Sequential dependencies are just sequential await. Concurrent dependencies are async let or task groups.
Most teams I've seen succeed with this approach:
Task { } and bridge back to a callback.The goal isn't 100% migration. The goal is async/await for new development and the parts of the codebase that benefit most.
Combine and async/await both handle async work, but they have different shapes. Combine is push-based with operators on streams. Async/await is pull-based with linear code. They overlap, and you can mix them — but for many use cases, async/await is now the simpler choice.
Combine is still excellent for:
@Published/ObservableObjectDon't rip Combine out wholesale. Coexist.
Before:
func fetchUser(id: String) -> AnyPublisher<User, Error> {
URLSession.shared.dataTaskPublisher(for: makeURL(id))
.map(\.data)
.decode(type: User.self, decoder: JSONDecoder())
.eraseToAnyPublisher()
}
// Usage:
fetchUser(id: "42")
.sink(receiveCompletion: { ... }, receiveValue: { user in ... })
.store(in: &cancellables)
After:
func fetchUser(id: String) async throws -> User {
let (data, _) = try await URLSession.shared.data(from: makeURL(id))
return try JSONDecoder().decode(User.self, from: data)
}
// Usage:
Task {
do {
let user = try await fetchUser(id: "42")
} catch {
// handle
}
}
Combine for one-shot fetches is overkill. Async/await is more direct.
For multi-value streams, you can bridge a publisher to an AsyncSequence using .values:
let publisher: AnyPublisher<Int, Never> = ...
for await value in publisher.values {
print(value)
}
This works on AnyPublisher<Output, Never> (and AnyPublisher<Output, Error> via try). You get an async iteration over published values. The subscription is automatically cancelled when the iteration ends.
This is convenient for migrating chunks of code: leave the publisher, consume it from async land.
@Published → published values via .valuesIn an ObservableObject:
final class ViewModel: ObservableObject {
@Published var search = ""
func observe() async {
for await query in $search.values {
// react to changes
print(query)
}
}
}
$search is the projected publisher of @Published. Its .values is an AsyncSequence.
Combine's combineLatest:
Publishers.CombineLatest(textField, dropdown)
.sink { (text, choice) in /* ... */ }
.store(in: &cancellables)
Async-algorithms equivalent (chapter 17):
import AsyncAlgorithms
for await (text, choice) in combineLatest(textFieldStream, dropdownStream) {
// ...
}
Many Combine operators have direct async equivalents in swift-async-algorithms.
Combine:
$search
.debounce(for: .milliseconds(300), scheduler: DispatchQueue.main)
.sink { ... }
Async:
import AsyncAlgorithms
for await query in $search.values.debounce(for: .milliseconds(300)) {
// ...
}
In SwiftUI, the .task { } modifier replaces a lot of Combine subscription patterns:
struct SearchView: View {
@StateObject var vm = SearchViewModel()
var body: some View {
TextField("Search", text: $vm.query)
.task {
await vm.observeQueries()
}
}
}
.task { } runs an async function for the lifetime of the view. When the view disappears, the task is cancelled. You don't need to manage subscriptions.
A Combine pipeline:
URLSession.shared.dataTaskPublisher(for: url)
.map(\.data)
.decode(type: Wrapper.self, decoder: JSONDecoder())
.map { $0.users }
.filter { !$0.isEmpty }
.first()
.sink(receiveCompletion: { ... }, receiveValue: { ... })
Async equivalent:
let (data, _) = try await URLSession.shared.data(from: url)
let wrapper = try JSONDecoder().decode(Wrapper.self, from: data)
guard !wrapper.users.isEmpty else {
throw MyError.noUsers
}
let users = wrapper.users
The async version is longer but reads top to bottom. The Combine version is more concise but obscures the "what happens if wrapper.users.isEmpty" question — filter just stops the pipeline, completion never fires, the consumer waits forever. The async version is forced to handle that case explicitly. That's usually a feature.
Some Combine patterns are genuinely better in Combine:
PassthroughSubject to many sinks is easy. AsyncStream is single-subscriber by default.@Published is built on Combine; there's no async-native equivalent that's quite as smooth for two-way bindings.For these, Combine is fine. Don't rebuild what's working.
Combine and async/await coexist. Use async/await for one-shot operations and linear pipelines. Use Combine for SwiftUI bindings and complex multi-subscriber pub-sub. Bridge between them with .values (publisher → AsyncSequence) and Task { } (async → publisher subscriber). Migration isn't all-or-nothing.
Concurrency in an iOS app has shape. UI lifecycle, view models, navigation, background tasks, and the App Store's sandbox all interact with async code. This chapter is patterns for the iOS context.
.task { } in SwiftUIThe bread-and-butter SwiftUI async modifier:
struct ProfileView: View {
let userID: String
@State private var profile: Profile?
var body: some View {
Group {
if let profile {
Text(profile.name)
} else {
ProgressView()
}
}
.task {
profile = try? await loadProfile(userID: userID)
}
}
}
The closure runs when the view appears. The task is cancelled when the view disappears. No subscription bookkeeping.
If the task should re-run when a value changes:
.task(id: userID) {
profile = try? await loadProfile(userID: userID)
}
When userID changes, the previous task is cancelled and a new one starts. Magic, in a good way.
.refreshable { }For pull-to-refresh:
List(items) { item in ItemRow(item) }
.refreshable {
await reloadItems()
}
The closure runs when the user pulls down. The refresh indicator stays visible until the closure returns.
Task { } in UIKitIn a UIViewController:
final class ProfileViewController: UIViewController {
private var loadTask: Task<Void, Never>?
override func viewDidLoad() {
super.viewDidLoad()
loadTask = Task { [weak self] in
guard let self else { return }
await self.loadProfile()
}
}
deinit {
loadTask?.cancel()
}
@MainActor
func loadProfile() async {
// since UIViewController is implicitly @MainActor in Swift 6,
// and we're already there, just await off it
let profile = try? await self.api.fetchProfile()
self.label.text = profile?.name
}
}
UIViewController got an implicit @MainActor annotation in newer SDKs, so most things just work. The loadTask storage lets you cancel on deinit, preventing work from continuing after the view controller is gone.
BGAppRefreshTask and async background workFor background refresh on iOS, you register a handler with BGTaskScheduler. The handler can be async:
BGTaskScheduler.shared.register(
forTaskWithIdentifier: "com.example.refresh",
using: nil
) { task in
Task {
let success = await refreshContent()
task.setTaskCompleted(success: success)
}
}
You bridge from the sync BGTaskScheduler callback into a Task. The system gives you limited time; respect the deadline.
URLSession has full async/await support:
let (data, response) = try await URLSession.shared.data(from: url)
// download to file
let (location, response) = try await URLSession.shared.download(from: url)
// upload data
let (data, response) = try await URLSession.shared.upload(for: request, from: bodyData)
// upload from file
let (data, response) = try await URLSession.shared.upload(for: request, fromFile: fileURL)
These respect cancellation. URLSession also supports bytes(for:) for streaming:
let (bytes, _) = try await URLSession.shared.bytes(from: url)
for try await byte in bytes {
process(byte)
}
Useful for large downloads where you want to process while the download is still happening.
A common pattern, building on actors and reentrancy:
@MainActor
final class ImageCache {
static let shared = ImageCache()
private var cache: [URL: UIImage] = [:]
private var inFlight: [URL: Task<UIImage?, Never>] = [:]
func image(for url: URL) async -> UIImage? {
if let cached = cache[url] { return cached }
if let pending = inFlight[url] {
return await pending.value
}
let task = Task<UIImage?, Never> { [weak self] in
do {
let (data, _) = try await URLSession.shared.data(from: url)
return UIImage(data: data)
} catch {
return nil
}
}
inFlight[url] = task
defer { inFlight[url] = nil }
let image = await task.value
if let image { cache[url] = image }
return image
}
}
The @MainActor annotation means UI code can read the cache without await. The in-flight tracking prevents duplicate downloads.
You can't use Task { ... } capturing self in deinit cleanly — self is being deinitialized. Patterns to handle "fire something off when this object goes away":
deinit {
let url = self.url
Task.detached(priority: .background) {
try? await Cache.shared.evict(url: url)
}
}
Capture only the values you need. Task.detached doesn't try to capture self.
A typical iOS view model with structured async work:
@MainActor
final class FeedViewModel: ObservableObject {
@Published private(set) var items: [Item] = []
@Published private(set) var isLoading = false
@Published var errorMessage: String?
private let api: API
private var loadTask: Task<Void, Never>?
init(api: API) { self.api = api }
func load() {
loadTask?.cancel()
loadTask = Task {
isLoading = true
defer { isLoading = false }
do {
items = try await api.feed()
} catch is CancellationError {
// expected on rapid reload
} catch {
errorMessage = error.localizedDescription
}
}
}
deinit { loadTask?.cancel() }
}
Clean, cancellable, error-handled, observable. This is the shape most view models will have.
The Main Thread Checker in Xcode warns when UIKit code runs off the main thread. With Swift Concurrency, you mostly avoid this by using @MainActor correctly.
If you're getting warnings:
@MainActor (most are by default in newer SDKs).await MainActor.run { ... }.DispatchQueue.global for UIKit work, even briefly.Apps often need to load configuration, check auth, sync data on launch. With async/await:
@main
struct MyApp: App {
@StateObject private var session = Session()
var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(session)
.task {
await session.bootstrap()
}
}
}
}
@MainActor
final class Session: ObservableObject {
@Published var user: User?
func bootstrap() async {
user = await loadCachedUser()
// refresh in background
Task {
user = try? await api.refreshUser()
}
}
}
.task on a top-level view runs once when the app's UI is up. Inside, you do the bootstrap work.
iOS code has well-defined places to launch async work — .task, Task { } in lifecycle methods, BGTaskScheduler handlers. Use the right one for each. Cancel tasks on view dismissal/deinit. Embrace @MainActor for view models and view controllers; it eliminates an entire class of "wrong thread" bugs.
Async code presents new challenges in all three areas. Some techniques.
XCTest supports async test methods directly:
final class UserTests: XCTestCase {
func test_fetchUser_returnsExpectedUser() async throws {
let api = MockAPI(fixedUser: User(id: "42", name: "alice"))
let user = try await api.fetchUser(id: "42")
XCTAssertEqual(user.name, "alice")
}
}
The test method is async throws. XCTest awaits it. If it throws, the test fails.
For XCTestExpectation-style waiting (callbacks, notifications), you can still use those, but for pure async code, the simpler form is better.
Test that your code respects cancellation:
func test_cancellation_stopsWork() async throws {
let task = Task {
try await longRunningWork()
}
task.cancel()
do {
_ = try await task.value
XCTFail("expected cancellation")
} catch is CancellationError {
// expected
}
}
Actors are testable like any other type. Keep their interface async, await calls in tests:
func test_counter_incrementsCorrectly() async {
let counter = Counter()
await counter.increment()
await counter.increment()
let value = await counter.value
XCTAssertEqual(value, 2)
}
Run a bunch of concurrent operations and verify the actor handled them correctly:
func test_counter_handlesConcurrency() async {
let counter = Counter()
await withTaskGroup(of: Void.self) { group in
for _ in 0..<1_000 {
group.addTask { await counter.increment() }
}
}
let value = await counter.value
XCTAssertEqual(value, 1_000)
}
If Counter were a class without locking, this would sometimes fail. As an actor, it always passes — that's the value.
A protocol-based mock:
protocol UserAPI: Sendable {
func fetchUser(id: String) async throws -> User
}
actor MockUserAPI: UserAPI {
var fixedUser: User?
var fixedError: Error?
init(user: User? = nil, error: Error? = nil) {
self.fixedUser = user
self.fixedError = error
}
func fetchUser(id: String) async throws -> User {
if let error = fixedError { throw error }
if let user = fixedUser { return user }
throw MockError.unset
}
}
The mock is itself an actor for thread safety, but it could equally be a struct or final class with immutable state.
Useful diagnostic helpers:
print("Current thread: \(Thread.current)")
print("Is main thread: \(Thread.isMainThread)")
print("Task priority: \(Task.currentPriority)")
print("Task is cancelled: \(Task.isCancelled)")
In a debugger, you can inspect the current task's state. The "View Memory Graph" and "View Thread State" panels in Xcode show which tasks are running and what they're awaiting.
Thread Sanitizer (TSAN). Build → Diagnostics → Thread Sanitizer. Catches data races at runtime in debug builds. Slows execution down significantly but catches real bugs.
Main Thread Checker. Default-on. Warns when UIKit/AppKit calls happen off the main thread.
Instruments.
A task seems to never finish. Likely causes:
for await on a stream whose continuation is never finished.DispatchSemaphore.wait).A task runs but seemingly does nothing. Likely:
Things happen in unexpected order. Reentrancy! Or concurrent execution of work you thought was sequential. Audit await points and think about what other tasks could run there.
Async/await isn't free. Each suspension and resumption has overhead. For tight inner loops, this can matter.
A fully synchronous async function (no actual suspensions) is roughly 10-30ns of overhead vs a sync one. Negligible for almost any real work.
An actor hop is more expensive — typically 50-200ns when uncontended, much more when contended. If you have a hot path that hops to an actor 100,000 times per second, you'll notice.
Suspensions across executors (e.g., main → background) involve thread context handoff. Microseconds per transition. Don't do this in tight loops.
If profiling shows concurrency overhead:
nonisolated more aggressively for code that doesn't need isolation.@unchecked Sendable for very hot paths.A function that's marked async but never actually suspends still has overhead — the compiler generates state-machine code, allocates a continuation, etc. If you have a "fast path" that doesn't need to be async, mark it sync:
func cachedValue(_ key: String) -> String? {
return cache[key] // synchronous fast path
}
func fetchValue(_ key: String) async throws -> String {
if let cached = cachedValue(key) { return cached }
return try await actuallyFetch(key)
}
The fast path stays out of async-land; only the slow path goes async.
Test async code with async test methods. Use actor mocks for thread safety in tests. Use Thread Sanitizer in debug builds. Profile with Instruments when you suspect concurrency overhead. Most of the time, performance is fine; when it's not, look at actor contention and unnecessary suspensions.
A grab-bag chapter to consolidate what we've covered, name patterns you'll reach for again and again, and call out anti-patterns to avoid. Then a map of where to keep learning.
Cancel-old-on-new. When the user takes an action that supersedes a previous one (typing a new search query, picking a different filter), cancel the previous task before starting the new:
@MainActor
final class SearchViewModel: ObservableObject {
@Published var results: [Item] = []
private var searchTask: Task<Void, Never>?
func search(_ query: String) {
searchTask?.cancel()
searchTask = Task {
try? await Task.sleep(for: .milliseconds(250))
guard !Task.isCancelled else { return }
do {
results = try await api.search(query)
} catch is CancellationError {
// expected
} catch {
results = []
}
}
}
}
The cancel-then-start cadence and the explicit Task.isCancelled check after the sleep are the two pieces. Together, they give you debounce + cancel-old-on-new in a few lines.
In-flight deduplication. When multiple callers ask for the same async result simultaneously, do the work once and share it:
actor Loader<Key: Hashable & Sendable, Value: Sendable> {
private var cache: [Key: Value] = [:]
private var inFlight: [Key: Task<Value, Error>] = [:]
private let work: @Sendable (Key) async throws -> Value
init(work: @escaping @Sendable (Key) async throws -> Value) {
self.work = work
}
func value(for key: Key) async throws -> Value {
if let cached = cache[key] { return cached }
if let pending = inFlight[key] { return try await pending.value }
let task = Task { try await self.work(key) }
inFlight[key] = task
defer { inFlight[key] = nil }
let value = try await task.value
cache[key] = value
return value
}
}
A general-purpose deduplicating cache. Pass in a function that loads a value for a key; the loader handles caching, in-flight tracking, and concurrent requests.
Retry with exponential backoff. For transient failures:
func retry<T: Sendable>(
maxAttempts: Int = 3,
initialDelay: Duration = .milliseconds(200),
operation: @Sendable () async throws -> T
) async throws -> T {
var delay = initialDelay
for attempt in 1...maxAttempts {
do {
return try await operation()
} catch is CancellationError {
throw CancellationError()
} catch {
if attempt == maxAttempts { throw error }
try await Task.sleep(for: delay)
delay *= 2
}
}
throw RetryError.exhausted
}
enum RetryError: Error { case exhausted }
// usage:
let data = try await retry(maxAttempts: 3) {
try await api.fetchSomething()
}
Note the explicit cancellation check — we re-throw CancellationError immediately rather than retrying.
Producer/consumer with AsyncStream. For decoupling who-makes-events from who-handles-them:
let (events, continuation) = AsyncStream.makeStream(of: Event.self)
// Producer (somewhere):
button.addAction { continuation.yield(.tapped) }
notificationObserver { continuation.yield(.notification($0)) }
// Consumer (somewhere else):
Task {
for await event in events {
await handle(event)
}
}
The continuation is the input. Anywhere you have a continuation, you can yield. The stream is the output. One consumer reads them in order.
Fan-out, fan-in. Process many things in parallel, gather results:
func processAll<T: Sendable, R: Sendable>(
_ items: [T],
_ transform: @Sendable @escaping (T) async throws -> R
) async throws -> [R] {
try await withThrowingTaskGroup(of: (Int, R).self) { group in
for (i, item) in items.enumerated() {
group.addTask {
let r = try await transform(item)
return (i, r)
}
}
var indexed: [(Int, R)] = []
for try await pair in group {
indexed.append(pair)
}
return indexed.sorted(by: { $0.0 < $1.0 }).map(\.1)
}
}
Carries the original index so we can preserve order. For unordered results, drop the indexing.
Bounded concurrency. As we saw earlier, throttle parallelism to avoid overwhelming a downstream service. Maintain an "in-flight" count and feed new tasks as old ones complete.
Timeout wrapper. From chapter 11 — race the operation against a sleep, cancel the loser. Useful for any operation where "if it takes more than N seconds, give up" is reasonable.
Cooperative cancellation in long loops. Sprinkle try Task.checkCancellation() periodically. The cost is negligible; the responsiveness benefit is large.
Sendable boundaries. Push non-Sendable mutable state into actors. Make data-carrying types value types. Use final class : Sendable for immutable reference types. Reach for @unchecked Sendable only when you have specific synchronization in place and you've documented it.
Blocking inside an async task. DispatchSemaphore.wait, pthread_mutex_lock over an await, Thread.sleep, Date(timeIntervalSinceNow:) busy-waits. Any of these can deadlock the cooperative thread pool. If you need to wait, await. If you need to delay, try await Task.sleep.
Ignoring CancellationError. Catching errors generically and silently dropping cancellation:
// BAD
do {
try await something()
} catch {
// swallow everything
}
Cancellation is a normal control-flow signal. Either propagate it or check is CancellationError and handle it explicitly.
Doing CPU-heavy work on @MainActor. Anything you mark @MainActor runs on the main thread. If you do an O(n) loop over a million items there, the UI freezes. Move computation off the main actor:
@MainActor
func updateList() async {
let items = await Task.detached(priority: .userInitiated) {
return computeExpensiveList()
}.value
self.items = items
}
The detached task does the work; we hop back to update the property.
Using raw Task { } when structured concurrency would do. A common code smell:
// SMELL
func loadProfile() async -> Profile {
var user: User?
var avatar: UIImage?
Task { user = await fetchUser() }
Task { avatar = await fetchAvatar() }
// wait somehow??
// ...
}
You can't wait correctly; the function will return before children complete. Use async let or task groups. Save Task { } for top-level entry points, bridging from sync, or fire-and-forget.
Overusing Task.detached. Detached tasks are the explicit escape from inheritance. Most of the time you actually want priority and task-local-value inheritance. Reach for plain Task { } first.
Strong self capture in long-running tasks. In a view model:
// SMELL
Task {
while true {
await self.poll()
try? await Task.sleep(for: .seconds(5))
}
}
The task captures self strongly. The view model can't deinit until the task ends. The task never ends. Use [weak self]:
Task { [weak self] in
while !Task.isCancelled {
guard let self else { break }
await self.poll()
try? await Task.sleep(for: .seconds(5))
}
}
Even better, store the task and cancel it in deinit.
Storing continuations in mutable state without nilling. A delegate-bridge pattern that fires more than once will resume a continuation more than once and trap. Always nil the stored continuation after resume:
private var continuation: CheckedContinuation<Result, Error>?
func didReceive(_ result: Result) {
continuation?.resume(returning: result)
continuation = nil
}
Creating an actor just to hold immutable data. If a type only has let properties, it doesn't need to be an actor. Make it a Sendable struct or final class : Sendable. Actors are for mutable state.
Hopping actors mid-loop unnecessarily. Each await on an actor is potentially a context switch. A loop that hops on every iteration:
// SLOW
for item in hugeList {
await store.append(item)
}
It's much faster to batch:
// FAST
await store.appendAll(hugeList)
Where appendAll does the loop on the actor side.
Thinking of await as a function call boundary. await is a suspension point. State can change. Don't carry assumptions across it:
// SUSPECT
if cache[key] != nil {
let stored = cache[key]
let processed = await process(stored) // oops: cache might not contain key anymore
return cache[key]! // crash if it was evicted
}
If you read a value before await, hold the local copy and use it. Don't re-read assuming the world hasn't changed.
Using Task.sleep for synchronization. "I'll sleep 100ms then assume the previous task is done." This is racy and brittle. Use proper coordination — await task.value, for await on a stream, an actor's state.
Passing closures with non-Sendable captures across boundaries. The compiler will complain in strict mode. Don't @unchecked Sendable your way around it; address the root cause.
This guide has covered most of what production iOS code needs. The frontier looks like this:
Apple's Swift Evolution proposals. The canonical, normative documents for the language. Worth reading the originals for the "why":
@isolated(any) Function Types)Find them at github.com/apple/swift-evolution.
WWDC sessions. Apple's deep dives, with examples and walkthroughs. Particularly:
Search the developer.apple.com videos page.
The swift-async-algorithms package. We touched it in chapter 17. The full operator list at github.com/apple/swift-async-algorithms is worth a browse — there are useful operators you didn't know existed.
Distributed actors. Swift has a distributed-actor extension to the actor model: actors that can live on different machines, with calls transparently going over a network transport. The swift-distributed-actors cluster framework is the reference implementation. Mostly relevant for server-side or distributed systems work, but the model is fascinating.
Custom executors. SE-0392 added the ability to write your own executor — the thing that decides which thread or queue runs an actor's work. For most app code this is overkill; for embedded systems, custom runtimes, or specialized scheduling needs, it's where the action is.
Server-side Swift. Frameworks like Vapor and Hummingbird are async/await native. Building a server is a great way to internalize structured concurrency, because the request-handling model maps naturally to "each request is a task, with child tasks for the work it spawns."
Books and ongoing content.
Profile and read source. When something behaves unexpectedly, read the standard library source. The Swift project is open source. The Concurrency runtime in the swift repo under stdlib/public/Concurrency shows you exactly what Task, AsyncStream, and the actors are doing.
Swift Concurrency is, more than anything, an attempt to make correct-by-default the new normal. The async/await syntax is what you'll use every day. Structured concurrency is the discipline that makes it scale. Actors and Sendable are the type-system tools that turn race conditions from runtime mysteries into compile errors.
You don't need to use everything. Most apps need async/await for I/O, Task { } to bridge from UI events, @MainActor on view code, and a handful of actors for shared state. That's a relatively small toolkit; it covers a lot.
The harder material — reentrancy, region-based isolation, custom global actors, the gnarlier corners of Sendable — is there when you need it. Reach for it when a real problem demands it. The basics, applied consistently, are usually enough.
Good luck, and have fun.