How to use async / await keywords in Swift | Modern Swift Concurrency #3
With GCD, the code that describes what you want to do is split across multiple nested closures and queue dispatches — following the actual execution order requires reading inside-out. async/await puts that execution order back into a linear sequence that reads top-to-bottom, while the Swift runtime handles thread management automatically.
What You'll Learn
- How
asyncmarks a function as suspendable andawaitmarks the exact points where suspension can occur - Why
awaitdoes not block a thread — what "cooperative suspension" means and how it differs from locking or sleeping - How
Thread.currentin the sample output reveals that Swift moves your code between threads transparently across suspension points
Mental Model
Imagine you're a contractor with multiple job sites. With GCD, you physically drive to each site in sequence, wait on-site until each task is done, then drive to the next — one dispatcher queue, one closure at a time. With async/await, you're more like a project manager with a radio. You call a team, ask them to start a task, and tell them to radio you when they're done (await). While you wait, you manage other jobs — you're never idle. When the radio crackles with "done," you pick up right where you left off on that job site.
The Thread.current output in the sample makes this concrete: the same logical function may resume on a different thread after an await. This is fine and expected. What matters is that you're never on two threads at the same time for the same work, and you never block a thread while waiting.
Detailed Explanation
The async keyword in a function signature is a promise to the Swift compiler: "this function may suspend during execution." A suspended function yields the thread it was running on back to Swift's cooperative thread pool, which can immediately use that thread for other work. When the awaited operation completes, the runtime resumes the function — potentially on a different thread. From your code's perspective, this is invisible: execution continues on the next line as if nothing happened.
await is the call-site marker for potential suspension. Every await is a visible suspension point — the reader knows "something asynchronous is happening here." This is intentional: Swift's designers wanted suspension to be visible in the source code, unlike implicit suspension in some other async systems. You can reason about your function's flow by looking for await markers.
Comparing the GCD functions (addTitle1, addTitle2) to the async functions (addAuthor1, addSomething) reveals the difference clearly. In addTitle2, the code that runs on the background queue and the code that runs on the main queue are written in separate nested closures — you have to mentally track which closure runs where. In addAuthor1, the code before Task.sleep runs in the ambient context (main actor, since the view model isn't explicitly off-main), and the code after runs in MainActor.run — the structure is flat and the thread hops are explicit.
Task.sleep(nanoseconds:) is the async equivalent of Thread.sleep(). The critical difference: Thread.sleep() blocks the thread entirely — no other work can run on that thread. Task.sleep(nanoseconds:) suspends the task but frees the thread, so the cooperative thread pool can run other tasks during the sleep. This is why async/await scales better than GCD for concurrent workloads.
MainActor.run(body:) is the async/await equivalent of DispatchQueue.main.async { }. It schedules the closure to run on the main actor (main thread) and, because it is itself an async function, it can be awaited. This means you can write UI updates inline in the same function as your background work without nesting closures.
The commented-out addTitle1() and addTitle2() calls in the view demonstrate the old GCD approach. The live .onAppear code shows the same logical sequence using async/await: addAuthor1() runs, then addSomething() runs sequentially after it, then a final string is appended. Each await in the Task body makes the sequential ordering explicit.
Code Structure
AsyncAwaitBootcamp.swift contains AsyncAwaitBootcampViewModel with four functions: two using GCD (addTitle1, addTitle2) and two using async/await (addAuthor1, addSomething). The view builds a list from the growing dataArray and starts the async chain in .onAppear. The Thread.current prints in each function let you observe which thread is executing at each step.
Complete Code
AsyncAwaitBootcamp.swift
import SwiftUI
class AsyncAwaitBootcampViewModel: ObservableObject {
@Published var dataArray: [String] = []
// GCD approach: synchronous function, schedules work on the main queue after 2 seconds
func addTitle1() {
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
self.dataArray.append("Title1 : \(Thread.current)") // runs on main thread after 2s delay
}
}
// GCD approach: hops to a background queue, builds the string, then hops back to main to update UI
func addTitle2() {
DispatchQueue.global().asyncAfter(deadline: .now() + 2) {
let title = "Title2 : \(Thread.current)" // captures Thread.current on the background queue
DispatchQueue.main.async {
self.dataArray.append(title) // safe: now on main thread for UI update
let title3 = "Title3 : \(Thread.current)" // Thread.current is now the main thread
self.dataArray.append(title3)
}
}
}
// async/await approach: runs synchronously up to the first await, then suspends and frees the thread
func addAuthor1() async {
let author1 = "Author1 : \(Thread.current)" // captured before any suspension — same thread as the caller
self.dataArray.append(author1)
try? await Task.sleep(nanoseconds: 2_000_000_000) // suspends this task for 2 seconds; thread is freed for other work
let author2 = "Author2 : \(Thread.current)" // may be a different thread after resuming from suspension
await MainActor.run(body: {
self.dataArray.append(author2) // explicitly hop to main actor before mutating @Published state
let author3 = "Author3 : \(Thread.current)" // Thread.current here is the main thread
self.dataArray.append(author3)
})
}
func addSomething() async {
try? await Task.sleep(nanoseconds: 2_000_000_000) // suspends for 2 seconds; addAuthor1 must finish before this runs
let something1 = "Something1 : \(Thread.current)"
await MainActor.run(body: {
self.dataArray.append(something1) // hop to main for UI update
let something2 = "Something2 : \(Thread.current)"
self.dataArray.append(something2)
})
}
}
struct AsyncAwaitBootcamp: View {
@StateObject private var viewModel = AsyncAwaitBootcampViewModel()
var body: some View {
List {
ForEach(viewModel.dataArray, id: \.self) { data in
Text(data)
}
}
.onAppear {
Task { // bridge from synchronous onAppear into async context
await viewModel.addAuthor1() // awaits completion: Author1, Author2, Author3 are appended, then this returns
await viewModel.addSomething() // only starts after addAuthor1 fully completes
let finalText = "FINAL TEXT : \(Thread.current)"
viewModel.dataArray.append(finalText) // appended only after both async functions complete
}
// viewModel.addTitle1()
// viewModel.addTitle2()
}
}
}
struct AsyncAwaitBootcamp_Previews: PreviewProvider {
static var previews: some View {
AsyncAwaitBootcamp()
}
}Code Walkthrough
GCD vs async — the structural difference —
addTitle2uses two nested closures (DispatchQueue.global().asyncAftercontainingDispatchQueue.main.async). You must read from the outside in to understand execution order.addAuthor1is flat: read top-to-bottom and you understand the sequence. Both accomplish the same logical thing — background work then UI update — but the readability difference compounds as code grows.addAuthor1()— tracing suspension — Execution reachestry? await Task.sleep(...)and suspends. The thread is freed. Two seconds later, the runtime resumesaddAuthor1— likely on a different thread from the cooperative pool. Theawait MainActor.runthen hops execution to the main thread for the@Publishedmutation. Run this and printThread.currentat each step:Author1is typically on the main thread (because the parentTaskinherits the@MainActorcontext ofAsyncAwaitBootcamp), andAuthor2may or may not be.Sequential awaits in
Task { }— Inside theTaskbody,await viewModel.addAuthor1()followed byawait viewModel.addSomething()execute sequentially.addSomethingdoes not start untiladdAuthor1returns. This is structurally equivalent to chaining callbacks, but without the nesting. The total elapsed time beforeFINAL TEXTis appended is 4 seconds (2s + 2s), not 2s, because the awaits are sequential, not concurrent. For concurrent execution, see lesson 5 onasync let.Thread.currentin the output — The printed thread descriptions reveal when Swift hops threads. Notice that code written afterawait MainActor.run { }may continue on the main thread or may resume on a pool thread depending on context. The important point: you don't control which thread resumes your code after a suspension point, and you don't need to — the actor system ensures correctness regardless.Task.sleepvsThread.sleep— Both sleep for 2 seconds, butTask.sleep(nanoseconds:)is a cooperative suspension — it suspends the task and returns the thread to the pool.Thread.sleepis a blocking call that holds the thread hostage for the duration. In a UI app with a finite thread pool, usingThread.sleepor other blocking calls insideasyncfunctions can starve the pool and cause unresponsive UI.
Common Mistakes
Mistake: Calling a blocking API (Thread.sleep, semaphore.wait, synchronous file I/O) inside an async function An async function runs on Swift's cooperative thread pool, which has a limited number of threads. Blocking one thread inside an async context doesn't just affect your task — it removes a thread from the pool, potentially causing other async tasks to stall. Always use async equivalents: Task.sleep instead of Thread.sleep, URLSession.data(from:) instead of URLSession.dataTask.
Mistake: Assuming code after await runs on the same thread as code before await After a suspension point, Swift may resume your function on any available thread in the cooperative pool. Code that relies on thread-local storage or thread identity after an await will behave unpredictably. Actor isolation (not thread identity) is the correct tool for ensuring code runs in a specific context.
Mistake: Starting async work directly in a Task in body rather than in onAppear or .task Creating a Task directly inside a SwiftUI body property creates a new task every time the view re-evaluates — which can happen many times per second. Each evaluation launches an additional parallel download or operation. Always start async work in response to lifecycle events (.task, onAppear) or user actions, not in body.
Key Takeaways
- Every
awaitis a potential suspension point where the task yields its thread — the runtime may resume the task on a different thread, which is safe and expected Task.sleep(nanoseconds:)suspends cooperatively (frees the thread);Thread.sleepblocks the thread — never useThread.sleepinside async code- Sequential
awaitcalls in aTaskbody execute one-after-another and their total time is additive; for concurrent execution of independent work, useasync let(lesson 5) orTaskGroup(lesson 6)
Last updated: June 27, 2026