[Concurrency] Actors & actor isolation

What are the author’s thoughts on behaviors for actors?
With these one could define and dynamically enable subsets of the actor API.
This could e.g. help to define the allowed interleaving at suspension points.

It is not the primary use case, it is one of the applications/implications of @actorIndependent though, it is to address what you have raised as a concern in the overall roadmap thread:

Thus, @actorIndependent and it's friend @actorIndependent(unsafe) exist to address this concern and allow developers to step out of the rigid rules and use external synchronization whenever necessary.

As such, the @actorIndependent is the weaker form of this, and one might even say the less "intended use" one -- though both make sense really.

As for the typesystem aspects of it, I'll leave it up to Doug or John to comment more.

We did talk about allowing an @instantaneous hint for async functions which would not force the "await" on call sites -- this shows up a lot with the Task APIs where we just have them async because they must be called from an async context, not because they'll ever suspend -- but the conformance to sync protocols is a very different beast, it is not instantaneous; rather, it is either unsafe ("break through the model", i.e. @actorIndependent(unsafe)) or known to be safe for some reason @actorIndependent(unsafe) which yeah, just touching "thread safe state" is -- and how we define that indeed it an open hole in the model which is waiting for the "2.0"...

Personally I'd very much like a ValueSemantics protocol, it would have solved many issues, but not all of them. Again, I'll leave it to the compiler folks to argue about the viability of one model over the other though.

Trying to stick to well defined terms only; we don't have "Actor handles", I'll assume you mean a "reference to an actor".

I guess there might have to be some helpers, but coming from other implementations there really isn't all that much an actor reference needs to do – they need to provide equality (potentially serialization of references if we'd like to jump ahead a little bit) but that's about it.

We are not modeling parent/child actor hierarchies in the core model (it can be built on top), nor are we enforcing naming schemes or "props"/"settings" of actors (the "configure an actor" really being handled by overriding pieces of the Actor protocol rather than any new types to do this).

In other runtimes this is fairly logical and well understood and we should follow the same precedent imho. The current shape of the APIs makes it non obvious though because the thinking that it's "just a class" (it isn't just that, imho) clouds the perception.

Having an actor reference means that we point at some actor, somewhere, maybe it's not even on the same node, that's where the actor model actually shines, allowing modeling and understanding of distribution using the same mental model as any other communication.

As such, equality of "actors" is never the same as equality of classes or structs, or anything you "actually really have in your hands", but it really means comparing actor addresses (in a high level meaning, not pointers/addresses). Equality of actor references is then simple: does it point to the same actor or not. Perhaps it is a remote actor, yet it is possible to uniquely identify it. And such equality on actor references is tremendously useful – consider "I have a set of actors I take care of, i get notified to let one of them go away" so I need to remove it from my observed set, thus we need equality of actors in that meaning.

I'm jumping ahead here a little bit, but equality of actors makes a lot of sense and is defined exactly like this in all other runtimes I'm aware of (Akka (actor ref equality), Erlang (PID equality), and Orleans (via grain identifier equality)).

In other runtimes it is more explicit, because we talk about ActorRef<Behavior> or Grain<Behavior> or just PID rather than say "it's just like a class", which brings me to:

It may be counter productive to call them actor class in my opinion, it keeps muddying the water rather than helping clarify one's understanding that they're different beasts once one really gets to use them.

The similarities end at being "a reference rather than a value", however the semantic capabilities the two expose are very different. Beginning from what one can call on them, how they execute, and where they even are located (i.e. not necessarily in the same memory space).

They also cannot inherit from non-actors; so an actor cannot inherit from a class (it could easily violate the substitution principle and allow incorrect synchronous calls to an actor), so they really aren't all that much like classes after all, very similar, but different enough.

This is yet another reason to move towards spelling actors as actor rather than actor class, because the similarities dwindle quite quickly the more one leans into the real world usage of them and restrictions they impose. Also, actors cannot extend classes, but they could ex

I'm not so sure about usefully defining / guaranteeing === it "depends" really where the actor lives. I don't think it is valuable to === actor references other than for the odd trick to avoid a "full" ==.

6 Likes

I think you mean like in the good ol' actor model where "actor == mailbox + behavior" right? You are right that "becoming other behaviors" is helpful and can help avoiding code like this:

actor class Waiter {
var waiting = false

func hello() { 
  if waiting {  // repeated in every function :<
    helloWaiting()
  } else {
    helloReady() 
  }
}

I guess the specific shape of a solution to this Akka popularized with its "become(behavior)" feature. This is the core of Akka and Akka Typed, however there it is easy to model this because

a) akka classic is untyped as such you can at any point in time easily become any other "receive block" and things just work; I.e. you can easily do:

receive { context, message in
  handleThing(context.myself)
  context.become(ignoreOtherMessagesUntilWeGetAReplyFromHandleThing)
}

ignoreOtherMessagesUntilWeGetAReplyFromHandleThing = receive { ... }

so "receive" is referred to as a behavior, and they can easily swap in and out. It indeed is a very useful pattern.

b) it gets more difficult with types though; the new behavior has to become a behavior that can handle the exact same set of messages as the current one.

This exists in Akka typed and one can easily do:

// pseudo code
let initial: Behavior<MyMessage> = receive { ... in 
  switch message {
    case hello: // "ready handling"
      return waiting // return behavior to handle next message with
      // ...
    }
}

let waiting: Behavior<MyMessage> = receive { ... in 
  switch message {
    case hello: // "waiting handling"
      return waiting // return behavior to handle next message with
      // ...
    }
}

So with this style, there does not have to be an "if waiting" because the entire behavior is the "waiting".

You'll notice though... this works well when behaviors are values; implementation wise it simply means the actor has a var behavior which it runs whenever it gets a message.


How does this translate to language embedded, similar to classes, actors though which are definitely much nicer to use, but are harder to make flexible on the inside...? For what it's worth, this is a model more simlar to Orleans, which to my knowladge does not offer such "become" like feature (because it is hard to fit into the "looks like a class" model).

It is a bit difficult to express this because there isn't a "Message", it is "all my functions which are async and public, because those can be called". One could force expressing all these messages first as a protocol, and then implementing it -- then one can force implementations "you can become(behavior:)" but only the same protocol you initially had... In practice though, this ends up very weird very fast... I have played around with expressing this for a while but have not found a satisfying way to do this in the "messages look like methods" style for Actors which we are embracing.


Another way to do this though, is to push the state machine one layer below and have an

enum State { 
  case waiting
  case ready(SomeValue) 

  func hello() async -> String {
  switch self { 
  case .ready(let value): return readyHello(value)
  // ... 
  }
}

actor class Greeter: GreeterProtocol { 
  var state: State = ... 
  func hello() {
    // maybe change `state` here?
    return state.hello()
  }
}

and have all logic handling the calls inside it, forcing the switch over self each time -- it's verbose, but very easy to model explicit state machines this way. We've done this similar style with our SWIM membership implementation -- the entire logic resides in an "instance" and can be invoked by an actor (or something else, like in that repo -- just NIO handlers).

It hides away the state management a bit, and makes the state machine very explicit which is great.
But it is sadly a bit verbose, and it's not quite the same as "become".

Some style of this pattern though can be very useful if what you're modeling is nicely expressed as a state machine – indeed then handling interleaving calls is easier, we simply notice we are in the "wrong state" and handle such call appropriately.


It helps a little bit... but I'm not sure it's a real solution if we'd never allow non-reentrant actors at all. Non-reentrant actors are important and useful IMHO, but we still need to discuss this in depth (I've been working on trade-off examples and a writeup for this).

Thanks for your insights on this (sub) topic of actors.
Just a raw idea: maybe something similar to extensions could help to provide a scope to overload methods per behavior.
But maybe your explicit state pattern is doing the job equally well, especially given that it’s unclear how many devs would use such a behavior feature anyhow.

Regarding reentrancy into an actor - yes, that’s needed to prevent deadlocks but maybe only those methods which are called back on me when I call another actor need to be so.

1 Like

@ktoso Just quickly wanted to say Thank You for all these detailed and well written posts/replies. Top!

6 Likes

Again, I don't really understand this - it seems like a very strong violation of the actor model, which is all about async communication. Allowing sync communication which does internal local locking is not how the actor model works.

I agree that it is important to integrate types that have internal synchronization (as mentioned in the ActorSendable discussion, but they are not themselves actors. Many of these types won't want the overhead (e.g. a queue), design patterns, or APIs associated with actors. Conflating the two of these seems like a pretty strong design problem with the proposal.

Furthermore, the rationale in the proposal doesn't address this use case at all.

I think we're talking across each other. I understand you can do reference identity comparisons between actors, I meant a classical Equatable conformance (which requires looking into two actors) isn't in model, because you can't force two different actors to be synchronized at the same time (at least without going out of model).

I completely agree as I mentioned in my longer post upthread -- this should be an actor declaration. Swift already has functions and classes which have reference semantics. Clarifying this as its own kind of decl would be much cleaner.

-Chris

4 Likes

I am very familiar with the actor model and its implementations :slight_smile:

Sure, all this does not mean people should do this in general, but there are very narrow, specific valid use cases for it.

I think where the confusion or disagreement really originates is in our perspectives/backgrounds and what we mean with the same words, not an actual disagreement. Note also that we're, by design, not going for complete memory isolation like e.g. dart's actors/isolates do.

Your reply here made me realize this:

Sure, we're obviously agreeing here.

I'm not sure the "classical Equatable" phrasing makes sense at all here so I'm confused why it comes up. :slight_smile: There is no "member-wise equality" for actors and there must not be.

There can however, for good reasons, be equality implementations that do not use "reference" (as in "pointer") equality, but need to implement the equality by means of some constant value that the actor contains. This means, answering the question of "Are you 'representing' the same 'resource' as that other actor or not?". To implement this, either it has to be built into the language, or enough escape hatches must exist, thus why @actorIndependent inside an actor makes total sense to me.

To explain this even more...

This is a result of Swift's specific implementation and take on the actors: it's both a "limitation"/"feature" I guess? There is ONE type, "the actor class." There is no "Reference<Actor>", onto which implementations are able to put these extra constant identifiers. One example that needs this form of equality is "proxies", in all shapes and forms. In other words, other implementations are able to have Reference<SomeActor>.someID while we don't have that type, so it must be on the actor class.

But coming back to the actual discussion point:

Sure. And perhaps this is where our discussion went off the rails? And we fixated on the weird super edge case I explained above, rather than the usage of this attribute not in actors:

@actorIndependent can also be applied to top level declarations after all -- it simply means it is "safe to access from any actor":

@actorIndependent(unsafe)
let concurrentHashMap = ...

actor Hello { 
  func hi() { concurrentHashMap.put(...) } // OK, no executor hop involved
}

This also is the same style as one would annotate something with a globalActor:

@someGlobalActor // typical example maybe "Main/UIActor" etc
let thingy = ...

actor Hello { 
  func hi() { thingy.hello(...) } // OK, potentially executor hop involved
}

Does this perhaps clarify the intended use?

3 Likes

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: https://github.com/DougGregor/swift-evolution/pull/38

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

Terms of Service

Privacy Policy

Cookie Policy