I’m in the process of migrating a reasonably large (60K LOC) Swift app to Swift 6 and have a few questions about concurrency:
Approachable Concurrency
Is the new Approachable Concurrency designed to be sort of like training wheels for Swift Concurrency? Do I need to turn on complete checking to truly address any potential data races?
Or, if I turn on Approachable Concurrency and fix all of its warnings, am I done?
Migrating Model Classes
I’ve been working on complete currency checking more or less since it was first announced, without much success. One problem is that a lot of my model types are classes, not structs. When I see a warning like “Non-Sendable type cannot exit actor-isolated context” I always read that as a recommendation to switch to value types, which could easily be marked Sendable.
Can someone expand on how to apply this to resolving data race warnings? Do I need to isolate more of the code that uses my model classes to @MainActor?
nonisolated Keyword
My original understanding of the nonisolated keyword was simply that any functions it annotated would not be isolated to the surrounding actor. Lately, a lot of writing about Swift 6 suggests that nonisolated functions are automatically run on a background thread. Is this a recent change in Swift 6, or was it always like this? Or is this true only for functions that are both nonisolated and async?
Avoiding Performance Hits
In general, it seems like complying with Swift 6’s concurrency rules will mean a lot more @MainActor classes and functions in my project. How do you recommend detecting increased contention for the main actor when code that could previously run anywhere now has to compete for the main actor? The Swift 6 migration guide and some of the WWDC videos suggest that programmers worrying too much about using @MainActor in UI-driven apps is a major obstacle to supporting full Swift 6 concurrency, but…I do still worry about it.
Documentation
I was surprised by how short the Concurrency section of “The Swift Programming Language” is. Is there a good, official reference on Swift concurrency, updated for the latest changes? It’s tough to cobble together blog posts, WWDC videos, evolution pitches and the Swift 6 migration guide.
I had the chance to have a call with Holly Borla this past WWDC and what follows is my understanding of things:
I don’t view approachable concurrency as training wheels. The vast majority of code is single-threaded, so Apple is now allowing one to more easily adopt Swift 6.
Even for software making use of networking, Apple’s APIs (e.g. URLSession) already allow you to use async/await. Work will be done on a background thread, and when returning, you can be on the main thread to update UI. No actors needed here at all.
In addition, there’s the default actor isolation setting. Holly had recommended that most projects should have approachable concurrency enabed along with setting the default actor isolation to MainActor.
This has worked well for my own projects as the amount of @MainActor throughout the code was almost eliminated.
With that said, there was an issue with the earier implementation of Swift 6.2 where one still needed to sprinkle in @MainActor (resolved with Pull Request #82383).
These are settings found in XCode. What about SPM projects, especially on platforms where XCode is not available? What are the default settings for SPM projetcs? Are approachable-concurrency and main-actor-by-default settings enabled by default?
I would like to eliminate the @MainActor annotations in my GUI framework for GUI-related classes. Is this realistic?
Edit: In my project GUI-related classes are annotated @MainActor, and things that are not GUI-related are non-isolated. And I think this is right, and I actually don't need main-actor-by-default setting enabled. Not sure about approachable-concurrency setting though.
though there will be warnings that GlobalActorIsolatedTypesUsability, DisableOutwardActorInference, InferSendableFromCaptures are enabled by default for Swift 6. So overall for those who will use SPM it could be reduced to:
It’s not “training wheels”. It is radical elimination of unnecessary complexity. This is arguably long overdue. The motivating ideas behind Approachable Concurrency are outlined in the Improving the approachability of data-race safety vision document. And that video you reference really walks through when the rationale of when you stick with the main actor isolation (esp when employing the few “Default isolation” build setting of “Main Actor”) and when you might introduce @concurrent functions or actors to get work off the main actor. It might take more than one viewing of that video to really appreciate the many subtle points contained therein.
IMHO, the driving principle of “Approachable Concurrency” (and all of its associated build settings) is that writing multithreaded code (with Sendable types, region-based isolation, etc.) might be a little complicated, but writing code that enjoys Swift concurrency doesn't need to be. The vast majority of apps simply don’t actually need to write multi-threaded code, themselves, but rather they are just availing themselves of existing framework API that offer async API that do all that heavy lifting for us.
Yes, that is prudent. The whole idea is to warn you if you do something that could expose you to data races. (And when you transition to Swift 6 mode, it will enforce this “Complete” checking.) But if you are in Swift 5 mode, first try “Targeted” mode, and fix those issue. Then transition to “Complete” mode and fix those issues. And only after you have all of that behind you, then consider the transition to Swift 6 language mode.
The thing is, when you embrace “Approachable Concurrency” (and all of the various build settings associated with this broader initiative), tons of headaches that we’ve been wrestling with in Swift concurrency for the last few years just evaporate. If, for example, you use the “Default isolation” build setting of “MainActor”, then you simply don’t have to worry about sending objects across actor boundaries if everything is already on the main actor. For example, if you are writing an app that is simply performing a bunch of network requests and showing the results in the UI, the bulk of your code can be isolated to the main actor, and URLSession will run its requests on its own thread, completely transparent to you.
For non-async functions, the answer is simple: A nonisolated synchronous function always runs on the caller’s thread. A synchronous function that is isolated to a particular actor, will run on that actor. (And if you call an actor-isolated synchronous function from any other isolation, you will have to await it (so that it can run on the actor to which it was isolated). End of story.
The discussion is more complicated for async functions: Unfortunately, we can no longer talk about the threading behavior of nonisolatedasync functions in the abstract. The behavior is now dictated by an Xcode 26 build setting, “nonisolated(nonsending) by default”.
If this build setting is “No”, we get the historical behavior that we have had since Swift 5.7, as outlined in SE-0338, where nonisolated async functions will, regardless of where you called it from, run on a generic executor (i.e., not using the caller’s isolation, with some exceptions that are beyond the scope of this question). The problem with this historical behavior is that if you had a bunch of nonisolated async, you could often end up with a bunch of unintended context hops. Also, you had a situation where nonisolated behaved quite differently for synchronous functions and for asynchronous functions.
Using the new “nonisolated(nonsending) by default” is “Yes” looks to remedy these problems. If this build setting is on, now a nonisolatedasync functions runs avoids this context hop (kind of like its synchronous brethren). And in those cases where you really need to hop off the caller’s executor, you would use the @concurrent qualifier. This is outlined in SE-0461.
While this new feature is much welcomed, we must acknowledge that we’re going to have a bumpy few years, because when we look at code with nonisolatedasync functions, we can no longer know what the resulting behavior is without knowing the module’s “nonisolated(nonsending) by default” build setting. So, the transition will be frustrating, it’s for a greater good.
For the sake of clarity, it’s not technically Swift 6 “concurrency rules” that call for more @MainActor classes. It is a realization (articulated well in that video you shared, as well as the aforementioned “Approachable Concurrency” vision document), that if we shift from “get everything not UI related off the main actor” to the new paradigm of “keep things simple and avoid a lot concurrency headaches by defaulting to using the main actor; only moving work off the main actor if it might block the main thread”.
In practice, we would use Instruments’ “hangs” tool to identify situations where the main thread is getting blocked. Or, after you do this a while, you’ll quickly identify stuff that is slow and synchronous that you have to get off the main actor (e.g., writing files, decoding huge files or assets, computationally intensive calculations). But the reality is that the vast majority of apps do this sort of work very rarely, and if it does, only that work should be moved off the main actor. It keeps life much simpler rather than introducing a lot of unnecessary context switches (and you can improve performance by being more judicious about what should be moved off the main actor).
Agreed. The Swift Programming Language simply hasn’t kept pace with the advances in the language. The WWDC videos provide accessible (but thin) introductions to new features, and the Swift Evolution Proposals are the go-to resource for more detailed discussions (but simply aren’t a nicely organized reference guide).