Five ways to break Swift Concurrency

I really like Swift Concurrency.

I just wish Apple would clearly document what it actually guarantees

Generally, sites (including Apple) explain that marking a function with @MainActor guarantees that it will always run on main.

By adding the new @MainActor annotation to Photos, the compiler will guarantee that the properties and methods on Photos are only ever accessed from the main actor.

Discover Concurrency in SwiftUI – Apple

This simply isn’t true.

Here are five simple functions that cause a @MainActor function to run on a background thread…

class OnMain:NSObject {
    
    @MainActor
    @objc
    func noCanFail() {
        guard Thread.isMainThread else {
            fatalError("not on main")
        }
        print("Did Not Fail!")
    }

    //Just call the selector directly. Swift doesn't care
    func breakWithSelector() {
        Task {
            self.perform(#selector(self.noCanFail))
        }
    }
    
    // Get some Objective C code to call the 'protected' function
    // The objective C code is simply
    // [[OnMain new] noCanFail];
    func breakByCallingObjC() {
        Task {
            Trouble.cause()
        }
    }
    
    //NSNotification centre calls you back on whatever thread the notification was posted
    //It doesn't care about Swift Concurrency
    func breakByNotification() {
        let notification = Notification.Name("Cheeky")
        let center = NotificationCenter.default
        
        center.addObserver(self,
                           selector: #selector(self.noCanFail),
                           name: notification,
                           object: nil)
        Task {
            center.post(name: notification, object: nil)
        }
    }
    
    //Surprisingly, even with block syntax, you get the fatal error
    //At least there is a warning in this one
    func breakByNotification2() {
        let notification2 = Notification.Name("Cheeky2")
        
        let queue = OperationQueue()
        let center = NotificationCenter.default
        
        center.addObserver(forName: notification2,
                           object: nil,
                           queue: queue) { _ in
            self.noCanFail()
        }
        
        //No need to post from a task this time!
        center.post(name: notification2, object: nil)
    }
    
    
    //Objective C will just run the block for us by calling
    //block();
    //At least there is a warning in this one
    func breakWithBlock() {
        Task {
            Trouble.run {
                self.noCanFail()
            }
        }
    }

    func breakFoo() {
        let foo = Foo()
        let library = MyLibrary(delegate: foo)
        library.doWork()
    }
}

Two of these functions generate a warning in XCode 14.1 -three don’t.
They all trigger fatal errors when they end up calling noCanFail on a background thread.

This isn’t by any means an exclusive list.

The most obvious point where bugs like this can bite you is that any time you have a callback function from a system framework (such as bluetooth discovery, or even an Alert), or from a third party framework like a networking library – then it _could_ call back on any thread.

You might get lucky – but in this scenario, @MainActor does precisely nothing.

To my mind, the great sin here is not that Swift Concurrency has edge cases.

The great sin is that Swift Concurrency is presented as if it ‘just works’ – when in fact there are important caveats that you need to manage.

@MainActor is barely documented by Apple. There is a single oblique reference to it in ‘the Swift Programming Language’

It is a core part of the language, it’s ridiculous that Apple doesn’t have clear documentation on what it actually does.

The Rules (?)


My understanding is something like the following:

@MainActor works as long as you…

  1. Are calling from your own code
  2. Are not using selectors
  3. You only use Swift
  4. You don’t use any other form of concurrency (like OperationQueue)

NB – I am not claiming that these rules are 100% correct.
There are probably edge cases even within these rules.

Apple should document what the real rules are.

Rule #1 is trickier than you might think…

It is up to you to ensure that any code which is protected by @MainActor is only called directly from your own code.

If you have a callback from a library/framework, and that calls a function, which calls a function, which eventually calls your protected function – then that’s a problem.

This is true even if you’re providing a callback to an Apple framework like SKStoreKit

It’s _really_ easy to miss something like this.

Imagine the following common pattern

class Foo:NSViewController {

}

//perhaps conforming to some delegate protocol...
extension Foo:MyLibraryDelegate {
    func someCallback() {
        OnMain().noCanFail()
    }
}

You have a UIViewController or an NSViewController.

That does calls a framework/library (perhaps SKStoreKit or a networking library)

The delegate is able to call noCanFail() in the extension here because the whole class has inherited @MainActor from NSViewController

The library is something trivial that ends up calling the delegate off the main thread

protocol MyLibraryDelegate {
    func someCallback()
}

class MyLibrary {
    var delegate:MyLibraryDelegate
    
    init(delegate:MyLibraryDelegate){
        self.delegate = delegate
    }
    
    func doWork() {
        Task {
            delegate.someCallback()
        }
    }
}

Once again – a fatal error.
No warnings in the compiler (at least not with default XCode settings)

Swift Concurrency is great. I’ll continue to use it.

Carefully.

Update – Add flag for warnings…

Thomas Goyne pointed out that that you can add a build flag to generate warnings in these cases.
It doesn’t stop @MainActor methods from being called in the background – but it at least generates a purple runtime warning for you to investigate.



The flag is -Xfrontend -enable-actor-data-race-checks

Add it to ‘Other Swift Flags’

I’ll be adding this to all my projects.

Update – Keypaths…

Probably not surprising at this point. Keypaths seem to completely ignore Swift Concurrency

No Objective C involved here at all

    @MainActor
    var doNotFail:Int {
        guard Thread.isMainThread else {
            print("Failed")
            return 0
        }
        return 0
    }

    func breakWithKeypath() {    
        Task {
            var array = [OnMain()]
            var sorted = array.map(\.doNotFail)
        }
    }

doNotFail is merrily called on a background thread…