Search code examples
swiftswiftuiswift-concurrency

Should I declare a 'nonisolated' or 'async' function in my 'MainActor' view model for work that will run inside and outside of the MainActor context?


Intro

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.

Option 1

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()
}

Option 2

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()
    }
}

The Question

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?

Update

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.


Solution

  • In answer to the question regarding how to get this off the current actor, there are a variety of approaches:

    • move it into its own 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;
    • have it launch a 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.; or
    • make it a non-isolated async 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:

    1. 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.

    2. 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.