Understanding Nonisolated Async Code in Swift 6.2

Understanding Nonisolated Async Code in Swift 6.2

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. 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: @concurrent automatically 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: Calling helperMethod() 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 on BackgroundActor, so execution correctly hops over to a background thread (false). Step 4 (After await doWork): Here’s the key difference. When doWorkfinishes, control resumes in complexHelper. 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 @concurrent or 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.

Original Source

Read the full article at Hackernoon →

KhanList aggregates and links to publicly available news content. We do not host full articles from third-party sources. Always verify important information with original sources.