Swift 6.2: Approachable Concurrency & Default Actor Isolation Issues
Understanding the coming changes and challenges.
Swift 6.2 adds some new settings to our Swift projects, allowing us to enable Approachable Concurrency and define the Default Actor Isolation used within our projects.
These changes, once implemented, will purportedly simply our mental models of how async/await and structured concurrency works in our Swift applications.
The net effect is simple enough to understand. When both are enabled, pretty much all of your code runs on MainActor unless we explicitly indicate that we want to do otherwise.
That’s all well and good… but implementing those changes can be confusing and some of them lead to significant side-effects in our existing code bases.
This article explains and demonstrates those changes and contrasts them with how things work today. I’ll run some sample code with Approachable Concurrency and Default Actor Isolation on and off so you can see what’s going on with your own eyes.
Let’s dig in.
Approachable Concurrency
Approachable Concurrency encompasses quite a few changes under the hood, but at its core it’s designed primarily to unify how Swift behaves when we call a synchronous function vs how it behaves when calling an asynchronous function.
This can be seen in the following code:
class NormalClass {
@MainActor
func load() async {
pureSync()
await pureAsync()
}
func pureSync() {
checkMainThread()
}
func pureAsync() async {
checkMainThread()
}
}
Our load
function is constrained to the MainActor. When it calls the synchronous function pureSync
, pureSync
inherits the actor context and it too runs on MainActor.
When load
calls pureAsync
, however, pureAsync
switches to the global executor, the default cooperative thread pool Swift uses to schedule tasks that aren’t bound to a specific actor (like MainActor) or explicitly dispatched (e.g., Task.detached).
It’s part of Swift’s runtime, implemented atop libdispatch’s global concurrent queues.
That’s how it works today. Turn Approachable Concurrency on, however, and our pureAsync
function also inherits our MainActor context.
The two work the same.
This not only simplifies our mental model, but it’s also more efficient from a performance standpoint as well. We can stay on the same execution context until we need to explicitly leave it.
Default Actor Isolation
Being able to set the Default Actor Isolation is the second big change to Swift 6.2.
Default Actor Isolation gives us the ability to set a global default actor for unannotated code within a given target or module.
In other words: you can opt in to having your entire codebase default to a specific actor — usually MainActor — without requiring explicit MainActor annotations on every class and function in our codebase.
Not only does that free us from propagating MainActor annotations anywhere and everywhere, it protects us from situations where we should have annotated our UI update code with MainActor… but forgot to do so.
Set Default Actor Isolation to MainActor in our project file, and our class now effectively looks like the following (comments added for clarity):
// @MainActor inferred
class MainActorClass {
// @MainActor inferred
func load() async {
pureSync()
await pureAsync()
}
// @MainActor inferred
func pureSync() {
checkMainThread()
}
// @MainActor inferred
func pureAsync() async {
checkMainThread()
}
}
Not only would pureAsync
inherit the actor context from being called by load
, it’s annotated that way as well.
This not only cleans up our code, but again simplies our mental model of the world: Everything runs on MainActor… unless otherwise specified.
Concurrent
All that’s well and good, but running everything on the main thread isn’t the best choice either, since we all know that blocking the main thread can lead to performance problems and animation glitches.
So how do we indicate otherwise? How do we explicitly run an async function off the MainActor? Prior to Swift 6.2, one way of accomplishing that used to be simply marking the function in question as nonisolated
.
@MainActor
class MainActorClass {
// @MainActor inferred
func load() async {
pureSync()
await pureAsync()
} ...
nonisolated func pureAsync() async {
checkMainThread()
}
}
Which escaped the function from inheriting MainActor. Which meant the function could hop back on the global executor, and all was well in the land of Swift.
But… that no longer works with Approachable Concurrency. Remember, Swift 6.2 functions inherit the isolation context of the caller. Including those explicitly marked as nonisolated
. Bummer.
To solve the problem, Swift 6.2 explicitly gives us the @concurrent
annotation.
@MainActor
class MainActorClass {
// @MainActor inferred
func load() async {
pureSync()
await pureAsync()
} ...
@concurrent func pureAsync() async {
checkMainThread()
}
}
Mark an async function as @concurrent
, and Swift will assume that we finally, actually want that function to run in the background, on another thread.
Settings
Just in case you’re unaware, Approachable Concurrency and Default Actor Isolation are both set in Xcode 26 in the Swift Compiler — Concurrency section in Build Settings.
Approachable Concurrency is Yes/No and Default Actor Isolation is either nonisolated
or MainActor
.
Examples
So let’s take a look at the following code. We’re going to run it under Standard pre-6.2 Swift, again with Approachable Concurrency enabled, again with Default Actor Isolation set to MainActor, and, finally, with both changes enabled.
This way we can see the behavior and, hopefully, internalize it. We have a ContentView that runs a task
, calling a load
function on a view model.
struct ContentView: View {
@StateObject var object = MyObject()
var body: some View {
Text(object.text)
.task {
checkMainThread()
await object.load()
checkMainThread()
}
}
}
The view model has a load function that calls out to a varied set of sync and async functions. One synchronous, one is simply async
, one is nonisolated
, one run runs on a different actor, and another is annotated with Swift 6.2’s new @concurrent
attribute.
There’s also a couple of calls to Task
and Task.detached
for good measure.
class MyObject: ObservableObject {
@Published var text = "Waiting..."
@MainActor
func load() async {
checkMainThread()
pureSync()
await pureAsync()
await nonisolatedAsync()
await concurrentAsync()
await myActorAsync()
await Task { checkMainThread("Task") }.value
await Task.detached { checkMainThread("Task.detached") }.value
checkMainThread()
text = "Loaded!"
}
func pureSync() {
checkMainThread()
}
func pureAsync() async {
checkMainThread()
}
nonisolated func nonisolatedAsync() async {
checkMainThread()
}
@concurrent func concurrentAsync() async {
checkMainThread()
}
@MyActor func myActorAsync() async {
checkMainThread()
}
}
And, finally, each function calls out to a helper function that checks to see if we’re on the main thread/actor, or not.
nonisolated func checkMainThread(_ location: String = #function) {
print(Thread.isMainThread ? "\(location): Main Thread" : "\(location): Not Main Thread")
}
Let’s go.
Standard Swift
With Approachable Concurrency disabled and Default Actor Isolation set to nonisolated
, we see the following when we run the code.
body: Main Thread
load(): Main Thread
pureSync(): Main Thread
pureAsync(): Not Main Thread
nonisolatedAsync(): Not Main Thread
concurrentAsync(): Not Main Thread
myActorAsync(): Not Main Thread
Task: Main Thread
Task.detached: Not Main Thread
load(): Main Thread
body: Main Thread
Body is, of course, on MainActor, as is load
. Our sync
function is as well, and as expected.
PureAsync, however, has hopped off the main threat, running on the global executor. Same for nonisolatedAsync
and concurrentAsync
.
Our custom actor-ioslated function isn’t on main either, also as expected.
Finally, note that Task
inherited load’s MainActor context, but Task.detached
is detached. Also as expected.
Got this one? Let’s move on.
Approachable Concurrency Enabled
With Approachable Concurrency enabled and Default Actor Isolation still set to nonisolated
, we see the following when we run the code.
body: Main Thread
load(): Main Thread
pureSync(): Main Thread
pureAsync(): Main Thread // 1
nonisolatedAsync(): Main Thread // 2
concurrentAsync(): Not Main Thread
myActorAsync(): Not Main Thread
Task: Main Thread
Task.detached: Not Main Thread
load(): Main Thread
body: Main Thread
Here the big changes are to pureAsync
and nonisolatedAsync
, both of whom are now running on the main thread.
Again, with Approachable Concurrency enabled nonisolated functions will run within the isolation context of their caller.
Default Actor Isolation MainActor
With Approachable Concurrency disabled and Default Actor Isolation still set to MainActor
, we see the following when we run the code.
body: Main Thread
load(): Main Thread
pureSync(): Main Thread
pureAsync(): Main Thread // 3
nonisolatedAsync(): Not Main Thread // 4
concurrentAsync(): Not Main Thread
myActorAsync(): Not Main Thread
Task: Main Thread
Task.detached: Not Main Thread
load(): Main Thread
body: Main Thread
Here again, the differences are to pureAsync
and nonisolatedAsync
, PureAsync is running on the main thread, but now it’s because it’s getting annotated as MainActor behind the scenes.
And nonisolatedAsync
is on the global executor since being explicitly marked as nonisolated
overrides the default MainActor inheritance.
Approachable Concurrency Enabled & Default Actor Isolation MainActor
With Approachable Concurrency enabled and Default Actor Isolation set to MainActor
, we see the following when we run the code.
body: Main Thread
load(): Main Thread
pureSync(): Main Thread
pureAsync(): Main Thread // 5
nonisolatedAsync(): Main Thread // 6
concurrentAsync(): Not Main Thread // 7
myActorAsync(): Not Main Thread
Task: Main Thread
Task.detached: Not Main Thread // 8
load(): Main Thread
body: Main Thread
PureAsync is once more running on the main thread as it’s getting annotated as MainActor behind the scenes.
As isnonisolatedAsync
. The func isn’t annotated as MainActor, but it is running within the MainActor context it received from being called by load
.
Some Analysis
The function that consistently runs concurrently across all of our example is concurrentAsync
, where it’s been explicitly annotated as @concurrent
.
(And MyActor
and Task.detached
, of course.)
If you’ve already been marking your View Models and other code as MainActor, then you probably have the least amount of work to do, since for the most part your code is already primarily on the main thread.
You just need to track down the locations where you were trying to hop off the main thread and run something in the background.
If you were depending on nonisolated
to accomplish that, then you also need to mark them as @concurrent
.
If you were only marking certain functions as MainActor, then you have more work to do, particularly if you were calling out to other async functions and expecting them to switch off the main thread.
Be wary here of services and models that you once expected to be implicitly nonisolated
by default. Note above that I had to explicitly mark my global checkMainThread
function as nonisolated
in order to get it to work as expected.
That sort of thing is especially problematic and I could probably do another article on that alone.
As in most cases, Instruments is your friend here. Run it and look for main thread bottlenecks.
Xcode 26 Support
Note that @concurrent
is a compiler directive and backported all of the way down to iOS 15.
That said, only Xcode 26 and Swift 6.2 can build code using it. If you have a library or target that needs to build on earlier versions of Xcode, then your best bet is to not enable Approachable Concurrency and Default Actor Isolation at this point in time.
Or bail out of structured concurrency altogether with Task.detached
.
Approachable Concurrency/Default Actor Isolation Goals
There’s a lot to take in here, but all of the above is inline with the goals of Approachable Concurrency and Default Actor Isolation.
Async/await should be predictable for average developers.
Concurrency should feel safe by default, not something where you need to memorize arcane rules about executors and suspension points.
Main-thread behavior for UI apps becomes the baseline, matching the concurrency expectations of decades of Apple UI development.
This approach does not dumb concurrency down, it’s simply designed to give you precise control over when it occurs.
It lets you move work off the main actor when you want and needperformance or parallelism.
Completion Block
I’m looking forward to working with Approachable Concurrency and Default Actor Isolation, but remember that we’re in a transition phase here.
I fully expect to see a few bumps and hiccups along the way.
That said, I hope some of the concepts and examples I’ve laid out here in this article will help smooth the road.
So what do you think? Ready to move forward? Or do you have some code you need to go examine and think about first?
As always, let me know in the comments.