Introduction
Ever since Swift Concurrency was introduced, its main mission has been clear: keep memory safe without making us write callback hell. But if we’re being honest, context switching-specifically thread hopping-has always been a bit of a head-scratcher.
How many times have you marked an async function as nonisolated on a @MainActor class, only to watch it instantly jump off to the cooperative global pool for no obvious reason?
Swift 6.2 addresses this head-on with Approachable Concurrency and its underlying flag, NonisolatedNonsendingByDefault.
Let’s break down what actually changes under the hood, how @concurrent fits into the picture, and what this all looks like when stepping through real code.
Table of contents
Open Table of contents
What Changes with NonisolatedNonsendingByDefault
Before Swift 6.2 (or with Approachable Concurrency turned off), any nonisolated async function would immediately yield its execution to Swift’s global cooperative executor whenever you called await. That meant constant, often unnecessary thread switching.
With Approachable Concurrency enabled (APPROACHABLE_CONCURRENCY = YES), that default behavior flips.
Ordinary async methods now behave much like their synchronous counterparts. They stay on the caller’s executor by default instead of hopping away.
A few quick rules to keep in mind:
nonsending: The function isn’t bound to a specific actor’s isolation domain, but it keeps the execution context of whoever called it.@concurrent: The explicit opt-in attribute telling the compiler, “No, seriously, run this on the global concurrent pool.”- Good to know:
@concurrentautomatically implies nonisolated, so writing both is redundant.
Comparing the Flags: A Basic Test
Let’s look at a straightforward example to see the difference in practice:
@MainActor
class ViewModel {
var title = "Hello"
func updateData() {
print("1:", Thread.isMain)
}
nonisolated func helperMethod() async {
print("2:", Thread.isMain)
}
@concurrent
func thirdMethod() async {
print("3:", Thread.isMain)
}
}
// Calling it from a MainActor context:
Task {
let viewModel = ViewModel()
viewModel.updateData()
await viewModel.helperMethod()
await viewModel.thirdMethod()
}
💡 Quick Compiler Tip:
When testing thread execution across different isolation contexts, you might run into compiler warnings or errors when accessing Thread.isMainThread. To cleanly check the main thread without triggering actor isolation warnings, use a nonisolated helper extension:
extension Thread { static nonisolated var isMain: Bool { Thread.isMainThread } }
Here’s what gets printed depending on your project settings:
| Output | APPROACHABLE_CONCURRENCY = NO | APPROACHABLE_CONCURRENCY = YES |
|---|---|---|
1: updateData() | true | true |
2: helperMethod() | false (Background) | true (Main Thread) |
3: thirdMethod() | false (Background) | false (Background) |
What’s happening here?
- When set to
NO: CallinghelperMethod()drops off the main actor and executes on a background thread (false). - When set to
YES:helperMethod()isn’t isolated, but thanks to nonsending, it inherits the caller’s context. Since the calling Task runs on@MainActor,helperMethod()stays right there on the main thread. thirdMethod()is marked@concurrent, so it always hops to a background worker thread regardless of the build setting.
Deep Dive: Following the Execution Chain
To really see how thread hopping behaves during nested calls and returns, let’s trace a slightly more complex scenario involving a custom global actor:
@globalActor
actor BackgroundActor {
static let shared = BackgroundActor()
}
@MainActor
class ViewModel {
var name = "Swift 6"
// 1. Synchronous isolated method
func runTest() {
print("1:", Thread.isMain)
Task {
await complexHelper()
}
}
// 2. Async nonisolated helper
nonisolated func complexHelper() async {
print("2:", Thread.isMain)
// Jumping over to our custom actor
await BackgroundActor.shared.doWork {
print("3:", Thread.isMain)
}
print("4:", Thread.isMain)
// Calling a sync nonisolated helper
syncHelper()
}
// 3. Synchronous nonisolated helper
nonisolated func syncHelper() {
print("5:", Thread.isMain)
}
}
extension BackgroundActor {
func doWork(_ operation: @Sendable () -> Void) async {
operation()
Task {
print("6:", Thread.isMain)
}
}
}
Side-by-Side Execution Trace:
| Step | APPROACHABLE_CONCURRENCY = NO | APPROACHABLE_CONCURRENCY = YES |
|---|---|---|
| 1 | true | true |
| 2 | false | true ← Stays on caller’s thread |
| 3 | false | false ← Hopped to BackgroundActor |
| 4 | false | true ← Returned to caller context |
| 5 | false | true ← Synchronous call from step 4 |
| 6 | false | false ← Task spawned inside BackgroundActor |
Why steps 2, 4, and 5 change in Swift 6.2:
- Step 2 (
complexHelper): Because the caller is on@MainActor,complexHelperstarts executing on the main thread (true). - Step 3 (
doWork): We explicitly await a method onBackgroundActor, so execution correctly hops over to a background thread (false). - Step 4 (After
await doWork): Here’s the key difference. WhendoWorkfinishes, control resumes incomplexHelper. Under Swift 6.2, the method remembers where it was called from, so it hops back to the Main Thread (true). - Step 5 (
syncHelper): This is a plain synchronous call made right after step 4, so it stays on the main thread (true).
Wrapping Up
Swift 6.2’s Approachable Concurrency makes writing async Swift feel a lot more natural:
- Fewer random context switches: Your app spends less time hopping back and forth across threads when it doesn’t need to.
- Predictable execution: Async code holds onto its caller’s context until you explicitly use
@concurrentor call into a different actor. - Easier mental model: Async methods now align much closer with how we expect synchronous code to flow, removing a big chunk of the concurrency learning curve.