Concurrency in Swift: async/await, Tasks & Actors

iOS Development — Architecture & Data

Chapter 2 · Concurrency in Swift: async/await, Tasks & Actors

Fundamentals Chapter 9 covered async/await just enough to fetch one real thing. A ViewModel doing genuine work needs more: running several async operations at once, protecting shared mutable state from real data races, and guaranteeing UI-touching code actually runs on the main thread.

Unstructured Work: Task {}

Fundamentals' own .task {} view modifier is tied directly to a view's own lifecycle. A ViewModel method — not itself a view — needs a different real starting point: Task { }, which launches a genuine, independent unit of async work immediately, not bound to any one view's own appear/disappear cycle.

func refresh() { Task { do { tasks = try await fetchTasksFromServer() } catch { print("Refresh failed: \(error)") } } }
A Real, Genuine Trade-Off
Unlike .task {}, a plain Task { } isn't automatically cancelled by anything — Fundamentals Chapter 9's own real safety guarantee doesn't apply here for free. A long-lived ViewModel method that starts background work this way is genuinely responsible for its own cancellation if that's ever needed (Course 3's own deployment chapters return to this).

Structured, Parallel Work: async let

await-ing two calls back to back runs them sequentially — the second doesn't start until the first finishes. async let is real, genuine structured concurrency: both operations start immediately, running in parallel.

func loadDashboard() async throws -> (tasks: [Task], quote: Quote) { async let tasks = fetchTasksFromServer() async let quote = fetchQuote() return try await (tasks, quote) // waits for both, but they ran in parallel }
Real, Automatic Structure
Both child operations are genuinely tied to the surrounding function's own lifetime — if loadDashboard() is cancelled while both are still running, real, automatic cancellation propagates to both, with no manual bookkeeping needed. This is what "structured" means in structured concurrency: child work can never outlive its own parent scope.

Actors: Protecting Mutable State from Real Data Races

Introduced alongside async/await in Swift 5.5, an actor is a real reference type — like a class — that the compiler guarantees only ever executes one piece of its own code at a time, even when called from multiple concurrent tasks at once.

actor RequestCounter { private(set) var count = 0 func increment() { count += 1 } }
The Real Problem This Solves
A plain class with a shared, mutable var count — called concurrently from several real tasks at once — genuinely risks a data race: two increments reading the same starting value before either writes back, silently losing an update. An actor's own serialized access makes that specific class of bug structurally impossible, enforced by the compiler, not just careful coding.

Calling an actor's own method or property from outside the actor requires await, even though nothing about increment() itself looks asynchronous — the await here reflects real, potential waiting for the actor's own serialized turn, not network latency.

@MainActor: Guaranteeing the Main Thread

UIKit and SwiftUI both genuinely require UI updates to happen on the main thread — a real, hard rule, not a suggestion. @MainActor is Swift's own real, compiler-enforced way of guaranteeing that.

@Observable @MainActor final class TaskListViewModel { private(set) var tasks: [Task] = [] func refresh() { Task { let fetched = try? await fetchTasksFromServer() tasks = fetched ?? [] // real, guaranteed-safe: this class is @MainActor } } }
A Real, Genuine Risk Without It
A ViewModel updating @Observable state — state SwiftUI reads to redraw the UI — from a background thread is real, genuinely undefined behavior: at best a visual glitch, at worst a crash. Marking a UI-facing ViewModel @MainActor makes the compiler itself enforce that every one of its own property writes happens on the main thread, closing off that entire class of bug at compile time rather than leaving it to be discovered at runtime.

Hands-On Exercises

Exercise 1

Write a function loadProfile() using async let to fetch a user's real name and avatar image URL in parallel (two separate, imagined async functions), then await both together and return a combined result.

📄 View solution
Exercise 2

Define a real actor called DownloadTracker with a private(set) var activeDownloads = 0 and two methods, start() and finish(), incrementing and decrementing it. Explain, in your own words, why calling tracker.activeDownloads from outside the actor requires await.

📄 View solution
Exercise 3

Explain, in your own words, why marking a ViewModel @MainActor is specifically about where code runs, while marking a type an actor is specifically about serializing access to shared state — and why a typical SwiftUI ViewModel usually needs the former rather than the latter.

📄 View solution

Chapter 2 Quick Reference

  • Task { } — unstructured async work, not tied to a view's lifecycle, and not automatically cancelled the way .task {} is
  • async let — real, structured, parallel concurrency; child work can't outlive its own parent scope
  • actor (Swift 5.5, 2021) — a reference type with compiler-enforced serialized access, preventing real data races on shared mutable state
  • Calling an actor's own members from outside it requires await, reflecting a real wait for serialized access, not necessarily network latency
  • @MainActor — compiler-enforced guarantee that a type's own code runs on the main thread, essential for any ViewModel updating UI-facing state