SE-0316: Global Actors

I'm not sure how I should ask my question.

Often, when you develop UIKit apps, you need both to make sure some code runs on the main queue, and to avoid a queue hop (DispatchQueue.main.async { ... }) if the caller is already running on the main queue.

Really?

Yes! People do check for Thread.isMain, or for the main DispatchQueue (with specifics), in order to preserve a synchronous behavior when possible!

Come on, this thing is so subtle, and so important, that RxSwift has three ways to specify how you want your code to be dispatched on the main queue:

  • MainScheduler.asyncInstance: always after a queue hop
  • MainScheduler.instance: always after a queue hop, unless the caller is already on the main queue, and the scheduler is not called in a reentrant way.
  • ConcurrentMainScheduler.instance: : always after a queue hop, unless the caller is already on the main queue.

Talk about a crucial feature!


Guarantees of synchronous calls are critical in order to avoid unwanted visual glitches.

Let's give an example. Say you want to push a view controller that displays information about a book. It is crucial that the book is available before the push animation starts, or you may see a flash of an empty screen right in the middle of the push animation. This flash is the sign of an unwanted DispatchQueue.main.async { ... }.

You'll ask: "but why don't you just load your book before pushing your screen?"

I'll answer: this muddles responsibilities. Sometimes you really want to encapsulate the book screen and its data source / view model / store / whatever as a unit that is fully responsible for loading and displaying the needed data. So you do not want the previous screen, the one from which you navigate to the book screen, to be responsible for getting a book value before pushing the book screen. Instead, you want the book screen to be autonomous, and so you need to make sure it is able to grab the book synchronously, in order to avoid the visual glitches I described above.

The consequence is that it would be really not good at all if undesired dispatch queue hops could happen when two @MainActor functions / classes / closures call one each other, or when undecorated code that happens to run on the main queue calls a @MainActor function / class / closure.

To me, this is the difference between a tool that is gorgeous and does what I need, and a tool that stabs me in the back after I've spent hours and days refactoring my code for the new shiny fad. There is nothing worse than discovering that the tool can not do what I need (it's not that it is difficult, it is that it is impossible, because the tool does not understand what people need).

Please remember that UI programming sure needs guarantees to run on the main queue, but it also needs a lot of synchronous code.

Is this topic addressed in the proposal? Am I alone feeling somewhat nervous?

2 Likes

I believe that at least the first part of your concern here is addressed in the proposal (emphasis added):

Any declaration can state that it is actor-isolated to that particular global actor by naming the global actor type as an attribute, at which point all of the normal actor-isolation restrictions come into play: the declaration can only be synchronously accessed from another declaration on the same global actor, but can be asynchronously accessed from elsewhere.

Calls/accesses between two declarations isolated to the same global actor are synchronous—there is no potential suspension.

1 Like

Can we read a "can" as a "must"?

I wonder if synchronous calls, in the spirit of the proposal, are more than an optimization.

I'm not after an optimization, but a reliable feature.

And it's OK if global actors don't provide synchronous guarantees. I just prefer to know it upfront, so that:

  • I can avoid them when they can't address my needs
  • I can report a feature request to the language authors.

:-)

The async/await model here guarantees synchronous access from what I understand. If I'm able to call funcOnTheMainActor() without an await, then there's no potential suspension point and so the call is guaranteed to be synchronous.

The "can" here, I believe, should be read as a "can" for the user, rather than the language runtime. Synchronous access (or not) is something visible in the source, and therefore something the user has control over. If you're not writing await, the access is synchronous. The global actors model enforces rules such that attempts to write a synchronous access to a global-actor-isolated declaration from a different global actor (or none at all) is a compile-time error. But from the same global actor, users "can" write a synchronous call and have it compile. (Of course, users "can" also write an asynchronous access to the declaration as Task.detach { await funcOnTheMainActor() }, so "must" doesn't seem appropriate to me.)

I see what you mean. Yes, this looks like a guarantee indeed, thank you! :+1:

Maybe the proposal authors will add extra information, around the fact that we also need synchronous guarantees in the interaction between @MainActor code, and other code that happens to run on the main executor.

Sometimes you want to know if a problem is fully acknowledged by the proposal authors, or a mere detail lost in a sea of indirect consequences.

2 Likes

To add to this - I believe this is covered in the 'reentrancy' section of the main Actors proposal. Suspension can only happen if there is an await in the code. Swift Actors are re-entrant, and therefore have the same issue (phrased in the proposal as possibly breaking invariants). The invariant in UI code is whether the run loop has a chance to spin and start a new Core Animation transaction.

I have a couple of detailed questions below, but I am overall +1 on this proposal.

That said, there is a caveat on this - the proposal isn't clear what happens with unannotated global variables, and the decisions made here have a profound impact on the language, user model, and migration story for Swift Concurrency. There are two ways to handle this: 1) pick a design and try to wrestle this to the ground in this proposal, or 2) land the general design for Global Actors in this proposal, then drive a new "Swift Concurrency meets Global Variables" proposal as a follow-on.

Beyond that major issue, I also have a serious concern about the inference rule for closures (mentioned below). Overall though, this is very nice work. Some train of thought notes below:


The approach of using a user-defined attribute is very nice. I also agree that @MainActor is a natural name for this thing, aligning with the precedent set by the @main attribute. The ability to mark both global vars and functions is very nice.


The approach described in Defining Global Actors is very sensible. What is done to prevent creating other instances of the actor? I think we need to prevent this somehow (for memory safety), perhaps with a dynamic check?


In Using global actors on functions and data, it makes total sense to be able to mark methods as pinned onto an actor. How does this interact with API resilience/evolution?

Can protocol requirements be marked as @MyGlobalActor? (later: yes apparently according to the inference section). It would be great to mention this earlier in the proposal.


Global actor function types: nice. The subtyping rules look great.


In Closures, I'm not sure I understand the last bit:

Are you saying that the @MainActor aspect is inferred from the type of callback or are you saying that it is inferred from the type of globalTextSize which happens to be uttered inside the closure?

The former is super sensible to me, the later is very scary and I think it is unprecedented. The consequence of that is that changing the body of the closure will change the inferred type of the closure, which could lead to downstream changes.

The only thing I'm aware of close to this is inference of the throwing and async bit on the closure. However, that inference is done syntactically and can be done before name lookup. This is a different beast.


Global and static variables

I'm +1 on allowing globals to be tagged, this is the core part of the model. However, I'm not sure what is being proposed:

The phrasing "we can require" makes it sound like a decision hasn't been made here, and this is a key part of the design that has to be nailed down.

I think there are some non-controversial cases to cover:

  1. Explicitly annotated globals are obviously ok and tied to a global.
  2. Following the precedent of the actor proposal, we should allow @Sendable lets to be usable within the module they are defined in.
  3. We could extend #2 to @frozen or some other attribute for cross-module let's.

The question is, what do do about unannotated vars, public lets that are used-cross module, etc. I see a few different options with different tradeoffs, all of which provide memory safety:

  1. Reject them as a compile time error. This will have massive source breakage and isn't a very friendly model.
  2. Codegen their accessors to produce a runtime error when accessed from a Task (either structured or actor), but allow access from pthreads an other legacy code. This is source compatible and provides progressive migration path, but is a footgun. However, this will be a much easier to detect and fix problem than optional unwraps and array out of bound errors that cause similar traps.
  3. We could codegen their accessors into implicitly async accessors, sort of treating each global variable as if it is on an anonymous global actor. This would also be a major source break (because you would only be able to touch these in async functions) but would provide nice model over time.
  4. We can codegen globals to be uninherited unstructured task local values, which don't require @Sendability. This would fill in a hole in the design space but more importantly, would provide full source compatibility for Swift concurrency. They do have somewhat new semantics that we'd have to teach, but I think the benefits outweigh the costs.

Coming back to the major point though, I don't know what the proposal is proposing here :-).


What is the proposals model for playgrounds and scripts? We obviously shouldn't have boilerplate annotations like @MainActor on every top level variable. How does this work / how does the proposal handle this?


At first blush, the inference rules look ok. However, I didn't really think deeply on them and consider all the angles. The meta question I'd think about is: how does this work with resilience and API evolution? Is this all consistent with what we do there?


In Source Compatibility, this claim is made:

Global actors are an additive feature that have no impact on existing source code.

This is true of the core feature, but the handling of global variables is complicated and is almost certain to be source incompatible in some ways. This is the core issue in this proposal.

-Chris

6 Likes

I added a fourth option to the list above, which I think is also worth discussing. In any case though, I think that the exact fate of Swift global variables is both complicated and separable from the base "Global Actors" proposal here, and should likely be split to its own discussion.

-Chris

1 Like

I feel like synchronous guarantees between @MainActor code are well-covered, since they're identical to synchronous access in the general actor model. What "other code that happens to run on the main executor" are you thinking about? The operation passed to DispatchQueue.main.async? Objective-C code?

We're not doing anything to prevent other instances of the actor, and I'm not sure we need to: only the instance returned by shared is special. One could presumably create some kind of shared implementation that produces a unique actor instance at each call, and we'd lose the guarantee that everything is getting serialized properly, but that doesn't seem like something we should defend against.

You can't add a global actor annotation because you'll break any caller that isn't on that global actor. Removing a global actor annotation is okay in some cases---but not from anywhere that one could infer from. The proposal states a more restrictive requirement because the latter is a bit hard to describe well:

A global actor attribute (such as @MainActor) can neither be added nor removed from an API; either will cause breaking changes for source code that uses the API.

Sure, I'll update the protocol text.

The former. Without this inference, the write to globalTextSize would be ill-formed.

The intention of the proposal is to require this, but it's a point that absolutely needs discussion. I think the issue of unannotated global/static variables is large enough that we should subset it out.

Unclear! The current model of "global" variables in playgrounds and scripts isn't even memory safe without concurrency in the mix, so we need a larger rethink here. If we treat global variables within scripts/playgrounds as if they were locals, there's nothing to do because they fall under the local capture rules.

Unrelated to any of the above, @John_McCall and I were talking about turning the ad hoc protocol for global actors into a formal protocol, e.g.,

protocol GlobalActor {
  associatedtype ActorType: Actor
  static var shared: ActorType { get }
}

It helps formalize the relationship between global actors and actors. We'd then extend the protocol with custom executors to eliminate the "hop" through the actor, e.g.

protocol GlobalActor {
  associatedtype ActorType: Actor
  static var shared: ActorType { get }
  static var sharedUnownedExecutor: UnownedExecutorRef { get }
  // note: we require that shared.unownedExecutor == sharedUnownedExecutor
}

Doug

2 Likes

I think we should use a formal protocol for global actors and suggested it in response to the first pitch [Pitch] Global actors - #6 by anandabits. This would ideally allow code to be written that is generic over global actors:

@MyActor
class MyClass<MyActor: GlobalActor> { ... }

I have a pre-existing library that uses a coarse-grained actor approach where many actor-bound objects run in the context of a single actor. This is encoded in the type system such that objects sharing an actor can be composed while those in the context of different actors cannot. The above generic global actor technique should make a straightforward port of the library to Swift’s actor system possible.

That protocol looks good!

Would we'd provide a default impl for sharedUnownedExecutor right? (shared.unownedExecutor)

Yes, everything which is not marked @MainActor: UIKit callbacks and overridden methods, existing libraries and code bases. I don't quite have a clear idea of the amount of @MainActor annotations that will have to be added, and the amount of interfaces that will have to create suspension points, and potential loss of critical synchronous calls.

1 Like

I have another question. The code shipped in the wild is littered with dispatchPrecondition(condition: .onQueue(.main)) and assert(Thread.isMainThread).

Is there any risk that those assertions are trigged by code that runs on the main actor? Is there a recommendation for developers of libraries whose APIs can talk to both clients who use the main actor, and clients whose code can't use the new runtime (how to rewrite those assertions in a way that is compatible with both environments)?

I am so sorry that I missed/forgot that you had brought this up before! Name lookup for @MyActor looking within the generic parameter list that comes later is a little bit odd, but this seems like a powerful pattern for custom attributes in general and I think it does make sense here.

Yes.

(Objective-)C entities can be marked as being on the main actor with, e.g., __attribute__((swift_attr("@MainActor"))). It's not enforced from the Objective-C side, of course, but it lets C APIs make the promise that they'll always be used from the main thread.

@MainActor code always runs on the main thread/main queue. When you await because you're referencing a @MainActor entity from a non-@MainActor context, it's doing a DispatchQueue.main.async.

Within the confines of this proposal, the only recommendation is "add a new @MainActor API and deprecate the old one". The implementation actually has some affordances for the use case you describe---essentially, it one can state that an API should be on the main actor, but only enforces that property from other code that has adopted Concurrency features (actors, async, whatever). We were planning to bring up those ideas to make it easier to evolve toward concurrency in a separate discussion thread, later, to tackle interoperability between pre-Concurrency and Concurrency code holistically.

Doug

5 Likes

Thank you :+1:

I’m also +1 on the GlobalActor protocol, but wonder if we’d be able to write:

struct T<Actor: GlobalActor> { 
  @Actor a: Int
} 

Other than that, the feature looks very well thought out! I can't wait for it to land in an official release!

Sorry to double-post, but two thoughts on this section:

A closure can be explicitly specified to be isolated to a global actor by providing the attribute prior to the in in the closure specifier, e.g.,

callback = { @MainActor in
  print($0)
}

callback = { @MainActor (i) in 
  print(i)
}
  1. There's no formal grammar specification in the proposal, so it's not clear: if the closure has a capture list, does it come before or after the attribute? (I think I may have brought this up once on Twitter or something; it must have been lost in the shuffle.)

  2. Can we extend this to other attributes that can be applied to closures, in addition to global actors? Result builders are the one that comes to mind immediately, but @Sendable and @escaping might also be useful in niche situations. (Maybe we should sever this from the global actors proposal, though.)

1 Like

Just to follow up on this, I've posted a writeup on how to manage the incremental rollout of Concurrency throughout the ecosystem, which provides some affordances for handling both pre-Concurrency and Concurrency-adopting clients. Let's chat details over there.

Doug

[EDIT: Added the link to the writeup.]

2 Likes

Thanks everybody for your feedback during this review. The core team has returned the proposal for another round of revision.

3 Likes
Terms of Service

Privacy Policy

Cookie Policy