When you write async/await code, it feels magical—asynchronous operations look like synchronous code, yet your program doesn’t block. But what’s actually happening under the hood? How does Swift transform your sequential-looking code into non-blocking, resumable operations?
In this deep dive, we’ll explore the internal mechanisms that make async/await work in Swift. We’ll examine state machines, continuations, async frames, and executor management to understand how the language achieves efficient asynchronous programming.
The Core Problem
Before async/await, asynchronous programming meant callbacks, manual state tracking, and complex control flow:
// The old way - callback hell
func fetchUserData(userId: Int, completion: @escaping (User) -> Void) {
fetchFromDatabase(userId: userId) { userData in
fetchUserPreferences(userId: userId) { preferences in
fetchUserPosts(userId: userId) { posts in
let user = User(userData: userData, preferences: preferences, posts: posts)
completion(user)
}
}
}
}With async/await, this becomes:
// The modern way
func fetchUserData(userId: Int) async -> User {
let userData = await fetchFromDatabase(userId: userId)
let preferences = await fetchUserPreferences(userId: userId)
let posts = await fetchUserPosts(userId: userId)
return User(userData: userData, preferences: preferences, posts: posts)
}The challenge: how do we make this sequential-looking code actually suspend, free up the thread, and resume later without blocking?
The State Machine Transformation
Swift solves this problem by transforming your async function into a state machine at compile time. Each await point becomes a distinct state.
Swift’s Async Frame Approach
When you write an async function, Swift allocates an async frame that persists across suspension points.
Here’s what you write:
func getData() async -> Int {
let result = await fetch()
let processed = process(result)
return processed
}Swift allocates an async frame that conceptually looks like:
┌────────────────────────┐
│ Async Frame │
├────────────────────────┤
│ Parent frame pointer │
│ Resume function ptr │
│ Resume point (state) │
│ Executor reference │
│ Local: result │
│ Local: processed │
└────────────────────────┘Unlike regular stack frames (which are destroyed when a function returns), async frames are heap-allocated and persist across suspension points. They form a linked chain through parent pointers, similar to a call stack but with the crucial difference that they can survive when the function suspends.
How the State Machine Works
The compiler transforms your async function into something conceptually like this:
// Conceptual transformation (simplified)
struct GetDataAsyncFrame {
// Current state in the state machine
var state: Int
// Local variables that survive across await
var result: Data?
var processed: Int?
// Parent frame pointer
var parentFrame: UnsafePointer<AsyncFrame>?
// Resume function
func resume() {
switch state {
case 0:
// Initial state - start fetch()
state = 1
// Suspend and wait for fetch() to complete
return
case 1:
// Resumed after fetch() completed
result = getAwaitResult()
processed = process(result!)
state = -1 // Mark as completed
completeAsync(result: processed!)
return
}
}
}The key insight: all local variables that need to survive across await points are stored in the async frame. This moves them from the stack to the heap, ensuring they persist when the method yields control.
When you await, the method doesn’t actually wait—it:
- Saves the current state
- Registers a continuation (the resume function)
- Returns control to the caller
- Later, when the awaited operation completes, the resume function is called
Understanding Continuations
Continuations are the core mechanism that makes resumption work. A continuation is essentially “the rest of the computation”—it packages up everything needed to resume execution later.
What’s in a Continuation?
Think of a continuation as a bookmark that contains:
- Where to resume: The specific point in the code to continue from
- What state to restore: The local variables and context
- Where to run: Which executor/scheduler to use when resuming
In Swift, a continuation conceptually looks like:
struct UnsafeContinuation<T> {
// Pointer to the async frame to resume
var asyncFrame: UnsafePointer<AsyncFrame>
// The function to call to resume execution
var resumeFunction: @convention(thin) (UnsafePointer<AsyncFrame>, T) -> Void
// Which executor to resume on
var targetExecutor: UnownedSerialExecutor
// Priority and other metadata
var priority: TaskPriority
}Creating a Continuation
Let’s trace what happens when you await:
func fetchUser(id: Int) async -> User {
let data = await networkRequest(id) // ← Suspension point
return User(data: data)
}At the await networkRequest(id) line:
-
The compiler generates code to create a continuation:
- Captures the current async frame
- Sets the resume point (state 1 in our example)
- Captures the current executor
-
The async frame is updated:
- Resume point set to 1
- Current state saved
- Live variables preserved
-
Control returns to the runtime scheduler:
- The current thread is freed up
- Other work can execute
Resuming a Continuation
When the async operation completes, someone calls continuation.resume(). Here’s what happens internally:
func resume<T>(continuation: UnsafeContinuation<T>, with value: T) {
// 1. Get the async frame
let frame = continuation.asyncFrame
// 2. Store the result value in the frame
storeResult(in: frame, value: value)
// 3. Check if we need to switch executors
let targetExecutor = continuation.targetExecutor
let currentExecutor = getCurrentExecutor()
if currentExecutor == targetExecutor {
// Already on correct executor - resume directly
continuation.resumeFunction(frame, value)
} else {
// Need to hop to the target executor
targetExecutor.enqueue {
continuation.resumeFunction(frame, value)
}
}
}The resume function jumps back into the async function at the right state:
func resumeFetchUser(frame: UnsafePointer<AsyncFrame>, data: Data) {
// Restore local state from frame
let locals = loadLocals(from: frame)
// Jump to the right state
switch locals.resumePoint {
case 1:
// Resume after networkRequest
let user = User(data: data)
completeAsync(frame, result: user)
default:
fatalError()
}
}Manual Continuation API
Swift exposes continuations through APIs that let you bridge callback-based code to async/await:
func legacyAsyncOperation(completion: @escaping (Result<String, Error>) -> Void) {
// Old-style callback API
DispatchQueue.global().asyncAfter(deadline: .now() + 1) {
completion(.success("Done!"))
}
}
// Bridge to async/await using continuation:
func modernAsyncOperation() async throws -> String {
return try await withCheckedThrowingContinuation { continuation in
legacyAsyncOperation { result in
// Resume the continuation with the result
continuation.resume(with: result)
}
}
}The withCheckedThrowingContinuation function:
- Creates a continuation for the current suspension point
- Passes it to your closure
- Suspends the async function
- Your closure stores the continuation and starts the async operation
- When the callback fires, you resume the continuation
- The async function continues execution
Continuation Safety
Continuations must be resumed exactly once. Swift provides both checked and unsafe versions:
// Checked - runtime verifies you resume exactly once
withCheckedContinuation { continuation in
continuation.resume(returning: 42)
// continuation.resume(returning: 43) // ← Runtime error!
}
// Unsafe - no checking, better performance
withUnsafeContinuation { continuation in
continuation.resume(returning: 42)
// Resuming twice = undefined behavior!
}Resuming twice leads to crashes and memory corruption. Never resuming causes the async function to hang forever and leak memory. The checked version protects you from these errors at the cost of some runtime overhead.
Executor Management and Context Switching
One of the most elegant aspects of Swift’s async/await is how it manages execution context—ensuring that code runs on the correct executor (thread pool, main queue, actor’s serial queue, etc.).
How Executors Are Tracked
The key is task-local storage. Each Task carries metadata including the current executor:
struct Task {
var id: TaskID
var priority: TaskPriority
var asyncFrameChain: UnsafePointer<AsyncFrame>
// Current executor stored per-task
var currentExecutor: UnownedSerialExecutor
var parentTask: Task?
// ... other metadata
}When you call getCurrentExecutor(), it essentially does:
func swift_task_getCurrentExecutor() -> UnownedSerialExecutor {
let currentTask = swift_task_getCurrent() // Get current Task
return currentTask.currentExecutor // Return stored executor
}How the Current Task is Tracked
The runtime maintains a pointer to the current task in each OS thread using thread-local storage:
// In each OS thread's thread-local storage
__thread Task* _currentTask = nullptr;
// Getting current task
Task* swift_task_getCurrent() {
return _currentTask;
}So the complete chain is:
OS Thread
│
└─> _currentTask (thread-local pointer)
│
└─> Task object (heap allocated)
│
└─> currentExecutor fieldThis indirection is crucial: it allows tasks to migrate between threads while maintaining their execution context.
Executor Switching with Actors
Actors in Swift guarantee that their methods run serially on their own executor. Here’s how that works with continuations:
actor DatabaseActor {
var state: Int = 0
func save(data: Data) async {
// Runs on DatabaseActor's executor
let result = await networkRequest(data)
// Automatically back on DatabaseActor's executor!
self.state += 1 // Safe - isolated to actor
}
}The flow:
-
Before
await networkRequest():- Currently on DatabaseActor’s executor
Task.currentExecutor = DatabaseActor.executor- Continuation captures this executor as
targetExecutor
-
networkRequest()executes:- May run on a completely different executor
- Might complete on a background thread
-
When
networkRequest()completes:- Calls
continuation.resume(returning: result) - Runtime checks: am I on the target executor?
- If not, enqueues the resume operation on DatabaseActor’s executor
- Calls
-
Resume executes:
- Now back on DatabaseActor’s executor
Task.currentExecutoris updated- Actor isolation is restored
- Safe to access
self.state
Setting the Executor
The executor is set or changed at specific points:
1. Task Creation:
// Inherits current executor
Task {
// currentExecutor = parent's executor
await doWork()
}
// Gets new executor from thread pool
Task.detached {
// currentExecutor = cooperative thread pool executor
await doWork()
}
// Explicitly on main executor
Task { @MainActor in
// currentExecutor = MainActor's executor (main queue)
await updateUI()
}2. Entering Actor-Isolated Context:
When you call an actor method, the runtime switches executors:
let actor = MyActor()
await actor.doSomething() // ← Executor switch happens hereInternally:
func callActorMethod(actor: MyActor, method: () async -> Void) async {
let currentTask = swift_task_getCurrent()
// Switch to actor's executor
currentTask.currentExecutor = actor.unownedExecutor
// Enqueue the method call on actor's executor
actor.unownedExecutor.enqueue {
method() // Execute on actor's executor
}
}3. Resuming from Continuation:
When a continuation resumes, it restores the executor:
func resume<T>(continuation: Continuation<T>, with value: T) {
let targetExecutor = continuation.targetExecutor
targetExecutor.enqueue {
let currentTask = swift_task_getCurrent()
currentTask.currentExecutor = targetExecutor // ← Restored here!
continuation.resumeFunction(continuation.frame, value)
}
}Complete Example: Tracing Executor Changes
actor DatabaseActor {
func save() async {
print("A: \(getCurrentExecutor())") // DatabaseActor's executor
await Task {
print("B: \(getCurrentExecutor())") // Inherited from parent
}.value
print("C: \(getCurrentExecutor())") // DatabaseActor's executor
await Task.detached {
print("D: \(getCurrentExecutor())") // New executor
}.value
print("E: \(getCurrentExecutor())") // DatabaseActor's executor
}
}What prints:
- A: DatabaseActor’s executor (we’re in an actor method)
- B: DatabaseActor’s executor (child Task inherits parent’s executor)
- C: DatabaseActor’s executor (continuation restored it after await)
- D: DefaultExecutor (detached task gets new executor)
- E: DatabaseActor’s executor (continuation restored it again)
Performance Optimizations
Swift has optimizations to avoid unnecessary overhead:
Inline Frames
Swift can optimize away frame allocation for simple cases:
func simple() async -> Int {
return 42 // No suspension - might not allocate frame
}
func alsoSimple() async -> Int {
let x = await simple() // Might inline simple's "frame"
return x
}If the compiler can prove an async function won’t actually suspend, it can optimize away the frame allocation entirely. This is similar to function inlining but for async operations.
Frame Reuse
For async functions called in a loop, Swift can reuse async frames instead of allocating new ones each iteration:
func processItems(_ items: [Item]) async {
for item in items {
await process(item) // Frame can be reused across iterations
}
}Practical Implications
Understanding these internals helps you write better async code:
1. Local Variables and Captures
Variables that cross await boundaries are stored in the async frame:
func processData() async {
let largeObject = LargeObject() // Allocated
await Task.sleep(nanoseconds: 1_000_000_000)
// largeObject kept alive in async frame
print(largeObject)
}The largeObject is kept alive for the entire duration. If you don’t need it after the await, consider scoping it:
func processData() async {
do {
let largeObject = LargeObject()
processSync(largeObject)
} // largeObject can be deallocated
await Task.sleep(nanoseconds: 1_000_000_000)
print("Done")
}2. Continuation Safety
Never resume a continuation more than once:
// WRONG - potential double resume
func dangerous() async -> Int {
return await withUnsafeContinuation { continuation in
performAsync { result in
continuation.resume(returning: result)
}
continuation.resume(returning: 0) // ← BUG!
}
}Use checked continuations during development to catch these errors.
3. Executor Awareness
Be aware of executor hops:
actor MyActor {
func process() async {
let data = await fetchData() // Executor hop out
// Executor hop back to MyActor
await saveData(data) // Executor hop out
// Executor hop back to MyActor
}
}Each await potentially involves executor switching. For performance-critical code, consider batching operations.
4. Avoiding Unnecessary Suspensions
If an operation might complete synchronously, you can avoid suspension overhead:
func getCachedOrFetch(key: String) async -> Value {
// Check cache first - no suspension needed
if let cached = cache[key] {
return cached
}
// Only suspend if we need to fetch
return await fetchFromNetwork(key)
}Conclusion
Async/await may look like magic, but it’s built on elegant compiler transformations and runtime support:
- State machines transform your sequential code into resumable operations
- Continuations package up “what comes next” so execution can resume later
- Async frames preserve local state across suspension points
- Executor management ensures code runs in the right context
Understanding these internals helps you:
- Write more efficient async code
- Debug async issues more effectively
- Make better architectural decisions
- Appreciate the engineering behind Swift’s concurrency model
The beauty of async/await is that while the internals are complex, the programming model is simple—you write code that looks synchronous but gets all the benefits of non-blocking I/O.
Want to dive deeper? Check out the Swift Concurrency Manifesto and Swift Evolution proposals SE-0296 for more detailed technical discussions.