I am updating the build settings in a project to Targeted Strict Concurrency Checking and am seeing warnings with a design pattern that we use frequently. The pattern involves a ViewModel with view-driving Published properties isolated to the MainActor and an asynchronous function called off the MainActor that needs both to access and update those properties:
class ViewModel: ObservableObject {
@MainActor @Published private(set) var value: Int = 0
func process(data: Int) async {
let newValue = await value + data
await MainActor.run {
value = newValue
}
}
}
The compiler gives me the following two warnings:
On the await value: Non-sendable type 'ViewModel' passed in implicitly asynchronous call to main actor-isolated property 'value' cannot cross actor boundary
In the await MainActor.run closure: Capture of 'self' with non-sendable type 'ViewModel' in a @Sendable closure
I can address these warnings by adding @unchecked Sendable conformance to the ViewModel, and I'd like to ask here if I am addressing the Sendable requirement correctly by doing so.
According to the docs "Reference types that internally manage access to their state" are sendable. When I use the MainActor to manage access to the value property, I am exactly doing this, right?
My question: is there a problem using the MainActor to manage access to mutable state like this, especially when that state already needs to be isolated to the MainActor since it drives UI, and is there some other way I should be doing this?
A couple alternatives I've considered:
Isolate the entire ViewModel to the MainActor
Separate out the process method into some other object, probably an actor
I can't isolate the entire ViewModel to the MainActor because I need the potentially long-running process method to execute off the MainActor. Separating out the process method to another object is an option, but I end up duplicating many of the properties (eg value) and still have to coordinate between the two objects. This just seems like extra effort that is unnecessary if using the MainActor to manage access to mutable state is appropriate.
It is kinda official recommendation from Apple to annotate type as whole to be @MainActor isolated. So in general you’d better make whole view model to run on main actor (which is reasonable since this is a view model). It will also greatly simplify the code.
Move out this long running process to one of these things:
nonisolated function inside view model
separate nonisolated type (might be tricky)
separate actor
Which will address the issue of only the thing that should not be on main actor will be called from other isolation, plus better separation of the code.
Another option is spawning an internal detached task and awaiting its value:
@MainActor // or the whole class
func process(data: Int) async {
let input = self.value // access synchronously on main actor
let newValue = await Task.detached {
// runs on the global executor
return doLongRunningStuff(input)
}.value
self.value = newValue // write synchronously on main actor
}
The detached task will not clog up the main actor, and you will be getting the benefits of reading and writing the input/output synchronously, which helps with reasoning about transactionality of the operation. Although if you only read and write arguments and return values, a nonisolated function works just fine.
You can also simply move the function out of the type; global async functions are nonisolated by definition.
I don’t know what processing you’re actually doing, but this looks like a race condition to me. If value is updated between the first and second line, you will overwrite that value on the third line. And if you call process multiple times concurrently, there’s no guarantee that the last one to read from value will be the last one to write to value.