Advanced Concurrency: async let, TaskGroup & Actors

Reviewed & published by Brayan K

Go beyond a single await. Run work in parallel with async let and withTaskGroup, protect shared state with actors, pin UI work to @MainActor, and let the compiler guard you with Sendable.

Part of the free Swift course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.

What You'll Learn in This Lesson

📊 Concurrency Tools

ToolUse it for
async letA few parallel tasks known up front
withTaskGroupA dynamic number of parallel tasks
actorProtecting mutable shared state
@MainActorForcing code onto the main thread
SendableSafe passing across boundaries

1️⃣ async let — Parallel Work

Each async let launches a child task right away. The tasks run side by side; you join the results with await only when you actually use them.

// async let starts work in PARALLEL. Each child task begins immediately;
// you await the results only when you need them.
func fetchUser() async -> String { "Ada" }
func fetchScore() async -> Int { 99 }

func loadProfile() async {
    async let name = fetchUser()    // starts now
    async let score = fetchScore()  // starts now, alongside name

    // Both ran concurrently; await collects their results.
    let result = await "\(name) scored \(score)"
    print(result)   // Ada scored 99
}

Your turn. Fill in the concurrency keywords.

// 🎯 YOUR TURN — replace each ___ then run it in SwiftFiddle.
func loadA() async -> Int { 10 }
func loadB() async -> Int { 20 }

func total() async {
    ___ a = loadA()        // 👉 keyword that starts a parallel child task
    ___ b = loadB()        // 👉 same keyword again
    let sum = ___ a + b    // 👉 keyword that waits for both results
    print("sum = \(sum)")
}

// ✅ Expected output:
//    sum = 30

2️⃣ withTaskGroup — Many Tasks

When the number of tasks isn't fixed, a task group lets you addTask in a loop and gather results with for await as each finishes.

// withTaskGroup runs a DYNAMIC number of child tasks concurrently.
func square(_ n: Int) async -> Int { n * n }

func squareAll(_ numbers: [Int]) async -> [Int] {
    await withTaskGroup(of: Int.self) { group in
        for n in numbers {
            group.addTask { await square(n) }   // each runs concurrently
        }
        var results: [Int] = []
        for await value in group {              // collect as they finish
            results.append(value)
        }
        return results.sorted()
    }
}

// await squareAll([1, 2, 3, 4]) -> [1, 4, 9, 16]

3️⃣ Actors — Safe Shared State

An actor serializes access to its state so only one task touches it at a time — no data races. Code inside the actor is synchronous; code outside must await. Use @MainActor to pin UI work to the main thread, and Sendable to mark types safe to share.

// An actor protects its mutable state: only one task touches it at a time,
// so there are no data races. Cross-actor access is async (needs await).
actor Counter {
    private var value = 0

    func increment() { value += 1 }   // inside the actor: synchronous
    func current() -> Int { value }
}

func run() async {
    let counter = Counter()
    await counter.increment()         // outside the actor: needs await
    await counter.increment()
    let now = await counter.current()
    print("count = \(now)")          // count = 2
}

Now you try. Add the keyword cross-actor calls need.

// 🎯 YOUR TURN — make the access compile.
actor Bank {
    private var balance = 0
    func deposit(_ amount: Int) { balance += amount }
    func total() -> Int { balance }
}

func demo() async {
    let bank = Bank()
    ___ bank.deposit(100)        // 👉 keyword needed for cross-actor calls
    let t = ___ bank.total()     // 👉 same keyword to read the result
    print("balance = \(t)")
}

// ✅ Expected output:
//    balance = 100

Pro Tips

Common Errors (and the fix)

📋 Quick Reference

GoalCodeNotes
Parallelasync let a = work()starts now
Joinawait aget result
Many taskswithTaskGroup(of:Int.self)dynamic count
Safe stateactor Counter { … }no races
Main thread@MainActorUI-safe
Shareablestruct X: Sendablecross-boundary

Mini-Challenge: Parallel Sizes

Fetch two simulated sizes in parallel and sum them, then try the task-group version. Compare with the comments.

// 🎯 MINI-CHALLENGE: Parallel image sizes (simulated)
// 1. Write an async func sizeOf(_ name: String) async -> Int that returns
//    name.count (pretend it's a download).
// 2. Use async let to fetch sizeOf("cat") and sizeOf("elephant") in parallel.
// 3. await both and print their sum.
// 4. Bonus: rewrite it with withTaskGroup over an array of names.
//
// ✅ Example output (cat=3, elephant=8):
//    total = 11

// your code here

🎉 Lesson Complete!

Practice quiz

What does 'async let' enable?

  • Sequential awaiting
  • Starting child tasks that run in parallel, awaited later
  • Cancelling tasks
  • Locking a property

Answer: Starting child tasks that run in parallel, awaited later. async let starts a child task immediately and lets you await its result later, enabling parallelism.

When do you await an async let binding?

  • Never
  • Before declaring it
  • At the point you actually use its value
  • Only inside an actor

Answer: At the point you actually use its value. You await the async let when you need its result, allowing the work to run concurrently until then.

What is withTaskGroup used for?

  • Running a dynamic number of child tasks concurrently
  • Defining a struct
  • Storing data
  • Animating views

Answer: Running a dynamic number of child tasks concurrently. A task group spawns a variable number of concurrent child tasks and collects their results.

What problem do actors solve?

  • Slow networking
  • Parsing JSON
  • Drawing UI
  • Protecting mutable state from data races

Answer: Protecting mutable state from data races. Actors serialize access to their mutable state, preventing concurrent data races.

How do you usually access an actor's properties from outside?

  • Directly and synchronously
  • With await, because access is asynchronous
  • Through a global lock
  • You cannot access them

Answer: With await, because access is asynchronous. Cross-actor access is asynchronous, so it requires await.

What does @MainActor guarantee for a function or type?

  • It runs on a background thread
  • It cannot be awaited
  • Its code runs on the main thread
  • It disables concurrency

Answer: Its code runs on the main thread. @MainActor isolates code to the main thread, ideal for UI updates.

What does the Sendable protocol indicate?

  • A type can be encoded to JSON
  • A type is a view
  • A type is an actor
  • A type is safe to pass across concurrency boundaries

Answer: A type is safe to pass across concurrency boundaries. Sendable marks types that can be safely shared between concurrent tasks.

Inside its own methods, an actor accesses its state...

  • Synchronously, without await
  • With await every time
  • Only via closures
  • Never

Answer: Synchronously, without await. Code already running on the actor accesses its own isolated state synchronously.

Child tasks created with async let are cancelled if...

  • They never start
  • The enclosing scope exits before they're awaited
  • You call await
  • The actor is deallocated

Answer: The enclosing scope exits before they're awaited. Structured child tasks are cancelled when their scope ends without awaiting them.

A key benefit of structured concurrency is...

  • Manual thread management
  • Avoiding async entirely
  • Child task lifetimes are tied to a scope, simplifying cleanup
  • Removing the need for actors

Answer: Child task lifetimes are tied to a scope, simplifying cleanup. Structured concurrency ties task lifetimes to scopes, making cancellation and cleanup predictable.

Continue this course

Frequently asked questions

How is async let different from a plain await?

A plain await runs work sequentially: the line blocks until the result arrives before the next line starts. async let starts a child task immediately and lets execution continue, so several pieces of work run in parallel. You then await the binding only when you actually need its value, which is where the results join back together.

When should I use a TaskGroup instead of async let?

Use async let for a small, fixed set of concurrent tasks known at compile time. Use withTaskGroup (or withThrowingTaskGroup) when the number of child tasks is dynamic, for example one per element of an array. The group lets you add tasks in a loop and collect their results with for await as they complete.

What exactly does an actor protect against?

An actor serializes access to its mutable state so only one task can touch that state at a time. This eliminates data races, the bugs that arise when multiple threads read and write shared mutable data simultaneously. Code inside the actor accesses its state synchronously; code outside must use await because cross-actor access is asynchronous.

What is @MainActor for?

@MainActor is a global actor that isolates code to the main thread. Annotate a type, function, or property with it to guarantee that code runs on the main thread, which is required for UI updates in SwiftUI and UIKit. Many SwiftUI view-model patterns mark the whole ObservableObject as @MainActor so all UI-facing state changes are main-thread safe.

What does Sendable mean?

Sendable is a protocol marking types that can be safely passed across concurrency boundaries, such as into another task or actor, without causing data races. Value types made of Sendable parts are usually Sendable automatically. Reference types need care: they must be immutable or internally synchronized. The compiler uses Sendable to catch unsafe sharing at compile time.