Advanced Concurrency: async let, TaskGroup & Actors
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 .
Learn Advanced Concurrency: async let, TaskGroup & Actors in our free Swift course — a beginner-friendly interactive lesson with worked examples, a practice…
Part of the free Swift course at LearnCodingFast — hands-on lessons with examples you run in your browser, plus practice exercises and a quick quiz.
What You'll Learn in This Lesson
- • Run independent work concurrently with async let
- • Spawn a dynamic number of tasks using withTaskGroup
- • Prevent data races with actors and actor isolation
- • Keep UI work on the main thread with @MainActor
- • Understand Sendable and safe sharing across tasks
- • Appreciate structured concurrency and scoped task lifetimes
📊 Concurrency Tools
Tool
Use it for
async let
A few parallel tasks known up front
withTaskGroup
A dynamic number of parallel tasks
actor
Protecting mutable shared state
@MainActor
Forcing code onto the main thread
Sendable
Safe 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.
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.
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.
Now you try. Add the keyword cross-actor calls need.
Pro Tips
- 💡 Only parallelise independent work. If B needs A's result, a plain sequential await is correct.
- 💡 Mark view models @MainActor so every state change is main-thread safe.
- 💡 Prefer actors over locks. They give race-free state with no manual mutex code.
- 💡 Let the compiler help. Sendable warnings flag unsafe sharing before it bites at runtime.
Common Errors (and the fix)
- "Expression is 'async' but is not marked with 'await'" — cross-actor access needs await .
- No parallelism observed — you awaited each async let immediately; await only when you need the value.
- "Main actor-isolated property can not be referenced" — hop onto the main actor with @MainActor or await MainActor.run .
- Sendable warning — you passed a non-Sendable reference type into a task; make it immutable or an actor.
📋 Quick Reference
Goal
Code
Notes
Parallel
async let a = work()
starts now
Join
await a
get result
Many tasks
withTaskGroup(of:Int.self)
dynamic count
Safe state
actor Counter { … }
no races
Main thread
UI-safe
Shareable
struct X: Sendable
cross-boundary
Frequently Asked Questions
Mini-Challenge: Parallel Sizes
Fetch two simulated sizes in parallel and sum them, then try the task-group version. Compare with the comments.
🎉 Lesson Complete!
- ✅ async let runs a fixed set of tasks in parallel
- ✅ withTaskGroup handles a dynamic number of tasks
- ✅ Actors serialize state access and stop data races
- ✅ @MainActor keeps UI work on the main thread
- ✅ Sendable marks types safe to cross concurrency boundaries
- ✅ Next lesson: Networking with URLSession & async/await
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.