Calling an actor method in a sync context: which method is better?

I was wondering, what is the difference between these two calls?

// A.
func syncFunc() {
    Task {
        await MyActor.shared.anotherSyncFunc()
    }
}
// B.
func syncFunc() {
    Task { @MyActor in
        await MyActor.shared.anotherSyncFunc()
    }
}
  1. What are the benefits of one or the other? Performance or otherwise.

  2. Why does the compiler require await in case B? My understanding is in B there will be two hops for no good reason it seems.

1 Like

I believe the compiler should be able to remove the extra hop to the global executor from the first one, so (without having checked) I would expect these to be the same

assuming the following:

  1. the MyActor API looks something like this:
@globalActor
actor MyActor {
    static let shared = MyActor()

    func anotherSyncFunc() {}
}
  1. there's no configuration that causes syncFunc() to be inferred as actor-isolated

then, to address these in reverse order:

the await is required because the compiler does not currently piece together the fact that @MyActor and calls to actor-isolated methods of the instance returned by MyActor.shared have the same static isolation, so it thinks there is an isolation crossing (hence possible suspension point). if you mark the anotherSyncFunc() call as @MyActor, then you will no longer be required to await it from contexts that are statically known to have the same isolation. granted, if you actually have any state the actor operates on, that will also need to be marked with the same global actor to avoid the same issue within the actor methods themselves (which perhaps means you're effectively dealing with global state isolated to the singleton actor instance, which can be modeled in various ways).

using the global actor annotation in the closure's signature can improve the amount of static reasoning the compiler is able to do, which may lead to fewer annotations being needed (e.g. as in the previous case), and changes actor inference propagation in some instances. additionally, its use in this scenario means that Task.init can synchronously enqueue the work directly on MyActor.shared's executor, without first having to potentially 'hop off' an existing actor's executor to do so. this means it may end up being a bit more efficient at runtime, but perhaps more importantly, it also means you won't lose certain ordering guarantees about when the closure will actually be scheduled relative to other things – after the Task.init returns, the global-actor-isolated work will be enqueued.

1 Like

Thanks for your detailed answer.

This is something new for me! Aren't global actor methods isolated to their actor? Why do I need to annotate them additionally? (And yes, I tried it and it works, await is no longer needed in my case B)

In my particular case I have methods that perform a lot of disk I/O, so they aren't required to be async but by declaring them in a global actor I was hoping to have them executed on a separate thread. So this isn't the case?

if you have a declaration like:

@globalActor
actor MyActor {
    static let shared = MyActor()

    func anotherSyncFunc() {}
}

then the instance method anotherSyncFunc() is isolated, but it is isolated to an instance of MyActor, not necessarily the shared instance. that is why the await is required in your original example even in the @MyActor closure. if you wish to use a type for which all its methods are isolated to the shared @MyActor instance, one way you can do so is via an appropriately annotated class like:

@MyActor
final class MyDiskIOManager {
  func anotherSyncFunc() {} // isolated to `@MyActor`
}

declaring them in a global actor will cause them to run on the actor's executor. by default that is the system-provided executor serviced by the concurrent thread pool. since you're dealing with IO, it's possible you might want your own custom SerialExecutor implementation (that could then be attached to MyActor via its unownedExecutor property) so that blocking IO calls don't clog up the shared thread pool (could be backed by a single thread or dispatch queue, or whatever makes sense in your context).

2 Likes

Now it makes a lot of sense!

I was actually suspecting that global actors are really useful only for annotation, i.e. you declare a "stub"

@globalActor
actor MyActor {
    static let shared = MyActor()
}

and use annotations where appropriate. But then I thought I'm probably doing something wrong, because having only global actor stubs in the code just doesn't look right.

But it seems to me that's how they should be used most of the time. I wish there was a shorter way of declaring them in Swift!