I have a view model declared as follows:
@MainActor class ContentViewModel: ObservableObject {
...
}
I want to add a function to this model which does a mixture of work inside and outside of the MainActor context.
I've run some experiments and I've come up with two options which achieve this, as described below.
Mark the new function in the view model as nonisolated
and initiate a Task within the function, as follows:
nonisolated func someFunction() {
Task {
// Do work here.
// Use 'await' to make calls to the 'MainActor' context
// and to make other asynchronous calls.
}
}
Call on this function in the View as follows:
Button("Execute") {
viewModel.someFunction()
}
Mark the new function in the view model as async
and omit the Task initiation, as follows:
func someFunction() async {
// Do work here.
// No need to 'await' to make calls to the 'MainActor' context
// given that this function runs in the 'MainActor' context.
// Use 'await' to make asynchronous calls
// and to free up the 'MainActor' whilst those calls execute.
}
Call on this function in the View as follows:
Button("Execute") {
Task {
await viewModel.someFunction()
}
}
Do either of the two options above have advantages over the over or are they both functionally the same? Is there an alternative option which is more idiomatic to Swift and SwiftUI, and offers advantages over the two options above?
When I asked this question initially, I was under the impression that I had to use await
to make calls to the MainActor
context from someFunction()
in Option 2 above. That is incorrect.
I have edited the comment in the code block in Option 2 to remove this incorrect assumption so that I do not mislead anyone in future who stumbles across this question.
In answer to the question regarding how to get this off the current actor, there are a variety of approaches:
actor
… this is preferrable if there is some shared mutable state or need to prevent parallel execution resulting from multiple calls, but is possibly overkill if the intent is just to get a single function off the main actor;detached
task … this is a simple way to get work off the current actor, but entails unstructured concurrency, encumbering the author with the burden of manual cancelation handling, etc.; orasync
function, as discussed in SE-0338 … this keeps us within the realm of structured concurrency and the benefits that confers, while getting us off the current actor.This begs the question as to whether you need to get someFunction
off the main actor at all. The fact that this function will subsequently “make calls to the MainActor
” suggests that you should keep this function on the main actor, too.
So, there are a few considerations regarding ensuring that someFunction
never blocks the main actor:
If this code will simply await
other async
methods, then there is no concern about blocking the main thread/actor. As soon the task encounters an await
suspension point, the current task is suspended, but the current actor (i.e., the main actor) is freed to perform other tasks while the asynchronous code runs. The current thread/actor will not be blocked.
For example, consider the following:
func someFunction() async {
let response = await someThingElse() // this is asynchronous and will not block
self.objects = results.objects // now update property isolated to the main actor with no `await`
}
If you await
other async
methods, it now becomes immaterial that someFunction
happens to be isolated to the main actor or not. As soon as someFunction
encounters an await
suspension point, the current actor is freed to do other tasks while the asynchronous work is underway. As you can see, if someFunction
later needs to do something on the main actor, this makes its intent clear and simplifies your code.
If someFunction
performs any slow and synchronous work (e.g., file i/o, etc.) before continuing to do more on the main actor, then move that synchronous work (and only that work) into its own function. You can get this new function off the main actor (e.g., a non-isolated async
function, a detached task, its own actor, whatever). But once this new function with the synchronous code is off the main actor, you can then keep someFunction
on the main actor, await
its call to this new function. This simplifies someFunction
, making its intent clear (that it will use the main actor at some point), but gets the slow and synchronous work off the main actor.
With this pattern, again, the main actor will not be blocked and only the slow, synchronous code is moved off the current actor.
As an aside, I would avoid unnecessarily introducing unstructured concurrency with Task {…}
. With unstructured concurrency, you bear all responsibility for handling task cancellation. For example , note that if you discard the resulting task reference, as you have in option 1, it “makes it impossible for you to explicitly cancel the task.” Properly handling cancelation with Task {…}
requires extra work; if you remain within structured concurrency, you get these behaviors for free. Avoid unstructured concurrency, if you can.
I would also suggest that you also avoid introducing detached tasks, too, unless absolutely necessary, as that also introduces unstructured concurrency. As the documentation says:
Don’t use a detached task if it’s possible to model the operation using structured concurrency features like child tasks. Child tasks inherit the parent task’s priority and task-local storage, and canceling a parent task automatically cancels all of its child tasks. You need to handle these considerations manually with a detached task.