async/await Fundamentals
async/await Fundamentals
Swift's concurrency model uses async/await to write asynchronous code that reads like synchronous code. It replaces callback-based patterns and reduces complexity.
Defining Async Functions
An async function can be suspended and resumed later:
func fetchUser(id: String) async throws -> User {
let url = URL(string: "https://api.example.com/users/\(id)")!
let (data, _) = try await URLSession.shared.data(from: url)
return try JSONDecoder().decode(User.self, from: data)
}
The async keyword marks the function as asynchronous. The await keyword marks suspension points where the function can yield control.
Calling Async Functions
Use await to call async functions:
func loadProfile() async {
do {
let user = try await fetchUser(id: "123")
print("Name: \(user.name)")
} catch {
print("Failed: \(error)")
}
}
Async Properties
struct DataStore {
var cachedUsers: [String: User] {
get async {
// async computed property
return await loadFromCache()
}
}
}
Async Sequences
Use for await to iterate over asynchronous sequences:
func streamNumbers() -> AsyncStream<Int> {
AsyncStream { continuation in
Task {
for i in 1...5 {
continuation.yield(i)
try? await Task.sleep(for: .seconds(1))
}
continuation.finish()
}
}
}
for await number in streamNumbers() {
print(number) // prints 1, 2, 3, 4, 5 with 1s delays
}
Converting Callbacks to async/await
Wrap legacy callback APIs:
func fetchData(from url: URL) async throws -> Data {
try await withCheckedThrowingContinuation { continuation in
URLSession.shared.dataTask(with: url) { data, _, error in
if let error = error {
continuation.resume(throwing: error)
} else if let data = data {
continuation.resume(returning: data)
}
}.resume()
}
}
withCheckedThrowingContinuation bridges callback-based code to async/await.
Main Actor
Use @MainActor to ensure code runs on the main thread:
@MainActor
func updateUI(with user: User) {
nameLabel.text = user.name
avatarView.image = user.avatar
}
The @MainActor attribute ensures the function runs on the main thread, which is required for UIKit/SwiftUI updates.
Tasks & Structured Concurrency
Tasks & Structured Concurrency
Tasks are the fundamental unit of concurrent work in Swift. Structured concurrency ensures tasks are properly organized and errors propagate correctly.
Creating Tasks
// Fire-and-forget task
Task {
let user = try await fetchUser(id: "123")
await updateUI(with: user)
}
// Task with result
let task = Task {
try await fetchUser(id: "123")
}
let user = try await task.value
Task Groups
TaskGroup runs multiple concurrent tasks and collects their results:
func fetchUsers(ids: [String]) async throws -> [User] {
try await withThrowingTaskGroup(of: User.self) { group in
for id in ids {
group.addTask {
try await self.fetchUser(id: id)
}
}
var users: [User] = []
for try await user in group {
users.append(user)
}
return users
}
}
Task groups provide structured concurrency—all tasks complete before the group returns.
Task Cancellation
let task = Task {
while !Task.isCancelled {
// do work
try await Task.sleep(for: .seconds(1))
}
}
task.cancel() // signals cancellation
Check Task.isCancelled periodically to support cooperative cancellation.
Task Priorities
Task(priority: .high) {
// high priority work
}
Task(priority: .low) {
// low priority work
}
Continuation
For bridging callback APIs:
// Non-throwing
func fetchData() async -> Data {
await withCheckedContinuation { continuation in
legacyAPI.fetch { data in
continuation.resume(returning: data)
}
}
}
// Throwing
func fetchData() async throws -> Data {
try await withCheckedThrowingContinuation { continuation in
legacyAPI.fetch { data, error in
if let error = error {
continuation.resume(throwing: error)
} else {
continuation.resume(returning: data)
}
}
}
}
Detached Tasks
For tasks that should not inherit the current actor or task context:
Task.detached(priority: .background) {
// runs independently
let result = await heavyComputation()
}
Structured vs Unstructured
| Feature | Structured (Task {}) | Detached (Task.detached {}) |
|---|---|---|
| Inherits context | Yes | No |
| Automatic cancellation | Yes | No |
| Error propagation | To parent task | Independent |
Structured concurrency ensures all child tasks complete before the parent, making code predictable and debuggable.
Actors & Data Safety
Actors & Data Safety
Actors are reference types that protect their mutable state by ensuring only one task can access it at a time. They eliminate data races without manual locking.
Defining an Actor
actor BankAccount {
var balance: Double
let owner: String
init(owner: String, balance: Double) {
self.owner = owner
self.balance = balance
}
func deposit(_ amount: Double) {
balance += amount
}
func withdraw(_ amount: Double) -> Bool {
guard balance >= amount else { return false }
balance -= amount
return true
}
}
Accessing Actor Properties
Accessing actor properties requires await:
let account = BankAccount(owner: "Alice", balance: 1000)
await account.deposit(500) // await required
let balance = await account.balance // await required
The compiler enforces that actor isolation—only one task can access the actor's state at a time.
Sendable Types
Actor properties and method parameters must be Sendable—safe to send across concurrency domains:
struct TransferRequest: Sendable {
let from: String
let to: String
let amount: Double
}
actor Bank {
func process(_ request: TransferRequest) async {
// TransferRequest is Sendable, safe to pass
}
}
Basic types (Int, String, Bool, Array of Sendable) are Sendable. Classes are not Sendable by default.
Global Actors
@MainActor is a global actor that serializes access to the main thread:
@MainActor
class UIController {
var label: String = ""
func update(_ text: String) {
label = text // safe — @MainActor ensures main thread
}
}
Nonisolated
Mark methods as nonisolated to opt out of actor isolation:
actor Logger {
func log(_ message: String) { /* actor-isolated */ }
nonisolated func format(_ message: String) -> String {
return "[\(Date())] \(message)" // no await needed
}
}
AsyncStream
AsyncStream provides an asynchronous sequence of values:
func temperatureUpdates() -> AsyncStream<Double> {
AsyncStream { continuation in
let sensor = TemperatureSensor()
sensor.onUpdate = { temp in
continuation.yield(temp)
}
continuation.onTermination = { _ in
sensor.stop()
}
}
}
for await temp in temperatureUpdates() {
print("Temperature: \(temp)")
}
Real-World Actor Example
actor MessageStore {
private var messages: [Message] = []
private var subscribers: [String: (Message) -> Void] = [:]
func add(_ message: Message) {
messages.append(message)
notifySubscribers(message)
}
func subscribe(id: String, handler: @escaping @Sendable (Message) -> Void) {
subscribers[id] = handler
}
func unsubscribe(id: String) {
subscribers[id] = nil
}
private func notifySubscribers(_ message: Message) {
for handler in subscribers.values {
handler(message)
}
}
}
struct Message: Sendable {
let id: UUID
let text: String
let timestamp: Date
}
Actors provide compile-time safety against data races, making concurrent code reliable and maintainable.
Quiz
1. What does the `await` keyword do?
2. What is an actor in Swift?
3. What does `@MainActor` ensure?
4. What is the difference between Task and Task.detached?
5. What is `Sendable`?
Flashcards
Question
What does `async` mean on a function?
Click to reveal answer
Answer
The function can be suspended and resumed later, enabling non-blocking asynchronous operations.
Question
What is structured concurrency?
Click to reveal answer
Answer
A model where child tasks complete before their parent, ensuring predictable error propagation and cleanup.
Question
What is an actor's main purpose?
Click to reveal answer
Answer
To protect mutable state by serializing access—only one task can access the actor's properties at a time.
Question
What is AsyncStream?
Click to reveal answer
Answer
An asynchronous sequence that yields values over time, useful for streaming data from callbacks or sensors.
Question
What is a Sendable type?
Click to reveal answer
Answer
A type that is safe to send across concurrency domains—immutable or actor-isolated types are Sendable.
Revision Notes
Key Takeaways
- 1. async/await replaces callback-based async code
- 2. Tasks are the unit of concurrent work
- 3. Structured concurrency ensures child tasks complete before parents
- 4. Actors serialize access to prevent data races
- 5. Sendable marks types safe for cross-concurrency sharing
- 6. @MainActor ensures UI code runs on the main thread
Interview Tips
- • Explain the difference between structured and unstructured concurrency
- • Know when to use actors vs classes for shared state
- • Be able to convert callback code to async/await with continuations
- • Understand Sendable and why actors need it
- • Describe how TaskGroup collects results from parallel work
Cheat Sheet
Async/Await Concurrency Cheat Sheet
Basic:
func work() async throws -> Result { ... }
let result = try await work()
Task:
Task { try await fetchUser() }
let task = Task { try await heavyWork() }
let value = try await task.value
TaskGroup:
try await withThrowingTaskGroup(of: T.self) { group in
group.addTask { await work() }
for try await result in group { ... }
}
Actor:
actor Store {
var items: [Item] = []
func add(_ item: Item) { items.append(item) }
}
let store = Store()
await store.add(item)
MainActor:
@MainActor func updateUI() { ... }
AsyncStream:
AsyncStream { continuation in
continuation.yield(value)
continuation.finish()
}