If you've been a Swift developer (or a developer in the Apple space), you've experienced your fair share of migrations. Whether it was Objective-C, SwiftUI, try...catch
or most recently async/await and actors, we've had to continue to evolve:
The good news is we'll continue to have a desired skill for the labor force! Besides that, it means:
- Swift is still a living evolving language.
- Swift continues to strive to be a safe language.
As we reach a full decade since its introduction, Swift 6 introduces its most bold set of safety features when it comes concurrency.
I've been interested in concurrency since the days of Grand Central Dispatch. With Swift 6, we are seeing the biggest advancements in safety to the language - which means it's going to break a lot of stuff.
Swift 6 forces you to make sure that things like data races can't happen while you doom scroll on your $3000 iPad Pro efficiently using processor cores properly.
Recently I took the deep dive and begun migrating Bushel to Swift 6 and have learned some general lessons which I hope help you with your migration. However before I do, I can't go without mentioning that the upcoming version of Xcode will include a Swift 5 mode. In my opinion, you should take advantage of this - especially if you are on a big team supporting older operating systems. For the more adventurous developer, you can already enable Complete Strict Concurrency in Xcode 15.
With that in mind, here's my very broad guide to fixing Swift 6. There are exceptions to many of these cases but my hope is this guide is a starting point for migrating your apps and bringing more efficiency and a better experience for your users.
When you have complete control
Let's start by talking about the code you have complete control over which is not connected to third-party code. Let's start with the largest issue where you’ll gain most benefit.
Mutable Properties should be a Sendable Struct or Actor
Stored property 'observations' of 'Sendable'-conforming class 'VirtualizationMachineBuilder' is mutable
A mutable property on Sendable types is a red flag for concurrency issues. If it can be changed, it can be changed by multiple actors/threads/etc... This is fertile ground for race conditions. Swift 6 looks for these situations and will happily throw you errors. Therefore you have two possible solutions for these properties:
- Make it an
actor
- Mark it as
Sendable
As you might imagine once you make something Sendable
or an actor
, this will spread to its descendants.
Here's a overly simple rule of thumb:
- If
class
make itactor
orSendable
- If
struct
make itSendable
Of course there are exceptions to this rule and it helps to think about what it means by marking types as Sendable
. If your type is a simple structure with Sendable
properties it's perfectly fine to make it Sendable
as well. If your type is a complex class and can be entered simultaneously in any way, it becomes more complicated. You can either mark it as an actor
which has repercussions or recursively follow the rules stated above for its properties.
By moving your classes to actor there are few restrictions:
- It's
final
which means it can't be subclassed - Every function becomes asynchronous from the outside in order to prevent race conditions
By making every function asynchronous, you will run into issues especially when you are trying to implement a Protocol on an actor. Luckily there are a few workarounds for that.
When you don't have complete control
Most of the time you'll have code you don't have control over a Swift Package, a neglected Apple API, a ... 👻 Cocoapod 👻 In these cases, you'll have to find creative ways to work with them while making them safe for Swift 6.
Use nonisolated
to start a call
Let's say you have an asynchronous method in your code base but you need to implement a synchronous method because it has to implement a protocol for instance. The solution I've taken is to start the asynchronous call within a synchronous call. In most cases, I am reacting to a button tap/click which just needs to start a process. I don't need to wait for it to be completed.
Where nonisolated
fits, is when you've created an actor
or a type which is marked for a global actor such as @MainActor
. Every call made to that object will automatically be marked as async
, since it needs to wait for that actor to be available. By marking a method as nonisolated
we allow it to be called from outside that actor. However this means we need to start the actual method on a Task:
@MainActor
final class MachineObject {
{
// this can be called syncronously from anywhere
nonisolated func deleteSnapshot(_ snapshot: Snapshot?, at url: URL?) {
Task {
await self.deletingSnapshot(snapshot, at: url)
}
}
// this is required to be called from MainActor
func deletingSnapshot(_ snapshot: Snapshot?, at url: URL?) async
}
nonisolated
static
synchronous properties
If you have a static
property, especially one that isn't dependent on any data, you can use nonisolated
since it's a simple return of a value. In my case I was using my library FelinePine to specify a Logger category:
internal class VirtualizationInstaller: Loggable {
internal static var loggingCategory: BushelLogging.Category {
.machine
}
...
}
I was receiving the error:
VirtualizationInstaller.swift:15:25
Main actor-isolated static property 'loggingCategory' cannot be used to satisfy nonisolated protocol requirement
In this case the category is hard-coded and not dependent on any other values, therefore it can just be marked as nonisolated
:
internal class VirtualizationInstaller: Loggable {
internal nonisolated static var loggingCategory: BushelLogging.Category {
.machine
}
...
}
You Pass an Argument That is Sendable use a @Sendable closure
After I migrated to my ModelActor API with SwiftData, one Swift 6 related issue remained:
Non-sendable type 'FetchDescriptor<T>' in parameter of the protocol requirement satisfied by actor-isolated instance method 'fetch' cannot cross actor boundary
Despite ensuring my SwiftData models were Sendable, FetchDescriptor
remained not Sendable
. However you can get around this by passing @Sendable
closure instead.
Therefore my Database
protocol goes from:
func fetch<T>(_ descriptor: FetchDescriptor<T>) async throws -> [T] where T: PersistentModel & Sendable
try await database.fetch(
FetchDescriptor<SnapshotEntry>(
predicate: #Predicate { $0.snapshotID == id }
)
)
to:
func fetch<T>(
_ descriptor: @Sendable @escaping () -> FetchDescriptor<T>
) async throws -> [T] where T: PersistentModel & Sendable
try await database.fetch{
FetchDescriptor<SnapshotEntry>(
predicate: #Predicate { $0.snapshotID == id }
)
}
Matt Massicotte has a great writeup here on recipes with this solution as well as alternatives.
If it's SwiftUI make it MainActor
Stored property '_isNextReady' of 'Sendable'-conforming struct 'SpecificationConfigurationView' has non-sendable type 'Binding<Bool>'
Stored property '_restoreImageImportProgress' of 'Sendable'-conforming class 'DocumentObject' is mutable
Converting non-sendable function value to '@MainActor @Sendable (OpenWindowAction) -> Void' may introduce data races
Back when we were developing Newton apps on punch cards the first issue we ran into was trying to make UI changes on the inappropriate queue. There was an obvious fix:
dispatch_async(dispatch_get_main_queue(), ^{
[[self itunespingLabel] setText:[NSString stringWithFormat:@"%@", name]];
});
Luckily this is still apt for SwiftUI (and AppKit and UIKit too). Every SwiftUI View should be run on the @MainActor
. Matt has a few suggestions on how to do this. I've taken the approach to explicitly marking all my views as @MainActor:
import SwiftUI
@MainActor
struct SessionToolbarView: View {
However this is means everything which interacts with SwiftUI will need to be @MainActor
as well:
EnvironmentKey value properties which are being interacted with
Anytime you do something which interacts with the UI (i.e. SwiftUI) then it'll need to be a @MainActor
as well. In this first instance we have potential Environment
property which calls openWindow
based on a particular value
in SwiftUI:
public struct OpenWindowWithValueAction<ValueType: Sendable>: Sendable {
let closure: @Sendable @MainActor (ValueType, OpenWindowAction) -> Void
public init(closure: @escaping @MainActor @Sendable (ValueType, OpenWindowAction) -> Void) {
self.closure = closure
}
@MainActor
public func callAsFunction(_ value: ValueType, with openWindow: OpenWindowAction) {
closure(value, openWindow)
}
}
Since we are interacting with the UI, the method which calls the OpenWindowAction
needs to be on @MainActor
. This means then that the closure also needs to be marked as @MainActor and the argument in the init
as well.
Here's another example:
public struct ViewValue: Sendable {
let content: @Sendable @MainActor (Binding<(any InstallImage)?>) -> AnyView
public init(content: @Sendable @escaping @MainActor (Binding<(any InstallImage)?>) -> some View) {
self.content = { image in
AnyView(content(image))
}
}
@MainActor func callAsFunction(_ selectedHubImage: Binding<(any InstallImage)?>) -> some View {
content(selectedHubImage)
}
}
private struct HubViewKey: EnvironmentKey {
typealias Value = ViewValue
static let defaultValue = ViewValue { _ in
EmptyView()
}
}
extension EnvironmentValues {
public var hubView: ViewValue {
get { self[HubViewKey.self] }
set { self[HubViewKey.self] = newValue }
}
}
extension Scene {
@MainActor
public func hubView(
_ view: @Sendable @escaping @MainActor (Binding<(any InstallImage)?>) -> some View
) -> some Scene {
self.environment(\.hubView, .init(content: view))
}
}
In the code example above, not only does the content
closure and callAsFunction
require @MainActor
but also the Scene
extension since Scene
is a UI component from SwiftUI
.
@Observable
Objects
For the most part @Observable
objects should be on @MainActor
since they are acted upon by SwiftUI. Again if you want to just start a method feel free to use nonisolated
method stated above.
The most inner circle of Hell
In many cases, you don't have any control. Unfortunately you simply need to wait for updates and can ignore the warning for now. 😭
This is a bug
Cannot form key path to main actor-isolated property 'requestReview'; this is an error in Swift 6
In many cases, there are pieces which haven't completed yet. In this case, it's currently a bug in Swift. Hopefully these will be updated in time for WWDC 2024 and most importantly I'll follow through and update this post. In any case, you should take the time file a Feedback Assistant.*
It uses DispatchQueue
There still remains plenty of code which still relies on GCD. In the case of Bushel this includes:
- XPC
- Combine
- Virtualization
- SwiftData (except for ModelActors)
There are two solutions:
- Stick with GCD and avoid new language features in those types.
- Wrap your GCD API in a
CheckedContinuation
.
For more info, I highly recommend checking out Matt Massicotte's recipe for this.
Server-Side Swift
Luckily, the Server-Side Swift team has been moving steadily forward with Swift 6 compatibility since it's announcement. If you are interested in learning more, I highly recommend:
- Tim's post a while ago on the next steps for Vapor
- As well as his post on Vapor's Next Steps with async/await
- Joannis's article on Getting Started with Structured Concurrency in Swift
- I would also check out the entire site at swiftonserver.com for more great articles by Joannis and Tibor.
- For a great example of the real-world challenges with Swift 6, I would check out Gwynne's recent article on Fluent Models and Sendable warnings.
It needs to be synchronous
Now What?
Hopefully this article helps you get started on migrating your code. While it won't be simple to migrate your code, here are the simple guidelines:
- Mutable properties need concurrency safety either via
Sendable
orActor
- In an
actor
, usenonisolated
for starting a task or for properties with constants - If type isn't
Sendable
use a@Sendable
closure - If it's UI-related make it @MainActor (i.e. SwiftUI View, Observable, etc...)
- There are still APIs and frameworks which haven't migrated over but hopefully soon 🤞
I'm excited to get the advantages of the concurrency safety that comes with Swift 6. I'm sure teams will have challenges in migrating. If you have a large enough team which needs to support older OSes, it's totally worth holding off on migrating. For those like myself, the hope is that getting in early on these features means improved performance and a safer concurrency implementation. I'm looking forward to following the events of WWDC 2024, as well as the advice from Apple and others. Stay tuned!