SE-0306 (Second Review): Actors

Fully agreed – I was just going to write something about how the proposal might need some patterns/examples so that we can form some best practices (similar to the property wrapper proposal, who's numerous examples were infinitely helpful in understanding them), and therefore make better-informed decisions about this proposal as a whole.

In the feedback by the core team in the first review, there seems no arguments around whether we want to use the name Actor or AnyActor as the type-erased protocol name. To me AnyActor aligns more consistently with Any and AnyObject, which preserves a unified naming pattern in Swift. Is there any reason/justification about why we still use Actor in the revised proposal?

I agree. As I see it all actor methods should return some sort of future or detached task handle (or nothing).

And that is actually sort of what is proposed here. Using async/await "callbacks". The only difference is that per default the caller postpones its remaining work until the actor method's "future" completes. The client may then use something like the previously named "async let" to not immediately wait on the "future" to complete, or even detach() to be completely independent of the actor responding.

That said, my mental picture of distributed actors would probably have method calls default to being independent of the "returned future", i.e. as if using detach { await actor.method() }. But I do not have any real experience with actors, so :slightly_smiling_face:

1 Like

What is your evaluation of the proposal?

-1. This version of the proposal has so restricted actor capabilities that it makes the feature much less useful while also increasing the difficulty of teaching and adopting it. Overall this proposal seems to have eschewed Swift's typically pragmatic nature in favor of one focused on purity, despite that purity having little benefit to Swift users. I've previously summarized many of these points, so I'll try to be brief.

  • Removal of subtyping greatly limits the usefulness of actors. For a feature that's all about protecting access to mutable state, disallowing the sharing of said state is extremely limiting. Given Swift has no other way to express this sort of functionality, it will be sorely missed.
An aside about "real-world adoption".

In the history of Swift, have deferments for "real-world adoption" ever born fruit? I can't think of any, despite many that should. This sort of decision rings hollow to me.

  • Removal of subtyping, and the strict async nature of the APIs, will make learning, adopting, and using actors more difficult than it needs to be. There's no attempt to allow existing attempts at thread-safety to migrate over to this model. Instead, code must be dumped and largely rewritten if it wants to take advantage of the compiler assured safety. And code which used previous solutions using subtyping can't transition at all.

  • async-only APIs aren't enough. Without a way prepend work on the internal executor (or even block while doing so), there's no way to accomplish necessary bits of functionality like cancellation or any mutation that might be depended on by already enqueued work. Instead it seems like any realistic type will need to provide its own blocking for such state updates, greatly diminishing the value of adopting actors in the first place. It shouldn't be necessary to immediately break out of the new model just to accomplish things that already exist. Insisting on async-only APIs doesn't seem necessary to guarantee safety, given users can already accomplish it manually. Instead it just seems like an attempt to stay inline with the actor model, despite the fact that Swift's actors aren't really actors at all. Adopting a 100% async API should be a willing design choice for performance or modeling actual async work, it shouldn't be something required because that's the only way to get the language guarantees.

Is the problem being addressed significant enough to warrant a change to Swift?

Yes, this is a great problem to solve.

Does this proposal fit well with the feel and direction of Swift?

No. It is not a pragmatic solution to the problem that fits well with existing Swift. Instead, it feels like a feature designed for a new language that doesn't have existing code to work with and doesn't have to work where Swift already lives.

If you have used other languages or libraries with a similar feature, how do you feel that this proposal compares to those?

I've manually implemented types which use underlying DispatchQueues to achieve this sort of safety. It's great, but limiting, so additional functionality to provide synchronous mutation had to be added as well.

How much effort did you put into your review? A glance, a quick reading, or an in-depth study?

Read the pitches and the proposals, been implementing actors with the experimental toolchains.

3 Likes
  • What is your evaluation of the proposal?
    +1, removing the legacy inheritance model from objc is the right direction and makes it easier to focus on what actors are about.

  • Is the problem being addressed significant enough to warrant a change to Swift?
    Yes.

  • Does this proposal fit well with the feel and direction of Swift?
    Yes.

  • If you have used other languages or libraries with a similar feature, how do you feel that this proposal compares to those?
    I worked with Erlang and always enjoyed to simplicity of the language. I like the fact that actors now focus on their purpose.

  • How much effort did you put into your review? A glance, a quick reading, or an in-depth study?
    About a day.

3 Likes

A primary concern I have that I haven't seen mentioned is how we test actors without inheritance. Actors, like classes, will likely be used to manage state. From experience, the stateful reference types in programs often form a dependency chain connected to some root singleton or singletons, which are then injected into parts of the program. One of the nice things about Swift's classes, is it's very simple to create a mock subclass inside a test function, only overriding what's needed for the test. This extends to integration tests where you can chain together these inline-mocks (in the fashion expected by the application) and have very clear and concise tests written out for entire application sub-systems. I see a future where these same stateful reference type chains exist, but often as actors injected into parts of the program.

I've read previous arguments that POP is the preferred way to structure subtyping, and I have no general argument there. But in application development, in the case I'm considering, there's almost never any more than 1 primary type for 1 job in the stateful dependency chain, in which case, adding a protocol is redundant. Additionally, external libraries (such as http networking) are usually included in that chain, and often use classes as their single interface. It's then simple to test the libraries and their integration given the method mentioned earlier.

Considering this, I would very much prefer a way to subclass/subactor, if only to test actors. I think Chris' suggestion (2) "Support for inheritance but without designated/required initializers, class methods, etc." would work for most cases, as long as (a) functions can be overridden and (b) mutable test variables can be added, to the sub-actor.

1 Like

Related to re-entrancy, is it possible to have a one-way message send to an actor’s mailbox? I’m thinking of how Erlang-style actors work. How would I invoke a Void-returning method on an actor without waiting for it to complete? The only thing I can think of is detach { actor.voidMethod(arg) }.

2 Likes

You're very right that this is a crucial piece and quite necessary sometimes.

The proposals so far have been avoiding this topic so you're right that there is no good API to achieve this in the existing proposals.

There is work in progress and to be pitched work which will address this over here though: https://github.com/apple/swift/pull/37007 (will get it's own evolution thread, let's not hyper focus on it just yet please :pray:)

While it is aimed to solve the "call async from sync" it also solves the issue of "send without waiting" (and I'd honestly lobby to call this operation send, but let's chat once it gets it's SE review).

With that proposal, the closure is run on the same executor as the enclosing context; so if used from an actor, the enclosing context is the actor's serial executor, this means that:

async (or send) { 
  await worker.hello(1)
} // -> Void
async (or send) { 
  await worker.hello(2)
} // -> Void

would do the right thing here.

It is very important to not devolve into using detach for such things, because a detach would lose: priority, executor, and also any task-locals attached to the calling task that you'd most definitely want to keep propagating.


I have yet to figure out if and how to solve "no need to even send back the result" in the distributed actor setting... but we'll figure that out and I'm pretty sure it could fit the above async (or send) API.

2 Likes

@ktoso Can you help to raise this concern in the second review process of the core team? I think this naming issue is not trivial. Thanks.

I don't really have any other powers than posting in threads or reaching out directly to the authors - same as you can here. The core team, and review manager, go through those threads and put it before the core team.

For what it's worth, personally, I myself do quite like Actor and find AnyActor unnecessarily ugly. The consistency argument falls a bit flat to me... There's also going to be DistributedActor and I honestly don't want to be talking in APIs about : AnyDistributedActor - meh.

2 Likes

Thanks for your feedback on this issue. I just hope that more arguments and explanations could be put on the table for why this proposal prefers Actor over AnyActor.

To provide counter-example for your personal argument, in Swift we already have AnyRandomAccessCollection, AnyBidirectionalCollection and other type-erased definitions. So from my side AnyDistributedActor aligns with the existing naming patterns in Swift. IMO beautifulness or ugliness can change over time, but consistency is more important in the long run :wink:

You can always @ the core team author(s) here (tho I personally don't really like to do it when reviewing).

I’m pretty sure it’s on the table, under this pile of other stuff… but the reason is quite clear: Actor is not a type-erasing wrapper or magic type, it’s “just” a protocol (that can’t be conformed to explicitly). As far as I’m aware, there are no protocols named Any... in the standard library.

4 Likes

What do you mean by "that can't be conformed to explicitly"? I suppose we can define a new protocol that conforms to Actor, and any actor type automatically conforms to the Actor protocol. This is exactly the same behavior as AnyObject on the class types. So either AnyObject should be renamed to Object, or Actor should be renamed to AnyActor. Breaking the naming pattern doesn't make sense to me.

Take the definition of Actor in the proposal:
protocol Actor : AnyObject, Sendable {}
It would make the naming pattern more consistent if it goes like this:
protocol AnyActor : AnyObject, Sendable {}

And if I understand the proposal correctly, the Actor protocol should goes into the same place as Any and AnyObject. So even there are some minor (IMO) difference between the usage of these protocols, we should keep a consistent naming pattern here.

cc @Douglas_Gregor (sorry for the bothering, but I really think the naming should be further discussed)

AnyObject is a special kind of non-protocol constraint. Other non-class types (such as ObjC block types and class metatypes) also satisfy the AnyObject requirement. AnyObject will not every have any requirements, and you cannot write an extension on AnyObject.

Actor is a much more of a normal protocol. It will have requirements when we get custom executors. One can write an extension on it. And beyond the fact that we actor-isolate methods on the Actor protocol by default, it's not really all that different from other protocols for which Swift can introduce or synthesize a conformance. We aren't trying to rename Sendable to AnySendable because structs and enums can implicitly conform to it.

It may even be that we find a reasonable way to allow classes to explicitly conform to Actor in the future, if they satisfy the requirements appropriately. That capability was removed because we're not sure exactly what it should do, not because it's fundamentally impossible to do.

AnyObject is the odd protocol out here. Usually Any means "type-erased", and we should consider that the stronger precedent to follow.

Doug

14 Likes

If we had a chance to do it all over again, AnyObject should probably have been named Object (and Any could be Value or something else more generic), especially if we eventually grow an any type modifier for explicit type erasure, which has been coming up frequently in the SE-309 review thread.

10 Likes

Is it not even an option to deprecate them and adopt the right names?

typealias AnyObject = Object
3 Likes

I don't think it's worth it to churn the names yet again. I also don't think they're good precedent for making further naming choices.

1 Like

That aside, there are no technical constraints that would prevent this from ever happening? So it‘s more like only a decision then? I just want to understand it better as I cannot tell it from the compilers perspective.

1 Like

This makes sense to me, it sounds like AnyObject is the one that is misnamed, which is a different issue. Actor SGTM as the protocol name.

-Chris

5 Likes