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.
link
this proposal introducesMainActor
as a global actor describing the main thread. It can be used to require that certain functions only execute on the main thread
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:
https://www.swiftbysundell.com/articles/the-main-actor-attribute/
all that we really need to know is that this new, built-inactor
implementation ensures that all work that’s being run on it is always performed on the main queue.
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
- in doWork(), it would return off the main thread after Background().go()
- @MainActor annotation would ensure that calling self.mainDate would happen on the main thread – or there would be a compiler error
- @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
-swiftbysundellactor
implementation ensures that all work that’s being run on it is always performed on the main queue.
The magic of
-hacking with swift@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.
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…
If you’re writing Swift concurrency code, add these compiler flags:
-Xfrontend -warn-concurrency -Xfrontend -enable-actor-data-race-checks
(in Xcode: Other Swift Flags)
Warnings in Swift 5.5 identify unsafe constructs, will become errors in Swift 6. https://t.co/kWEGwlH0rR
— Ole Begemann (@olebegemann) July 30, 2021
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.
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