I have recently started using async
/await
(in an existing, Non Swift-UI codebase).
Two things that I haven’t been able to get an answer to (neither by reading nor experimenting) is whether its wrong to
await
something on the@MainActor
and
Access properties inside a Task/other Actor
In my experience, accessing properties from different Threads can cause race conditions and shouldn’t be done, and performing blocking operations on the Main thread blocks the UI and shouldn’t be done.
Yet, I see a lot of code like what I have tried to isolate in the (fully runnable) example below.
So in loadStuff1
, we may or may not be on the Main Thread (depending on whos calling). Its accessing two properties, stuffLoader
and param
and they are probably read/written to from the Main Thread. Will this, as I expect, lead to Race condtions? What do you pros do instead?
In loadStuff2
, we are definitely on the Main Thread. Is it safe to await stuff here? If it is, then I guess this is the way to go, but I havent seen any statement about await not blocking the Main Thread.
In loadStuff3
we have started a Task, which in my understanding moves our execution to another thread, which will never be the Main Thread. Just as in loadStuff1
I expect this to produce race conditions.
import PlaygroundSupport
import UIKit
struct Stuff { }
enum Param {
case a
case b
}
class VM {
private let stuffLoader = StuffLoader()
var param: Param = .a
func loadStuff1() async -> Stuff {
// Is on the Main thread if the calling Thread is the Main Thread.
await stuffLoader.load(param: param)
}
@MainActor func loadStuff2() async -> Stuff {
// Is always on the Main thread.
await stuffLoader.load(param: param)
}
func loadStuff3(completion: @escaping (Stuff) -> Void) {
Task {
// Is never on the Main thread.
let stuff = await stuffLoader.load(param: param)
completion(stuff)
}
}
}
class StuffLoader {
func load(param: Param) async -> Stuff {
// Here, a network call occurs and returns in 1 second.
Stuff()
}
}
class VC: UIViewController {
let vm: VM
init(vm: VM) {
self.vm = vm
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
Task {
let stuff = await vm.loadStuff2()
print("Stuff 2 loaded.")
}
}
func onSomethingHappened() {
Task {
let stuff = await vm.loadStuff1()
print("Stuff 1 loaded.")
}
}
func onSomethingElseHappened() {
vm.loadStuff3 { stuff in
print("Stuff 3 loaded.")
}
}
}
let vm = VM()
PlaygroundPage.current.liveView = VC(vm: vm)
2
Answers
Overview:
await
, it is a potential suspension point, meaning it would suspend work on that thread (doesn’t block) and relinquishes the thread to the system.Safety checks to turn on
Strict Concurrency Checking
toComplete
to catch any warnings.Testing
Debug Navigator
Code
loadStuff2
StuffLoader.load
would make a network call for example:let(data, response) = try await session.data(for: request)
this would not block the thread it is running on, it would suspend and resume back.You ask …
No, it is not wrong to be on the main actor and
await
some other asynchronous function/task. It is the whole purpose ofasync
–await
, to offer us graceful patterns to manage dependencies between asynchronous tasks without ever blocking any threads or actors.Legacy API offer various renditions of
wait
orsleep
that are blocking and are therefore dangerous. Butawait
does not suffer these problems. Go ahead and useawait
from the main actor.(If you want to get into the weeds of what really happens when you
await
, what “suspension points” and “continuations” are, and what the Swift concurrency threading model is, check out WWDC 2021 video Swift concurrency: Behind the scenes.)You continue to ask …
This is the precise problem that Swift actors solve, to provide safe access to properties from different threads, without ever blocking any threads (including the main thread). I might suggest watching WWDC 2021’s Protect mutable state with Swift actors.
Bottom line, whenever you see
await
(e.g., when fetching a result from aTask
or Swift actor), that means that the caller is not blocked and it is free to go on to other tasks until the result is safely returned from the other concurrency context.And what’s so nice about the contemporary compiler, is that at compile-time (rather than the old pattern of waiting for a run-time crash or some TSAN warning), the compiler will tell you whether you need to
await
the result or not.So, bottom line, the compiler will tell you whether or not you are permitted to “access properties inside a task/other actor”, and when you must
await
and when you do not.In your code snippet, you show us an example of
VM
, which is not actor-isolated. So all bets are off and you lose any benefits of actor-isolation. You could make it anactor
rather than aclass
(conferring all those thread-safety benefits). Or, since view models generally exist solely to interface with a view, you can isolate it to the main actor, e.g.Whether you make it its own actor or isolate the class to the main actor (or some other global actor) is up to you. But view models generally exist solely to update and interface with the view, which needs to occur on the main thread. So, I generally designate my view models as
@MainActor
, but my various services/helpers as their ownactor
.