Skip to content

Demystifying Thread Hopping with Swift 6.2 Approachable Concurrency

Published: at 08:35 AM

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:

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:

OutputAPPROACHABLE_CONCURRENCY = NOAPPROACHABLE_CONCURRENCY = YES
1: updateData()truetrue
2: helperMethod()false (Background)true (Main Thread)
3: thirdMethod()false (Background)false (Background)

What’s happening here?

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:

StepAPPROACHABLE_CONCURRENCY = NOAPPROACHABLE_CONCURRENCY = YES
1truetrue
2falsetrue ← Stays on caller’s thread
3falsefalse ← Hopped to BackgroundActor
4falsetrue ← Returned to caller context
5falsetrue ← Synchronous call from step 4
6falsefalse ← Task spawned inside BackgroundActor

Why steps 2, 4, and 5 change in Swift 6.2:

Wrapping Up

Swift 6.2’s Approachable Concurrency makes writing async Swift feel a lot more natural:

Thanks for reading

If you enjoyed this post, be sure to follow me on X or Mastodon to keep up with the new content.


avatar

Nikita Vasilev

A software engineer with over 8 years of experience in the industry. Writes this blog and builds open source frameworks.


Next Post
SwiftUI: @State is a macro