@MainActor – The Rules!

I’m going to have a shot at defining what @MainActor does, and what it does not guarantee.

Why?

I keep seeing tutorials and talks explaining @MainActor with something like the following quote

Isolation to the main actor is expressed with the MainActor attribute.

This attribute can be applied to a function or closure to indicate that the code must run on the main actor.

Then, we say that this code is isolated to the main actor.

The Swift compiler will guarantee that main-actor-isolated code will only be executed on the main thread, using the same mechanism that ensures mutually exclusive access to other actors.

– Eliminate data races using Swift Concurrency – WWDC 2022

This quote is simply untrue.

The Swift compiler absolutely 100% does not guarantee that main-actor-isolated code will only be executed on the main thread.

An untrue ‘promise’ is much more dangerous than no promise at all.

There is no real documentation on what @MainActor actually does. The best you can get is probably to look at the Swift Evolution proposals.

Two relevant ones are the proposal for actors (SE-0306) and global actors (SE-0316)
I have no idea whether these are the only relevant ones, or whether they have been superseded.

There is no good way that I’m aware of to tell – other than having an intimate knowledge of all the proposals (which I do not).

I have been banging on for ages about how Apple should document this critical part of Swift, but they haven’t.

So I’m going to give it a go.

This will undoubtably be wrong in subtle and important ways!

Please let me know when you discover more edge cases, and I’ll update accordingly.

Current update: XCode 14.1, 21 Nov 2022

What is considered @MainActor

Starting with the obvious, any method or variable directly annotated with @MainActor

Next up – any method or variable in a class which is marked @MainActor – or in it’s extensions

Similarly – any method or variable in a class where one of it’s parents is marked @MainActor
(This happens a lot when you’re using UIKit and AppKit)

What is considered @MainActor – Protocols…

Protocols are more subtle…

A method is treated as @MainActor if

Declaration – Both of these apply:
a) The protocol declares the method as @MainActor
b) The method is declared in the same block where the protocol conformance is declared

Usage:
c) The object being called is ‘seen’ as an instance of the protocol – and not the original class

In the example below, ProtOne and ProtThree meet the ‘Declaration’ rules, so the relevant methods are always considered @MainActor

The more subtle case is illustrated with ProtTwo

The conformance is declared as an empty extension,
The method is declared in the main block, so the ‘Declaration’ rules do not make it @MainActor

If you call two on a Foo object, it is treated as NOT @MainActor
If you call two on a Foo object which is being ‘seen’ as a ProtTwo – then it IS treated as @MainActor

When is @MainActor ignored?

The swift compiler tries to figure out when @MainActor methods are being called – and tries to ensure that they are called on the main thread.

Because this is a compiler technology – it can only work at compile time.

Because it is extremely complex – it probably misses edge cases.

I’m not knowledgable enough to say for sure exactly why the following cases are actually ignored.

Some seem like dynamic code which is effectively running after the compiler checks…

For the examples, I use the two classes below.

Bar is the ‘pure swift’ version, OBar is annotated to allow ObjC interoperability.

I have separated Bar and OBar to illustrate that the issues don’t only happen when you use @ObjC interoperability, but all the examples work fine with OBar.

mainVar and mainFunc() are intended to be called on the main thread only.

In each case, I show code where mainVar or mainFunc are not called on the main thread.


(please let me know if you find more cases!)

– Keypaths

Keypaths completely ignore Swift Concurrency.
This will call mainVar on a background thread

– Selectors

Calling a selector directly bypasses any Swift Concurrency Checks
This calls mainVar on a background thread

– Objective C

Any time your @MainActor variable or method is called by ObjectiveC, concurrency rules are ignored

In this example, Swift calls MyObjC on the background thread – but the same problem exists if Swift calls an ObjC method, and _that_ method then moves to a background thread.

You can even pass pure swift code to ObjectiveC as a block – and it will be run.
This one does at least generate a warning message

This situation is very common whenever you have a delegate pattern and ObjC code

This is a massively simplified example, but it is essentially what lots of Apple frameworks do. You call a class, and it calls you back via your delegate.

The Swift code here generates no warnings – but calls mainFunc on a background thread

– Any Library/Framework with a delegate, or a block callback (possibly)

This isn’t technically a separate case, but it is important enough to be worth emphasising.

Whenever you call a Framework or Library, you don’t know how they have implemented their code.

That means that any callback may be using Objective C, or might be ‘seeing’ your class as a Protocol, or could be using some other dynamic coding approach.

One of the first times I came across this in my own code was when using NSAlert in MacOS.

Clicking the ‘ok’ button sometimes called back on a background thread.

Apple coding is full of examples where this problem _might_ exist; CoreBluetooth, NSNotificationCentre, any third party Networking Framework just to take some super-obvious examples.

And the behaviour in those libraries/frameworks can change from version to version.

If you need to be sure – then you need to manually dispatch your code to the main queue

Conclusion

Swift Concurrency is GREAT.

I use it in all my code now, and it massively simplifies async work.

It is a highly impressive technical achievement. But it doesn’t magically fix everything – and the perception that it might is dangerous.

I think it is shameful that Apple has not provided documentation on exactly what it guarantees. This should be covered in the swift guide, with perhaps a tech note on the more subtle details.

Apple should not be making false claims about what it does in WWDC talks.

My hope is that they’ll provide proper documentation and I can deprecate this article…

PS: Top Tip…

Update – Add flag for warnings…

You can add a build flag to generate warnings when a @MainActor func/var is called on a background thread.
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’

Update – Binding doesn’t enforce @MainActor!


Rather surprisingly – Binding is an example of callbacks from code you don’t control which can ignore @MainActor.
If you create your binding in an @MainActor context, then the setter/getter are considered @MainActor by the compiler – even though in fact they can be called from any thread.

Update – Words from a swift compiler engineer

I just had a really interesting chat with a swift compiler engineer during WWDC labs.
Their take was that every time @MainActor code is called off the main thread – then that’s a simple straightforward bug.
There is no limit to their ambition to enforce correctness here!