So far we've talked about when code runs (async/await) and how to organize it (Tasks). Now: where does it run, and how do we keep it safe?
Most apps just wait
Most app code is I/O-bound. You fetch data from a network, await a response, decode it, and display it. If you have multiple I/O operations to coordinate, you resort to tasks and task groups. The actual CPU work is minimal. The main thread can handle this fine because await suspends without blocking.
But sooner or later, you'll have CPU-bound work: parsing a giant JSON file, processing images, running complex calculations. This work doesn't wait for anything external. It just needs CPU cycles. If you run it on the main thread, your UI freezes. This is where "where does code run" actually matters.
The Old World: Many Options, No Safety
Before Swift's concurrency system, you had several ways to manage execution:
| Approach | What it does | Tradeoffs |
|---|---|---|
| Thread | Direct thread control | Low-level, error-prone, rarely needed |
| GCD | Dispatch queues with closures | Simple but no cancellation, easy to cause thread explosion |
| OperationQueue | Task dependencies, cancellation, KVO | More control but verbose and heavyweight |
| Combine | Reactive streams | Great for event streams, steep learning curve |
All of these worked, but safety was entirely on you. The compiler couldn't help if you forgot to dispatch to main, or if two queues accessed the same data simultaneously.
The Problem: Data Races
A data race happens when two threads access the same memory at the same time, and at least one is writing:
var count = 0
DispatchQueue.global().async { count += 1 }
DispatchQueue.global().async { count += 1 }
Data races are undefined behavior. They can crash, corrupt memory, or silently produce wrong results. Your app works fine in testing, then crashes randomly in production. Traditional tools like locks and semaphores help, but they're manual and error-prone.
Concurrency amplifies the problem
The more concurrent your app is, the more likely data races become. A simple iOS app might get away with sloppy thread safety. A web server handling thousands of simultaneous requests will crash constantly. This is why Swift's compile-time safety matters most in high-concurrency environments.
The Shift: From Threads to Isolation
Swift's concurrency model asks a different question. Instead of "which thread should this run on?", it asks: "who is allowed to access this data?"
This is isolation. Rather than manually dispatching work to threads, you declare boundaries around data. The compiler enforces these boundaries at build time, not runtime.
Under the hood
Swift Concurrency is built on top of libdispatch (the same runtime as GCD). The difference is the compile-time layer: actors and isolation are enforced by the compiler, while the runtime handles scheduling on a cooperative thread pool limited to your CPU's core count.
The Three Isolation Domains
1. MainActor
@MainActor is a global actor that represents the main thread's isolation domain. It's special because UI frameworks (UIKit, AppKit, SwiftUI) require main thread access.
@MainActor
class ViewModel {
var items: [Item] = []
}
When you mark something @MainActor, you're not saying "dispatch this to the main thread." You're saying "this belongs to the main actor's isolation domain." The compiler enforces that anything accessing it must either be on MainActor or await to cross the boundary.
When in doubt, use @MainActor
For most apps, marking your ViewModels with @MainActor is the right choice. Performance concerns are usually overblown. Start here, optimize only if you measure actual problems.
2. Actors
An actor protects its own mutable state. It guarantees that only one piece of code can access its data at a time:
actor BankAccount {
var balance: Double = 0
func deposit(_ amount: Double) {
balance += amount
}
}
await account.deposit(100)
Actors are not threads. An actor is an isolation boundary. The Swift runtime decides which thread actually executes actor code. You don't control that, and you don't need to.
3. Nonisolated
Code marked nonisolated opts out of actor isolation. It can be called from anywhere without await, but it cannot access the actor's protected state:
actor BankAccount {
var balance: Double = 0
nonisolated func bankName() -> String {
"Acme Bank"
}
}
let name = account.bankName()
Approachable Concurrency: Less Friction
Approachable Concurrency simplifies the mental model with two Xcode build settings:
SWIFT_DEFAULT_ACTOR_ISOLATION=MainActor: Everything runs on MainActor unless you say otherwiseSWIFT_APPROACHABLE_CONCURRENCY=YES:nonisolatedasync functions stay on the caller's actor instead of jumping to a background thread
New Xcode 26 projects have both enabled by default. When you need CPU-intensive work off the main thread, use @concurrent.
// Runs on MainActor (the default)
func updateUI() async { }
// Runs on background thread (opt-in)
@concurrent func processLargeFile() async { }
The Office Building
Think of your app as an office building. Each isolation domain is a private office with a lock on the door. Only one person can be inside at a time, working with the documents in that office.
MainActoris the front desk - where all customer interactions happen. There's only one, and it handles everything the user sees.actortypes are department offices - Accounting, Legal, HR. Each protects its own sensitive documents.nonisolatedcode is the hallway - shared space anyone can walk through, but no private documents live there.
You can't just barge into someone's office. You knock (await) and wait for them to let you in.