Chapter 15 — Approachable concurrency (Swift 6.2)
These features require Swift 6.2 (Xcode 26). Everything in Parts I–IV works without them; this chapter is about how 6.2 makes that same model dramatically friendlier to reach.
The problem: concurrency you didn't ask for
@Observable
final class ViewModel { // NOT annotated — so it's nonisolated
var items: [Item] = []
func load() async {
let fetched = await api.fetch() // api result crosses back to... where?
items = fetched // ⚠️ isolation errors, Sendable errors
}
}
Change 1: default actor isolation → MainActor
// With Default Actor Isolation = MainActor, this is implicitly @MainActor:
@Observable
final class ViewModel { // implicitly @MainActor now
var items: [Item] = []
func load() async {
let fetched = await api.fetch()
items = fetched // ✅ just works — we're on the main actor
}
}
flowchart TB
subgraph OLD__Swift_6_0_default__nonisolated___ [\"Swift 6.0 default (nonisolated)\"]
O["unannotated code = nonisolated"] --> OE["beginners hit Sendable/isolation
errors on simple code"] end subgraph NEW__Swift_6_2__Default_Actor_Isolation___MainActor___ [\"Swift 6.2 (Default Actor Isolation = MainActor)\"] N["unannotated code = @MainActor"] --> NE["simple code stays on main:
no boundaries, no errors"] NE --> NO["opt INTO concurrency
when you want it"] end
errors on simple code"] end subgraph NEW__Swift_6_2__Default_Actor_Isolation___MainActor___ [\"Swift 6.2 (Default Actor Isolation = MainActor)\"] N["unannotated code = @MainActor"] --> NE["simple code stays on main:
no boundaries, no errors"] NE --> NO["opt INTO concurrency
when you want it"] end
Change 2: nonisolated async runs on the caller's executor
struct S: Sendable {
func performSync() {}
// In Swift 6.2, `nonisolated(nonsending)` is the DEFAULT for async:
func performAsync() async {} // runs on the caller's executor
@concurrent
func alwaysSwitch() async {} // opts OUT: always runs on the global pool
}
actor MyActor {
let s = S()
func call() async {
s.performSync() // on the actor's executor
await s.performAsync() // ALSO on the actor's executor (stays put)
await s.alwaysSwitch() // switches to the global generic executor
}
}
Change 3: @concurrent to opt into offloading
@concurrent
func processLargeImage(_ data: Data) async -> UIImage {
// Runs on the global concurrent executor, OFF the caller's actor —
// so heavy CPU work doesn't block the main actor.
…
}
flowchart LR
Q{"Do I want this work
off the current actor?"} Q -->|"No (most code)"| STAY["default: runs on caller's executor
(no boundary, no Sendable friction)"] Q -->|"Yes (heavy/parallel)"| CONC["@concurrent
(explicit offload to global pool)"]
off the current actor?"} Q -->|"No (most code)"| STAY["default: runs on caller's executor
(no boundary, no Sendable friction)"] Q -->|"Yes (heavy/parallel)"| CONC["@concurrent
(explicit offload to global pool)"]
How this reshapes the earlier advice
- You'll annotate
@MainActorfar less, because it's the default. The view models in Chapter 11 are - You'll hit fewer
Sendableerrors on ordinary code, because ordinary code stays in one domain (the - Concurrency becomes explicit and visible. When you do want parallelism, you write
@concurrent,
A caveat for library authors: if you're writing a framework (not an app), main-actor-by-default is often not what you want — libraries should usually stay explicitly
nonisolatedso callers on any actor can use them. Default-main-actor isolation is primarily an application setting. Know which kind of code you're writing.
Adopting it
What we built in this chapter
- Diagnosed the Swift 6.0 pain point:
nonisolated-by-default forced beginners intoSendableand - Default Actor Isolation =
MainActor(the "Approachable Concurrency" setting): unannotated code is nonisolated(nonsending)by default (SE-0461): nonisolated async functions now run on the@concurrent: the explicit opt-in to run work off the current actor on the global pool — the- How this reshapes app-code advice (annotate
@MainActorless, hit fewerSendableerrors,
Mental model to take away
- Swift 6.2 flips the default posture from "everything is concurrent unless isolated" to
- Nonisolated async now stays on the caller's executor; you opt into offloading with
@concurrent - For apps, enable Approachable Concurrency for a gentle on-ramp: write simple main-actor code and
- None of Parts I–III changes — actors,
Sendable, structured concurrency are identical. 6.2 just moves