@MainActor was introduced introduced in Swift 5.5 by SE-0316 as a way to tell the compiler that certain code should always be run on the main thread. However, we have seen some cases where functions annotated with @MainActor
were still being called on a background thread. This post explores what was happening, and how it will get better in the future.
TL;DR – Swift is still in the middle of a multi-year migration to full concurrency checking. Existing code doesn’t enforce the concurrency constraints as strongly now as it will in future versions of Swift, in order to give people time to migrate their code.
What’s up with concurrency and closures?
Swift 5.5 introduced formal concurrency to the language. Prior to this, the compiler had no concept of “concurrency”. For example, consider this code:
var x = 0
DispatchQueue.main.async {
x = 1
}
DispatchQueue.global().async {
x = 2
}
We can’t be sure whether x
will end up with a value of 1
or 2
here, because there is a race condition. However, the compiler doesn’t know about the race condition. As far as the compiler is concerned, it sees this:
var x = 0
let closure1 = { x = 1 }
let closure2 = { x = 2 }
DispatchQueue.main.async(execute: closure1)
DispatchQueue.global().async(execute: closure2)
Note that there is no indication that closure1
and closure2
are doing anything tricky; they’re just modifying a variable. There’s nothing inherently unsafe about them. For example, they could be safely used like this:
var x = 0
let closure1 = { x = 1 }
let closure2 = { x = 2 }
closure1()
closure2()
// We absolutely know that x == 2, because
// we *know* closure2 ran after closure1
However, when we pass these closures off to DispatchQueue.async
, then magic things happen. These closures start running simultaneously with each other, and there is no indication to the compiler that this is going to happen. The compiler can’t predict it. How was it supposed to know that DispatchQueue was going to do sneaky things 😱 with that closure?
Even if we were to teach the compiler that DispatchQueue does sneaky things, we could hide that sneaky magic inside some other function, and the compiler would be back in the dark:
func runSomehow(_ closure: @escaping ()->Void) {
if Bool.random() {
closure()
} else {
DispatchQueue.global().async(execute: closure)
}
}
var x = 0
runSomehow { x = 1 }
runSomehow { x = 2 }
In this case, the compiler has no idea that runSomehow
might run the block concurrently with any other code. All it can see is a function that takes a closure. The only way to prevent this would be to prevent calling any @MainActor
functions unless it knows that you’re running on the main actor. That is indeed the eventual goal, but doing so right away would have broken pretty much all existing code that uses callbacks. So instead, Swift chose the conservative approach of only complaining when it knows that you’re calling from concurrency-aware code and breaking the rules.
In order for the compiler to do better in the future, it needs to have more information.
Marking concurrency-safe closures with @Sendable
The problem in the example above was that we took a closure which was safe to run synchronously and passed it to DispatchQueue.async
, which then scheduled it to run in some other concurrency context. Our closures were not safe to run on another concurrency context, but the compiler had no way of knowing that. (A “concurrency context” here means something like a dispatch queue, a Task { }
, or an actor).
We are currently in the middle of a multi-year transition in the Swift Language toward full concurrency checking by the compiler. If the compiler immediately started rejecting all calls to @MainActor
functions from code that was not itself annotated with @MainActor
, it would be massively source-breaking for pretty much every existing project out there. So instead, the concurrency checking is being staged in over multiple releases. You can read about the plan here.
The plan is that developers will mark their code to indicate which functions/closures/values are safe to send between concurrency contexts. This is done by marking closures and functions with @Sendable
, and by making types conform to the Sendable
protocol.
In Swift 6 (sometime down the road), it will be an error to pass any value to a different concurrency context unless it is marked as Sendable. This means that in the future, it will be an error to do this:
func callCompletion(_ block: @escaping ()->Void) {
DispatchQueue.global().async {
// error: Capture of 'block' with non-sendable type
// '() -> Void' in a `@Sendable` closure
block()
}
}
But you don’t need to wait for that future! In Swift 6, all Swift code will be checked for concurrency violations. In Swift 5.6 (Xcode 13.3), though, concurrency can be checked for any code that has shown that it is explicitly aware of concurrency, by using things like async
, @Sendable
, actor
, or @MainActor
in the function declaration.
So if we change our function definition to this:
func callCompletion(_ block: @Sendable @escaping ()->Void)
then the compiler will happily warn us when we try to call callCompletion
with something that shouldn’t be called from a background thread!
callCompletion {
// error: Call to main actor-isolated global function
// 'mainThreadThing()' in a synchronous nonisolated
// context
mainThreadThing()
}
@MainActor
func mainThreadThing() {}
The compiler is telling us here that it is not safe to pass this closure:
{ mainThreadThing() }
to callCompletion()
, because that closure is not safe to run in an arbitrary concurrency context.
So one way we can improve the safety here is by adding @Sendable
annotations (or @MainActor
annotations) to functions that take completion handlers. It’s work we’ll have to do anyway when Swift 6 comes out, so it’s not a bad idea to get a head start on it.
I’ll also note that there is a -warn-concurrency
flag that we can set in Other Swift Flags
, which is intended to issue warnings for anything that will become an error in Swift 6. However, in current releases, it seems to miss common cases like the one shown in callCompletion
above. I asked about it on the Swift Evolution Forums, and it seems like that’s a bug that should be fixed.
What should I do now?
For now, be aware that @MainActor
does not currently force existing code to run on the main thread. Rather, it is an annotation that enables other code to opt in to compiler enforcement. By marking a function with @MainActor
, you tell the compiler to enforce that other concurrency-aware code must call it on the main thread. There is a huge amount of existing of non-concurrency-aware code, though, and @MainActor
does nothing for those cases yet, because doing so would cause far too many warnings even in cases that are actually safe.
If you’re calling @MainActor
code inside a old-style completion handler, you still need to explicitly hop back to the main thread for now. Where possible, convert completion handlers to async
functions or add @Sendable
or @MainActor
annotations to your completion handler callbacks.
You can also try turning on -warn-concurrency
. (In Xcode 13.3.0, it is actually spelled -Xfrontend -warn-concurrency
.) It provides a preview of how your code will compile under Swift 6, enforcing the concurrency-safety mechanisms in all functions, not just those that have opted into it. It warns you about code patterns that will become errors in Swift 6.
Once your own code is using concurrency annotations, you may get warnings or errors when using external libraries which have not yet adopted Swift Concurrency. In this case, you can use @preconcurrency import
to indicate that the compiler should not warn about concurrency errors originating from that library. (See SE-0337 for more details.)
Finally, note that there was a compiler bug in Swift 5.5 that could sometimes cause concurrency-aware code to not correctly hop to the main actor even when correctly called with await
. This has been fixed in Swift 5.6 (included in Xcode 13.3), so I’d recommend upgrading to the latest compiler when possible.