An iPhone app has one special thread, the main thread, which draws the screen and handles taps. If you do slow work on it, such as waiting for a web server, the app freezes and the user thinks it has crashed. Apps must do slow work *off to the side* and come back to the main thread when the result is ready. Swift has a modern, readable way to write this, called Swift Concurrency. You will use it for networking in lesson 11 and for saving data after that.
Before async/await
In older code, slow work took a completion handler, a closure called later. Several in a row became a pyramid of nested braces that was hard to read and easy to get wrong. async and await let you write the same steps as ordinary code that reads from top to bottom.
async and await
- A function marked `async` may need to wait. While it waits, the thread is free to do other work.
- You call it with `await`. The word marks a *possible pause*. Your code stops at that line, and continues when the answer is ready. Meanwhile, the screen keeps working.
- A function that is
async throwscombines this with lesson 5. You call it withtry await.
struct ExpenseService {
func loadExpenses() async throws -> [Expense] {
// pretend to wait for a server
try await Task.sleep(for: .seconds(1))
return [
Expense(title: "Coffee", amount: 3.5),
Expense(title: "Train", amount: 12)
]
}
}
Task.sleep is a stand-in for a real wait. In lesson 11 it becomes a real network call. The shape of the code stays the same, so learning it now with a fake delay is useful.
Starting async work: the task modifier and Task
You cannot call an async function from ordinary code, because ordinary code cannot pause. You start a new task instead. In SwiftUI the best way for work that belongs to a screen is the .task modifier. It starts when the view appears and is cancelled automatically when the view goes away.
struct LoadingListView: View {
@State private var expenses: [Expense] = []
@State private var isLoading = false
@State private var errorMessage: String?
private let service = ExpenseService()
var body: some View {
List {
if let errorMessage {
Text(errorMessage).foregroundStyle(.red)
}
ForEach(expenses) { Text($0.title) }
}
.overlay {
if isLoading { ProgressView() }
}
.task {
isLoading = true
do {
expenses = try await service.loadExpenses()
} catch {
errorMessage = "Could not load. Please try again."
}
isLoading = false
}
}
}
This is the pattern behind most screens in real apps: show a spinner, wait for the data, then show the result or a friendly error. Notice that the code reads in order, with no nested closures. When you want a task from a button tap or other ordinary code, use Task { ... }.
Button("Refresh") {
Task {
isLoading = true
expenses = (try? await service.loadExpenses()) ?? []
isLoading = false
}
}
Doing several things at once
If two calls do not depend on each other, start them together with async let, then await both. The total time is about the time of the slowest one, not the sum.
func loadDashboard() async throws -> (expenses: [Expense], budget: Double) {
async let list = service.loadExpenses()
async let budget = fetchBudget()
return try await (list, budget)
}
For a variable number of calls, such as one per item in an array, Swift has task groups. They are an advanced topic. It is enough now to know that they exist.
Actors and the main actor
When many tasks run at once and touch the same data, two of them can change it at the same moment and corrupt it. This is a data race. Swift's answer is the actor. An actor is like a class that handles one request at a time, so its data is safe. You call into an actor with await, because you may have to wait your turn.
actor ExpenseCache {
private var items: [UUID: Expense] = [:]
func store(_ expense: Expense) { items[expense.id] = expense }
func find(_ id: UUID) -> Expense? { items[id] }
}
let cache = ExpenseCache()
await cache.store(Expense(title: "Tea", amount: 2))
The main actor, written @MainActor, is a special actor that stands for the main thread. Code that changes the screen has to run there. Views and anything they own are on the main actor already. When you write your own class that drives a screen, as in lesson 13, mark it @MainActor so the compiler checks this for you.
Common mistakes
- Doing slow work without
async, which freezes the screen. - Changing the screen from the wrong thread. Mark view-driving classes
@MainActor. - Starting a
TaskinonAppearthat never stops. Prefer.task, which cancels itself. - Forgetting that after an
await, other things may have changed. Do not assume the screen is as you left it.
Your task
- Type the
ExpenseServiceandLoadingListViewand run them. Watch the spinner for one second and then the list. - Make
loadExpensesthrow an error on purpose and check that your message shows. - Add a Refresh button that uses
Task { ... }. - Write the
ExpenseCacheactor and call it from aTask. Notice whyawaitis needed.
How to check it worked
- The list appears about a second after the screen opens, and the screen does not freeze meanwhile.
- When you force an error, the red message shows and the spinner stops.
- You can say what
awaitmarks and why an actor needs it.