SE-0316: Global Actors

It seems the definition of global actor hasn't changed from last pitch:

@globalActor
public struct MainActor {
  public static let shared = /* unspecified actor type */
}

I think the author may forgot to update?

2 Likes

Thanks for catching that. I will correct the proposal text.

+1

Is there a way that nonisolated could be modeled as a global default actor that is implicit in pre actor Swift code? A default global actor for non annotated code could also address the static variables issue. Has something like this been explored ?

Thanks,
Chéyo

Under the “Closures” section, is there preexisting syntax for this, or is there a typo using $i instead of i?

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

That's a typo.

It's a typo, thank you! Fixed here.

nonisolated should mean that it's accessible from everywhere, synchronously, so we cannot put global variables there by default. A global/static let of Sendable type could be accessed synchronously from within the module (in line with the recent amendment to SE-0306).

Mutable state cannot be nonisolated at all, unless we invent something like the nonisolated(unsafe) that was part of earlier revisions of SE-0313.

Now, we could consider putting mutable global/static variables on a specific global actor. For example, we could say that unannotated global/static variables are implicitly @MainActor, and perhaps even say that top-level code is on the main actor to reduce friction there.

Doug

1 Like

A class can only be annotated with a global actor if … the superclass is NSObject .

This wording seems to preclude the @MainActor class IconViewController: UIViewController example shown earlier; UIViewController is a subclass of NSObject but the wording here suggests that the superclass must be exactly NSObject. Or are we assuming that UIViewController will be imported with a @MainActor attribute?

(Either way, I hope the IconViewController example is allowed, because it’s a compelling use case.)

I'm very excited about this. Am I correct in understanding that this could potentially remove the need for a lot of DispatchQueue.main.async when interacting with UI elements/state?

In any case, +1.

1 Like

That's the idea, yes :slight_smile: Rather than having to interact with queues and make sure to be "on the right one", you'd annotate your functions, properties and types with @MainActor to get execution on the main actor be ensured automatically by the compiler and runtime.

2 Likes

Sounds like ObjC wouldn't be able to participate in this so in effect, most code that needs to run on the main thread (UIKit) would be excluded from having this safety? Also it sounds like if you annotate something as MainActor and it can't be guaranteed that the calling code is on the main thread (ie called from another MainActor context) then you have to call it using async code. Wouldn't this lead to the case where a UIKit callback like viewDidLoad or a button's action has to then create a task to call anything marked as MainActor?

1 Like

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?

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

1 Like

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.

3 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

7 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