Mar 27, 2024

Swift 5.10 Concurrency Survival Guide

You never know what you’re going to get when updating to a new version of Xcode. Historically, the only thing to worry about was the major version updates that came out in the fall. These include not only support for that year’s new operating systems but also various new IDE features and analysis tools (e.g., checkers and sanitizers). The minor version bumps, by contrast, were much more safe/boring.

This pattern was broken a few years ago when new versions of Swift started coming out in point updates. Xcode 15.3 includes Swift 5.10. Reading through the release notes, one can find a few mentions of concurrency, such as:

Under strict concurrency checking, every global or static variable must be either isolated to a global actor or be both immutable and of Sendable type.

It doesn’t sound like much. Indeed, it did not prepare me for what happened when I first opened our app in this new version of Xcode. A better description can be found in the Swift 5.10 release blog post.

Swift 5.10 accomplishes full data isolation in the concurrency language model.

Oh. That sounds a bit more important. What does it mean in practice? If you happened to opt your app into either target or complete concurrency checking, you might encounter something like this:

Xcode showing Build Succeeded with 361 warnings

Let’s just say that’s slightly more warnings than a typical Xcode point update. In this post, I want to share my experience of updating our app’s code to bring that number back down to zero.

A very brief summary of Swift’s concurrency model

While the probability that someone reading this post does not know the parts that makeup Swift concurrency is small, it seems appropriate to mention them anyway.

async/await is an alternative to callbacks. A function (or a read-only computed property) marked async indicates that it may suspend execution at some point (marked with await) and resume in the future (not necessarily on the same thread).

func randomNumberEventually() async -> Int {
    try? await Task.sleep(for: .seconds(5))
    
    return .random(in: 1...10)
}

actors are a new kind of declaration (joining structenum, and class), which disallows concurrent access to its internal state. This state is said to be “isolated” to the actor and can only be accessed asynchronously – using await.

Global actors can be used to extend that actor’s isolation over a declaration. The only predefined global actor is @MainActor, which corresponds to the main queue/thread and is required by the entirety of the UIKit framework and some parts of SwiftUI.

Certain declarations isolated to a concurrency context can be excluded from this isolation by marking them with the nonisolatedkeyword.

Sendable is a protocol that marks a type as being safe to transfer between concurrency contexts. Attempting to use a type that does not conform to Sendable between context (such as awaiting a @MainActor-annotated method outside of @MainActor) leads to the compiler complaining.

@Sendable does the same as Sendable, but is applied to functions instead of types.

Together, these (and some other) elements form a system that can detect potential data races at compile time. This is done by ensuring that a mutable state cannot be concurrently accessed from multiple threads at the same time. Values crossing actor boundaries must be Sendable.

The following types are Sendable by default:

  • most, if not all, standard library types (IntString, …), including conditional conformances for ArrayDictionaryOptional, etc.
  • internal structs and enums with Sendable members
  • actors
  • types isolated to a global actor

Other types can be made Sendable by explicitly conforming to the Sendable protocol:

  • public structs and enums with Sendable members
  • final classes with only immutable members (basically Sendable lets)

Functions always have to be explicitly annotated with @Sendable; there is no implicit conformance.

The warnings and what to do about them

There are basically two ways of dealing with most of the concurrency warnings that the compiler shows: either make the declaration in question Sendable so that it can safely cross boundaries, or annotate it with the same global actor so that the boundary does not have to be crossed in the first place. What makes more sense depends on the situation. Both options have the potential to trigger a cascade of additional annotations, though with global actors this can lead upward as well as downward.

Let’s start with the simplest possible warning, though.

var/static property is not concurrency-safe because it is non-isolated global shared mutable state

Top level (and non-isolated static) variables are simply banned outright:

var globalVariable = "test" // ⚠️ Var 'globalVariable' is not concurrency-safe because it is non-isolated global shared mutable state

If the variable doesn’t have to be mutable, turning it into a let will make it safe.

If it has to be mutable, it will need to be annotated with a global actor (such as @MainActor), as this will ensure that it can always be accessed only through that actor.

var/let/static property is not concurrency-safe because it is not either conforming to ‘Sendable’ or isolated to a global actor

A variation of the previous situation. The difference here is that the type of the property is not Sendable.

class Wrapper {
    let value = "test"
}

let wrapper = Wrapper() // ⚠️ Let 'wrapper' is not concurrency-safe because it is not either conforming to 'Sendable' or isolated to a global actor

Here, updating the class to final class Wrapper: Sendable would fix the warning. Marking it with a global actor would work as well, but it might lead to the need to spread that global actor to other places in the code.

Another option for cases where the returned value doesn’t have to be the same instance every time would be to convert the variable into a computed property. That way, there is no need for safety checks because each call returns a new value.

Passing argument of non-sendable type outside of actor-isolated context may introduce data races

This can happen when awaiting a method of a non-sendable type somewhere inside an actor or a global actor annotated declaration:

class Wrapper {

    @MainActor func foo() async { // ⚠️ Passing argument of non-sendable type 'Wrapper' outside of main actor-isolated context may introduce data races
        await bar()
    }

    func bar() async {

    }
}

The await sends Wrapper (remember that there is an implicit self there) outside of @MainActor, but Wrapper is not Sendable.

There are three ways to fix this:

  1. Conform the type to Sendable
  2. Annotate the method with the same global actor as the caller
  3. Annotate the type with some global actor

Passing argument of non-sendable type into actor-isolated context may introduce data races

This is the opposite of the previous warning:

class Wrapper {

    func foo() async {
        await bar() // ⚠️ Passing argument of non-sendable type 'Wrapper' into main actor-isolated context may introduce data races
    }

    @MainActor func bar() async {

    }
}

The fixes are the same as for the previous warning.

Non-sendable type passed in call to actor-isolated property cannot cross actor boundary

This warning is similar to the previous ones, but applies to properties instead of method calls:

class Wrapper {
    
    func foo() async {
        print(await bar) // ⚠️ Non-sendable type 'Wrapper' passed in call to main actor-isolated property 'bar' cannot cross actor boundary
    }
    
    @MainActor var bar: Int {
        get async { 0 }
    }
}

Once again, the fixes are the same as the previous examples.

actor-isolated class has different actor isolation from (nonisolated) superclass

When a class is annotated with a global actor, subclasses inherit this annotation:

@MainActor class Super {}

class Sub: Super {} // ✅ inherits @MainActor

Declaring different isolation in a subclass is not allowed:

class Super {}

@MainActor class Sub: Super {}  // ⚠️ Main actor-isolated class 'Sub' has different actor isolation from nonisolated superclass 'Super'

This was particularly painful for us since we’ve developed a habit of annotating entire XCTestCases as @MainActor if they tested @MainActor-isolated types. In Xcode 15.3, this is no longer possible and the annotations must be moved to individual member declarations (tests, set up / tear down method, etc.).

Other notes and gotchas

Dealing with protocols

If your code uses protocols for dependencies, you will probably run into a situation where the protocol needs to be passed between actors. The natural reaction is to tack on : Sendable to the protocol itself.

protocol UserSession: Sendable {
    func logOut()
}

This then leads to all the conforming types (actual dependencies, mocks, …) also requiring sendability. If the conforming type happens to be a class with mutable state, it cannot conform to Sendable.

final class UserManager: UserSession {
    
    private(set) var user: User? // ⚠️ Stored property 'user' of 'Sendable'-conforming class 'UserManager' is mutable
    
    func logOut() { 
        user = nil
    }
}

This leaves actors as the only alternative. However, protocols that are not themselves isolated to an actor cannot be satisfied by actor-isolated members:

@MainActor final class UserManager: UserSession {

    private(set) var user: User?

    func logOut() { // ⚠️ Main actor-isolated instance method 'logOut()' cannot be used to satisfy nonisolated protocol requirement
        user = nil
    }
}

One option is to to use the global actor on the protocol itself — this will be inherited by the conforming types (since actor-isolated declarations are Sendable, the conformance doesn’t have to be restated).

@MainActor protocol UserSession {
    func logOut()
}

Another option is to make the protocol members nonisolated. However, this is also an error, as it’s not possible to change the value of the actor-isolated user from a non-isolated context.

@MainActor final class UserManager: UserSession {

    private(set) var user: User?

    nonisolated func logOut() {
        user = nil // 🛑 Main actor-isolated property 'user' can not be mutated from a non-isolated context
    }
}

We can fix this by dispatching to the actor inside the method:

@MainActor final class UserManager: UserSession {

    private(set) var user: User?

    nonisolated func logOut() {
        Task { @MainActor in
            user = nil
        }
    }
}

One other possibility is to make the type into an actor and replace Sendable in the protocol declaration with Actor, which will enforce that the conforming types are in fact actors. But this of course makes it impossible to create classes or structs that conform to the protocol.

Escape hatches

The concurrency system doesn’t exist in a vacuum. While a significant part of Apple’s frameworks have been updated to support it, there are also many third party libraries that have not. This could lead to unsolvable situations with things like missing Sendable annotations in code that cannot be edited.

Fortunately, there are ways around this.

The @preconcurrency attribute can be prepended to an import statement to silence warnings originating from the imported module. Your mileage may vary, but at least in some cases this doesn’t work in Xcode 15.3 (confirmed to be a compiler bug).

For a more granular approach, @unchecked Sendable can be used to tell the compiler that the type is Sendable even though it cannot be proven with existing checks. Unlike plain Sendable, it can be added to extensions, which allows you to retroactively conform a type that is not even defined in your own code.

This is similar to implicitly unwrapped optionals, in that the language “assumes” this to be true and will crash the app if it turns out not to be the case. Only in case of @unchecked Sendable it might lead to data races instead. Therefore it is important to be sure that a type is actually safe before doing this. For example the Logger type from Apple’s OSLog framework is not marked as Sendable, but it was confirmed by Apple to be safe, so this is valid:

extension Logger: @unchecked Sendable {}

Bonus — when worlds collide

After fixing all the other warnings, I found a situation which could not be resolved using any of the above.

Our app uses Apple’s AuthenticationServices framework to offer users Sign in with Apple as well as stored keychain logins. As part of this flow, the library requires a type returning a “presentation anchor” from which to present the system UI. This is done using the following protocol:

protocol ASAuthorizationControllerPresentationContextProviding: NSObjectProtocol {

    func presentationAnchor(
        for controller: ASAuthorizationController
    ) -> ASPresentationAnchor
}

On iOS, ASPresentationAnchor is a typealias for UIWindow. For the sake of simplicity, let’s go with a minimal implementation (the actual one doesn’t use force unwrapping):

class AuthorizationProvider: NSObject, ASAuthorizationControllerPresentationContextProviding {

    func presentationAnchor(
        for controller: ASAuthorizationController
    ) -> ASPresentationAnchor {
        UIApplication.shared.connectedScenes // ⚠️ Main actor-isolated class property 'shared' can not be referenced from a non-isolated context
            .compactMap { $0 as? UIWindowScene }.first!.keyWindow!
    }
}

Recall that all of UIKit is annotated with @MainActor, so the above code emits warnings about accessing UIApplication. Annotating this class with @MainActor makes the type no longer conform to the protocol, because the protocol is not@MainActor:

@MainActor class AuthorizationProvider: NSObject, ASAuthorizationControllerPresentationContextProviding {

    func presentationAnchor( // ⚠️ Main actor-isolated instance method 'presentationAnchor(for:)' cannot be used to satisfy nonisolated protocol requirement
        for controller: ASAuthorizationController
    ) -> ASPresentationAnchor {
        UIApplication.shared.connectedScenes
            .compactMap { $0 as? UIWindowScene }.first!.keyWindow!
    }
}

We can recover the conformance by making the method nonisolated, but this brings back the original error, because now the access to UIApplication is once again not happening @MainActor.

@MainActor class AuthorizationProvider: NSObject, ASAuthorizationControllerPresentationContextProviding {
    
    nonisolated func presentationAnchor(
        for controller: ASAuthorizationController
    ) -> ASPresentationAnchor {
        UIApplication.shared.connectedScenes // ⚠️ Main actor-isolated class property 'shared' can not be referenced from a non-isolated context
            .compactMap { $0 as? UIWindowScene }.first!.keyWindow!
    }
}

Unlike the previous examples, this method actually returns a value and is not async, so we cannot simply use a Task to get around the issue.

Here we have to use the assumeIsolated – a method defined on the Actor protocol itself. The documentation states:

This API should only be used as last resort, when it is not possible to express the current execution context definitely belongs to the specified actor in other ways. E.g. one may need to use this in a delegate style API, where a synchronous method is guaranteed to be called by the specified actor, however it is not possible to move this method as being declared on the specified actor.

This precisely describes the situation. Applying the method here satisfies the compiler:

class AuthorizationProvider: NSObject, ASAuthorizationControllerPresentationContextProviding {
    
    func presentationAnchor(
        for controller: ASAuthorizationController
    ) -> ASPresentationAnchor {
        MainActor.assumeIsolated {
            UIApplication.shared.connectedScenes
                .compactMap { $0 as? UIWindowScene }.first!.keyWindow!
        }
    }
}

Conclusion

In all the error messages shown in this post, I omitted the last part:

this is an error in Swift 6

With Swift 6 likely coming later this year, it’s a good time to start updating apps so that this doesn’t become a blocker in the future.

The instructions for enabling concurrency checking can be found here. Note that if your app uses Swift packages, updating the settings in the Xcode project does not also turn it on for the packages — this has to be done separately.

Ultimately, even though the transition can be painful, adopting the concurrency checking does lead to safer code and I applaud the Swift team for getting to this point.

Search
Share
Featured articles
Generating SwiftUI snapshot tests with Swift macros
Don’t Fix Bad Data, Do This Instead