@MainActor – not guaranteed

tldr marking methods with @MainActor does not guarantee that they run on the main thread.


Swift has a magical new structured concurrency model.

‘using Swift’s language-level support for concurrency in code that needs to be concurrent means Swift can help you catch problems at compile time’

-the swift programming language

One of the new features is @MainActor. You can use this to annotate functions, classes, properties, etc.


this proposal introduces MainActor as a global actor describing the main thread. It can be used to require that certain functions only execute on the main thread

link

So – what does @MainActor do?
I hoped to find some documentation, but other than the evolution proposal, I haven’t found any.

There are some tutorials out there which say things like:


all that we really need to know is that this new, built-in actor implementation ensures that all work that’s being run on it is always performed on the main queue.

https://www.swiftbysundell.com/articles/the-main-actor-attribute/

This is what I thought @MainActor did. It is not true.


I created an example to demonstrate.

I have a ViewController with a method and property marked with @MainActor

    @MainActor var mainDate:Date {
        print("@MainActor var - main: \(Thread.isMainThread)")
        return Date()
    }
    
    @MainActor func printDate(_ label:String) {
        print("@MainActor func - \(label) - main: \(Thread.isMainThread)")
    }

My expectation is that calling the function printDate or the property mainDate will always run the code on the main thread. As such, they’ll always print “main: true” in their debug statements.

I created an async function which calls these

    func doWork() async  {
        let _ = await Background().go()

        print("returned from Background - now running off main thread")

        print("calling mainDate in doWork")
        self.storedDate = self.mainDate //sometimes not main thread
        printDate("in doWork") //sometimes not main thread
    }

The first call in the async function is to a background actor.

//Actor that isn't main - it does not run on the main thread
actor Background {
    func go() -> Date {
        print("Background go - main: \(Thread.isMainThread)")
        return Date()
    }
}

The effect of the background actor is simply to return from the await on a background (not main) thread.

This ensures that I’m not simply calling mainDate and printDate on the main thread by default.

I call doWork from a task responding to a button click

    @IBAction func doWorkInAsyncFunction(_ sender: Any) {

        Task { @MainActor in
            await doWork()
        }
    }

For good measure – I mark the task as @MainActor.
My expectation was the following

  1. in doWork(), it would return off the main thread after Background().go()
  2. @MainActor annotation would ensure that calling self.mainDate would happen on the main thread – or there would be a compiler error
  3. @MainActor annotation would ensure that calling self.storedDate would happen on the main thread – or there would be a compiler error

#2 and #3 are false. I get the following output when I run the code

returned from Background - now running off main thread
calling mainDate in doWork
@MainActor var - main: false
@MainActor func - in doWork - main: false

Note the last two lines print main:false – @MainActor isn’t keeping me on the main thread here…

Bizarrely – If include a print statement in my task, then everything _does_ run on the main thread!!!

    @IBAction func doWorkInAsyncFunctionWithPrint(_ sender: Any) {

        Task { @MainActor in
            //Adding this print statement, magically makes everything in doWork run on the main thread!!!
            print("Srsly?")
            await doWork()
        }
    }

gives the following “correct” output

Srsly?
Background go - main: false
returned from Background - now running off main thread
calling mainDate in doWork
@MainActor var - main: true
@MainActor func - in doWork - main: true

Similarly – if I call identical functions, but within a Task (and not even one annotated as @MainActor), then I get the “correct” results

    @IBAction func doInTask(_ sender: Any) {
        Task {
            let _ = await Background().go()

            print("returned from Background - now running off main thread")

            print("calling mainDate doInTask")
            self.storedDate = self.mainDate //main thread
            printDate("in doInTask") // main thread
        }
    }

gives the following…

Background go - main: false
returned from Background - now running off main thread
calling mainDate doInTask
@MainActor var - main: true
@MainActor func - in doInTask - main: true

Is this even wrong?

Swift isn’t behaving as I would expect it to. That’s hardly damning!

However:

  • I can’t find any formal documentation to compare against.
  • I can’t look up what the guarantee (if any) is that Swift provides when you annotate something with @MainActor

I have mostly learned from the brilliant resources provided by sites like swiftbysundell

all that we really need to know is that this new, built-in actor implementation ensures that all work that’s being run on it is always performed on the main queue.

-swiftbysundell

and Hacking with Swift

The magic of @MainActor is that it automatically forces methods or whole types to run on the main actor, a lot of the time without any further work from us. 

-hacking with swift


It seems that I’m not alone in what I expected @MainActor to do. Perhaps this is a bug. Perhaps it is expected behaviour. In the absence of clear documentation showing what @MainActor should to, I can’t tell.


Either way – marking methods with @MainActor does not ensure that they run on the main thread.

Update: Warnings Available…

returned from Background - now running off main thread
calling mainDate in doWork
warning: data race detected: @MainActor function at MainActorExample/ViewController.swift:26 was not called on the main thread
2022-01-17 14:43:40.786070+0000 MainActorExample[4407:7439502] warning: data race detected: @MainActor function at MainActorExample/ViewController.swift:26 was not called on the main thread
@MainActor var - main: false
warning: data race detected: @MainActor function at MainActorExample/ViewController.swift:31 was not called on the main thread
2022-01-17 14:43:40.811866+0000 MainActorExample[4407:7439502] warning: data race detected: @MainActor function at MainActorExample/ViewController.swift:31 was not called on the main thread
@MainActor func - in doWork - main: false

This does show runtime a bunch of warnings.

‘cannot use parameter ‘self’ with a non-sendable type ‘ViewController’ from concurrently-executed code’
and

‘cannot call function returning non-sendable type ‘Date’ across actors’

I’ll experiment some more…

Update 2: Models get errors!

Moving the exact same code out of the NSViewController and into a separate class (that inherits from nothing) causes the expected compiler warnings to kick in.

It seems that my model is ‘Sendable’ – so now the compiler can do magic.
This feels like an important limitation that should be in BIG BOLD LETTERS in the documentation…

Interestingly, when this code is moved to a separate model, calling self.mainDate without async causes an error.
However in the original ViewController version – adding an async (async self.mainDate) generates a warning

‘no ‘async’ operations occur within ‘await’ expression’

Update 3

This bug looks like the same setup

Update 4 – May 2022

Running the example code in Xcode 13.3.1 on OSX 12.3.1 and the code behaves as expected.

I don’t know if this is a compiler fix (so newly compiled apps are fine), or an OS fix (so your app will only behave unexpectedly on older OS’s.)


Example code available at https://github.com/ConfusedVorlon/MainActorExample