Skip to content
advanced Phase 1 · Swift Foundations

Async/Await Concurrency

Use Swift concurrency: async/await, Task, structured concurrency, actors, and async streams.

1h
4 problems
Topic Progress 0%

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?

Question 1 options

2. What is an actor in Swift?

Question 2 options

3. What does `@MainActor` ensure?

Question 3 options

4. What is the difference between Task and Task.detached?

Question 4 options

5. What is `Sendable`?

Question 5 options

Flashcards

Question

What does `async` mean on a function?

Answer

The function can be suspended and resumed later, enabling non-blocking asynchronous operations.

Question

What is structured concurrency?

Answer

A model where child tasks complete before their parent, ensuring predictable error propagation and cleanup.

Question

What is an actor's main purpose?

Answer

To protect mutable state by serializing access—only one task can access the actor's properties at a time.

Question

What is AsyncStream?

Answer

An asynchronous sequence that yields values over time, useful for streaming data from callbacks or sensors.

Question

What is a Sendable type?

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()
}