[Concurrency] Actors & actor isolation

I have written a lot of Erlang code (https://github.com/scalaris-team/scalaris). What is unique of Erlang: Actors resp. user-space processes can fail. This is not possible in other languages resp. libraries.

1 Like

Super excited to see this reach the pitch stage. Enormous congratulations to all those who have been working on this behind the scenes. From a first pass, this all seems to be pretty intuitive to me and (in tandem with async/await) definitely addresses many of my personal pain-points with concurrency in Swift. Following are some of my initial questions about this proposal's specifics!

Should there be a requirement that non-async methods on an actor class be private? We'd obviously still need the special diagnostic for when such methods are referenced on non-self instances from within the actor itself, but it might help to solidify the model in the minds of users that synchronous methods on actors are not appropriate as API surface.

This section purports to address closures and local functions, but I was left not entirely clear on what the rules are for local functions. Are they always treated as escaping closures, or no?

What about passing actor-isolated properties to inout parameters on async methods on the actor class itself? I think this would have to be disallowed, since from within that method the actor class would not have the necessary context to keep the parameter from escaping to an asynchronous context—is that correct?

The terminology in this section is confusing me. My understanding is that "value type" and "reference type" are simple descriptions of what sort of declaration defines the type in question (struct/enum and class, respectively). This section seems to be using those terms to refer to concepts that I usually see referred to as "value semantics" and "reference semantics."

This sentence in particular:

Seems to contradict the TSPL section on "value types":

All structures and enumerations are value types in Swift.

Overall, I can't wait to see these features make their way through the evolution process. Thanks again to everyone involved in the effort so far!

ETA: I've also opened a PR with some minor spelling/punctuation/wording adjustments to the proposal text.

1 Like

It's possible that we may want to allow synchronous methods on an actor to be used from outside the actor in certain ways. For example, we could allow an implicit hop over to the actor to call a synchronous method, as long as you're in an async context and you await it. We may also be able to infer that a closure is meant to be an actor function for a particular non-self actor. There's exploration to be done here to see what we can reasonably do.

Local functions are something of a weak point in Swift even without actors. I believe the current rule is that they're treated as escaping unless they capture something that cannot escape, like an inout parameter. Arguably it ought to be based on how they're used.

3 Likes

SILGen does in fact emit closures over local functions based on how they're used, but I think our type checking still treats them as always escaping.

4 Likes

No, because actor isolation checking is orthogonal to normal access control. It's reasonable to extend an actor class in module (say, by adding a new async method), and make use of public synchronous APIs in that new method.

The same problem that's described in the the pitch applies here: when you call an async method, you might suspend. This can even happen if you're calling the method on self if, e.g., someone else has enqueued a higher-priority task on that actor.

Hmm. I think of value types as "types that have value semantics" and consider the TSPL's use to be incorrect. However, I can see how this proposal is confusing in that regard... and can move toward "value semantics" terminology.

Doug

5 Likes

Wouldn't we need a separated axis for that? In particular, how would it work with DispatchQueue.concurrentPerform which is concurrent (and so racing) and non-escaping.

2 Likes

We might need to introduce yet another kind of function type for "concurrent, non-escaping". There's more design to do here, and we need to be mindful of the amount of complexity it might introduce.

Doug

1 Like

Ok, so it's probably better to be defensive here, i.e., the current proposal.

Right. We've thought through some of the consequences here, but we don't want to over-complicate the first set of proposals, which can stand on their own as real progress even without a complete thread-safety story.

3 Likes

Actor.enqueue(partialTask:) is a protocol requirement, while Actor.run(operation:) is protocol extension.

Shouldn't it be the other way around, or both be extensions? AFAICT, PartialAsyncTask is completely opaque, so overriding enqueue wouldn't provide any benefit.

OTOH, I can see an actor delegating run onto another (dynamic) actor.


The enqueue(partialTask:) requirement is special in that it can only be provided in the primary actor class declaration (not an extension), and cannot be final .

Could someone explain why having it be non-final would is required? I'm not sure why this would be different from having no-one overriding it. Maybe this is related to the previous question?


Should global actor be a protocol, instead of type annotation? It doesn't seem to require complex requirement, like property wrapper does, and we already need to define another protocol (Actor) anyway.

Or maybe we can even have it as a single global declaration:

// top-level
@globalActor let uiActor = SomeActorInstance.

We may be able to have actor imply class, but I think this is about a good balance.

1 Like

Enqueuing a partial task is the primitive, low-level operation that we want all executors to provide. The partial task fully encapsulates the unit of work that needs to be scheduled without any added overhead. Wrapping that up as an ordinary first-class function value would introduce a lot of overhead: partial tasks are one-shot and self-consuming, function values are not. It would also be semantically problematic because a call to an async function always happens as part of a task, but an executor itself is not a task; the async function would have to ignore its given context and introduce the appropriate task context.

5 Likes

Ideally, I would like global actors to be global (singleton) instances of an implicit corresponding class, so that you wrote something like:

public global actor UIActor {
  func enqueue(...) {}
}

and the identifier UIActor would (in most contexts) resolve as a reference to the singleton instance of the UIActor class. I think that is much cleaner. It is also, however, a whole feature in and of itself.

5 Likes

PartialAsyncTask needs to stay as is for performance reason, got it.

What about execute being protocol requirement, and not separated extension, and other non-final shenanigan? It feels like this is more appropriate to be in an extension:

extension Actor {
  func execute(...)

or even global function:

func execute(partialTask: ..., using actor: ...)

And run seems like it should be overridable:

protocol Actor {
  func run(...)
}

enqueue needs to be the customization point, or else the general enqueue has to forward to something.

The details of turning the function passed to run into a partial task and enqueuing it are not actually interesting to customize in an executor implementation.

2 Likes

I'm pretty sure we do need another value here for "actor-confined"; withoutActuallyEscaping(_:do:)'s documentation explicitly discusses using it to run non-escaping closures concurrently, and changing our minds about that being legal seems tantamount to a source break.

5 Likes

Copying a discussion from the roadmap thread per suggestion from @Lantua:

If these accesses are disallowed, from the "First Phase: Basic Actor Isolation" example in the roadmap thread:

    // error: an actor cannot access another's mutable state
    otherActor.mutableArray += ["not allowed"]

    // error: either reading or writing
    print(other.mutableArray.first)

What is the meaning of mutableArray being declared internal?

I see how this proposal adds an axis beyond access control—mutableArray is restricted even beyond what private would signify:

synchronous functions may only be invoked by the specific actor instance itself, and not even by any other instance of the same actor class.

(my emphasis)

What I am wondering is if these axes are orthogonal: is it at all meaningful that mutableArray is internal here? I'm not sure that access control modifiers really matter at all for actor state. Perhaps they would matter if the state was annotated @actorIndependent? I wonder if the language or the developer tools might clarify this at all.

At the very least, access control modifiers will affect the visibility of declarations from extensions, which are actor-isolated.

2 Likes

AFAICT, public actor-dependent and private actor-independent both make sense:

actor class X {
  public var dependent: ...
  @actorIndependent private var independent: ...
}

/// Same File
func foo(x: X) {
  x.dependent // error: actor isolation
  x.independent // ok
}

/// Separate module
extension X {
  func bar() {
    x.dependent // ok
    x.independent // error: access control
  }
}

Looks pretty orthogonal to me.

4 Likes

If these accesses are disallowed, from the "First Phase: Basic Actor Isolation" example:

    // error: an actor cannot access another's mutable state
    otherActor.mutableArray += ["not allowed"]

    // error: either reading or writing
    print(other.mutableArray.first)

What is the meaning of MyActor having declared access to mutableArray to be internal? Is mutable actor state automatically considered private?

I see how this is a separate axis, yes, and how actor state is restricted even beyond what private would imply. From the link you share:

synchronous functions may only be invoked by the specific actor instance itself, and not even by any other instance of the same actor class.

(my emphasis)

What I am wondering is if these axes are orthogonal: is it at all meaningful that mutableArray is internal here? I'm not sure that access control modifiers really matter at all for actor state. Perhaps they would matter if the state was annotated @actorIndependent? I wonder if the language or the developer tools might clarify this at all.

Terms of Service

Privacy Policy

Cookie Policy