[Concurrency] Actors & actor isolation

What's the main difference between top-level @actorIndependent(unsafe) and @someGlobalActor?

Does unsafe mean there's NO lock/queue sync mechanism but globalActor has it built-in which lead to executor hop?

1 Like

That's right. A global actor is still an actor, it's just globally scoped — there's always exactly one that actor, and you don't have to have a specific reference to it to use it, which gives us a lot more language flexibility to tie things to it. It turns out that that's a pretty common case in concurrent systems and it's worth giving it special attention. So @MyGlobalActor means that a function is supposed to execute on that specific global actor, whereas actor-independent means it doesn't care what actor it executes on.

2 Likes

Questions and comments from me and my colleagues at Google (most notably @gribozavr, @saeta, and @pschuh):


  • As others have noted the use of “value type” to mean “type with value semantics” clashes with conventions established by TSPL and makes the text confusing.

Actor Isolation

  • Does the fact that actors have a “queue” (as opposed to, say, a bag or a list) of partial tasks have any implications for the relationship between the order tasks are received and the order in which they are started?
  • “Synchronous functions in Swift are not amenable to being placed on a queue to be executed later”—I don’t understand what this means. Isn’t this exactly what we do with libdispatch?
  • “It should be noted that actor isolation adds a new dimension, separate from access control”—It’s not clear why these should be completely separate. Okay, I can touch private members of other instances of self, but then why not have instanceprivate and mandate that synchronous actor methods use that level of access control? Or, we could not introduce instanceprivate but mandate that synchronous actor methods are at least labeled private while also being inaccessible from other instances. If these things are truly separate, presumably you can (meaninglessly) label those methods public or internal without causing an error, which seems wrong.
  • “The rules that prevent a non-escaping closure from escaping therefore also prevent them from being executed concurrently”—I don't think this reasoning works, but see it being dealt with here, so won’t belabor the point.

inout parameters

  • “Actor-isolated stored properties can be passed into synchronous functions via inout parameters, but it is ill-formed to pass them to asynchronous functions via inout parameters.”—it’s not obvious to me that this restriction is needed or desirable, since presumably

    await balance = computesAsynchronously(balance)
    

    would be legal. IMO they are both semantically dubious because of reentrancy of actors, but if we're going to allow the above I don't see why we don't just transform the inout version into that code and allow it too.

Escaping reference types

  • “However, the actor isolation rules presented in this proposal are only sufficient for value types.”—as far as we can tell they are sufficient only for types with value semantics that aren’t mutably captured by a closure.

Detailed design — Actor classes

  • “An actor class may only inherit from another actor class. A non-actor class may only inherit from another non-actor class.”—These seem like complicated and unnecessary rules that could easily be encoded in the type system simply by making Actor a base class.

Detailed design — Actor protocol

  • “Non-actor classes can conform to the Actor protocol, and are not subject to the restrictions above.”—Aside from the fact that the benefits of this wrinkle aren’t very well-justified by the proposal, just from a “reading, thinking, and speaking about the code” perspective having an actor keyword separate from the Actor protocol seems like a terribly confusing thing to do to programmers. We won’t even be able to talk sensibly about whether something “is-an actor” without a whole bunch of qualification.

Detailed design — Global actors

  • “static let shared = SomeActorInstance()”—doesn’t this thing have to be an instance of UIActor? The way it’s capitalized here, the example seems to be implying it is of type SomeActorInstance.
  • “The type must provide a static shared property that provides the singleton actor instance”—Regardless of what conventions may have been established by Cocoa, the name “shared” seems like a terrible way to distinguish a singleton instance, since all class instances are “shared” in the same sense. May I suggest “singleton?”
  • Am I allowed to use a global actor attribute on a method, property, or subscript of a different actor?
  • “A protocol requirement declared with a global actor attribute propagates the attribute to its witnesses mandatorily if they are declared in the same module as the conformance… An overridden declaration propagates its global actor attribute (if any) to its overrides mandatorily.”—Don’t we want to allow the override/witness to be @actorIndependent? Later in the overrides section there’s an implication that it would be allowed. Maybe we just need to tweak the meaning of “propagated” a bit to accommodate @actorIndependent.
  • “Other forms of propagation do not apply to overrides”—it’s not clear what this means. What are the other forms, and why don’t they apply?

Detailed design — Accesses in executable code

  • Aren’t all accesses in executable code? What is that part of the section title trying to communicate?
  • My impression of this section is that actors can call into un-annotated code, and un-annotated async code can call into actors without an impedance mismatch. Is that correct?
4 Likes

I didn't mean to imply otherwise!

I think that is happening in this thread for sure, but I think there is also a difference of viewpoint in the actual model that is important to explore -- independent of the terminology.

The world view I'm advocating for here is that actor declarations are islands of single threaded-ness with a queue that protects them, and which are always communicated with asynchronously. Actors are an important design pattern and a good "safe default" for most (but not all) concurrent programming abstractions. I think we generally agree about this.

I think the divide here is that I see actors as living among other types of concurrency (pthreads!) and other forms of synchronization (e.g. the concurrent hashtable, things using RCU internally, whatever), both for compatibility reasons but also because actors are not always the right answer. If I understand you above, I think you're advocating that we make the "other synchronization" possibilities part of the actor model. In contrast, I'm arguing that these should be not part of the actor model, but that the actor model should work with them cleanly.

This is a pretty big difference conceptually, and this is why I'm arguing (over on the ActorSendable thread) that /classes/ can also participate and work with the actor model, and use their own bespoke internal synchronization approaches. If I understand your model correctly, you are advocating for "classes are never passed between concurrent domains", but people should use actors for that, and if the queue is inconvenient, then they can do unsafe things and @actorIndependent to stop using it.

I don't see why this is a better model - to me, it is muddling the behavior and responsibility of "real actors" by pulling things that are out of model into their design. This approach is also more complicated for the "migration and interoperability of pre-actors" code, which uses a wide range of concurrency methods and synchronization constructs. While some of these can and should move to first class actors (progress!) not all of them can, because actors are simply not the right answer to everything.

I don't see how forcing non-actor things into an actor class makes the model easier to understand, easier to deploy, or easier to work with. The design you are proposing is also more complicated (needing the attributes etc).

Despite the confusion about terminology, I think this really is a huge conceptual difference in the models we are imagining here.

-Chris

5 Likes

@Douglas_Gregor mentioned this when talking about what's disallowed for inout parameter.

It feels somehow very familiar (it totally makes sense btw). Then I realized that you can mutate a variable inside a sync escaping closure iff you can pass the same variable through an inout parameter of an async closure.

There may be some overarching story about variable access here. Though I'll need to think about it a bit more.

Yeah, I'm not sure how the inout part of this proposal works. I responded over on the async/await thread, but I think the cleanest answer is for cross-actor calls to forbid inout parameters. I don't feel like I have a strong grasp of the proposed answer though, so I could be missing something big.

-Chris

1 Like

I've been reading (back and forth, rather aptly) the concurrent concurrency pitches over the weekend and I have found the section on Global actors a little confusing.

Are the following statements correct?:

  • The @globalActor annotation to a type declaration creates a new form of '@' annotation that matches that type name.

  • There should only ever be one global actor per process, so the statement above just allows the user to customise the name of the globalActor annotation.

  • Non-actors should not be able to read mutable properties of Actor instances (unless one creates convince async accessor functions on that actor class).

  • Or does 'access' as per the pitch mean write-access?

There can be multiple global actors. Different attributes specify different global actors. Those actors act concurrently with each other. What makes them global is that their state is spread globally through the program rather than being tied to a dingle actor class.

5 Likes

If I understand correctly, nothing prevents an actor method to be called while another one is suspended, right ?
Eg: given the following actor, open() could start running while a former call to open() or close() is suspended ?

actor Order {
  func getCurrentStatus() async {
    // ...
  }
  func open() async {
    await something()
    // ...
  }
  func close() async {
    await something()
    // ...
  }
}

Yeah, what you're describing is re-entrancy and it indeed is quite tricky. Currently the proposal only has fully re-entrant actors, which indeed can make protecting invariants difficult -- or phrased differently re-entrant actors don't protect from "high-level" races.

I have prepared a writeup here intended to be merged into the actors proposal for "revision 2" and be discussed in depth: [Actors] Reentrancy discussion and proposal by ktoso ¡ Pull Request #38 ¡ DougGregor/swift-evolution ¡ GitHub

3 Likes

This is really interesting.

I'm thinking actors should be non-reentrant by default

And that the strategies for reentrant actor might be quite diverse, and evolve in the future. So it might be relevant to allow customisation for this, rather than bake it in the language.

I see 2 path to allow customisation:

  • the actor protocol with a few adjustments
  • method wrapper (but they don't exist, yet)

Glad to see non-reentrant functions being considered.

From the design you linked to, we would get both reentrant awaits and non-reeentrant ones, and they are spelled the same (depending on the function signature). I’d prefer if both kinds of await had a different spelling so it’s clearer what can potentially happen on the side during the await.

Otherwise copy pasting code with an await could change the meaning drastically.

2 Likes

I like the idea in the updated proposal that actors would be non-reentrant by default and can be made reentrant on a case-by-case basis.

Regarding spelling, if await is the keyword that defines suspension points and "inserts" reentrancy—would it make sense to allow await to be left out, which would create the intuition of a "blocking" call on the actor queue that would prevent interleaved execution? That would also allow more fine-grained control than @reentrant at the function level.