You're very welcome, I'm glad I could help your understanding a bit!
No, not quite. First, there are actors, the (relatively) new reference type that isolates state that will be shared among different execution contexts. Next, you can make actors adopt the GlobalActor
protocol and annotate them with the @globalActor
attribute, like so:
@globalActor
final actor MyGlobalActor: GlobalActor {
static let shared = MyGlobalActor()
}
This defines a new attribute that you can use to annotate other, non-actor types, like so:
@MyGlobalActor // Annotating the entire type is the same as annotations each individual function/property
final class MyClass {
func foo() { } // You could also only annotate individual functions or properties in the class.
}
This in effect makes the annotated types (or the relevant annotated functions/properties) isolated on the global actor. That means, in effect unless you access them from a place that is already in that actors isolation (i.e. another function annotated with it or a function defined on the actor itself), you have to await
them (even if they're not async
!).
Now, the @MainActor
is one (read: 1) such global actor the system already provides you with. It is a little special in that it also uses a custom serial executor that ensures to isolate everything to specifically the main thread. You could theoretically implement such a thing yourself, but there's no need for that, usually.
The usefulness of the main actor is to ensure that code that must run on the main thread (usually the underlying rendering logic for a UI system like UIKit or SwiftUI) does exactly that. This is why the relevant types are usually annotated with @MainActor
, like @imany's ProductViewModel
. Note that this does not violates @GreatOm's excellent article (in fact it does follow their suggested pattern).
In way, such types become "almost" actors, as in they share the annotated actor's isolation domain, but they, of course, stay their own type.
To your question:
This is difficult to generalize, I think.
First off, there's another question "hidden" in that: Besides thinking about being on the main actor or not, you need to also consider whether you're in an asynchronous context or in synchronous code.
If it's the latter, you basically have to start an unstructured Task
. And in 9 out of 10 scenarios you don't have to start a detached one, btw. SwiftUI already offers a view modifier for this (.task
and .task(id:)
).
That Task
will inherit the isolation context. So if your calling context (i.e. the function where you start the Task
) is on the main actor, the new unstructured Task
will inherit that. Inside that task you can then use the structured concurrency mechanisms (async let
, task groups, generally call await
things). You may think "But I need to do some stuff off the main actor!" and I will get to that in a second. The important part is that some of the asynchronous methods you call may, in fact, switch the isolation context (they are isolated to another actor). This is what you will want to design, ultimately.
One way to do so is, in fact, using an actor (as in your own defined actor type, like actor MyActor { ... }
). Async methods defined on that will run in that actor's isolation context.
The problem arises when you use async methods that are not defined to run on a specific actor (those are nonisolated
functions). The exec
and getAll
methods from the initial code example are like this.
As I explained those (implicitly) get their instance passed as a parameter so they can refer to self
in their body. But if that instance was originally created in a different isolation context (like in the example) and its type is not Sendable
, that's problematic.
Actors or types annotated with a global actor are (implicitly) sendable, so that is one option to get this done. A different approach can be to eliminate the need to pass the instance to the non-isolated function, for example by using a static
method. That's often trivial if the function doesn't actually need any state data from the related type at all, or only a subset of sendable data I can pass individually as regular parameters.
There's various ways to approach this, but I don't feel comfortable to say there is "one best practice". In my mind it depends too much on what you're doing and what the used types (which may come from a library not under your control) do.
One word of warning though: Using Task
(whether a detached or one with an explicitly global actor annotated closure parameter) to "escape" one isolation context doesn't help you against the problem of sending an instance. In the example above that is basically the reason why simply wrapping the call to execute
in a Task.detached
does not work: You still have to access the getProductsUseCase
, i.e. pass that to the new task's isolation context, and that is the same problem as passing it to execute
.