SE-0306 (Second Review): Actors

The second review of SE-0306: Actors begins now and runs through April 23, 2021. The previous review thread was here:

In response to the first round of review, the core team has decided to subset out actor inheritance. We would like to see how real-world adoption of actors plays out before deciding whether actor inheritance is necessary. The core team would like to focus review discussion on a top raised by Chris Lattner in the previous review:

The core team sees two competing draws for consistency here—first, as Chris notes, in other places where Swift models API design, let is externally treated equivalently with a get-only var, and by that principle, actors should follow suit and not expose lets as implicitly nonisolated outside of the actor even if they are immutable. That would reserve for the actor the ability to evolve the interface into an internally-mutable variable, or get-only computed property, without breaking API or ABI. It would also result in a consistent rule for how isolated and nonisolated apply to actor members; instead of lets being a special case, any actor declaration would require nonisolated to be explicitly annotated in order to declare it as safe to access from outside the actor without isolation.

On the other hand, "lets are immutable and safe to share across threads" has also been a common message through Swift's history, and it could seem boilerplatey to require an explicit annotation on top of let in order to say that, yes, this immutable value is actually immutable. If actors in practice end up carrying a lot of immutable state, the annotation burden could be onerous. The core team would like to hear from the community, particularly adopters who have experimented with actors, to get more signal about how much burden treating the isolation lets equivalently with other declarations will be in practice.


Reviews are an important part of the Swift evolution process. All review feedback should be either on this forum thread or, if you would like to keep your feedback private, directly to the review manager. If you do email me directly, please put "SE-0306" somewhere in the subject line.

What goes into a review?

The goal of the review process is to improve the proposal under review through constructive criticism and, eventually, determine the direction of Swift. When writing your review, here are some questions you might want to answer in your review:

  • What is your evaluation of the proposal?
  • Is the problem being addressed significant enough to warrant a change to Swift?
  • Does this proposal fit well with the feel and direction of Swift?
  • If you have used other languages or libraries with a similar feature, how do you feel that this proposal compares to those?
  • How much effort did you put into your review? A glance, a quick reading, or an in-depth study?

More information about the Swift evolution process is available at https://github.com/apple/swift-evolution/blob/master/process.md .

Thank you for contributing to Swift!

Joe Groff, Review Manager

18 Likes

Did the rules for the @escaping closures get any feedback in the first review?

I agree that immutable let should be nonisolated implicitly in actor.

2 Likes

There was a little feedback here, but not a lot of discussion. The rule about @escaping closures being non-isolated was never great: it's always been an heuristic to try to deal with the world where @Sendable has yet to be fully adopted. So instead of making this heuristic part of the actors design, to be removed at some later point, we've removed it from the proposal. Instead, we'll need to rely on dynamic checking where actor code interacts with non-@Sendable`-enforcing code.

Doug

6 Likes

I think one option we can choose to take is like @objcMembers class attribute: if the author of a class expect all of the public let declarations of a class can be shared across threads safely, an attribute like @safePublicLetConstants can be put before the class declaration. With this approach we can still have control over single properties.

1 Like

I agree with Chris on this. Having lets be non-isolated is really weird, and wildly inconsistent with the existing language model. I also think it makes actors much harder to teach and learn. Instead of just introducing them as ”any access to an actor goes through its mailbox,” you have to also introduce the concept of isolated vs non-isolated members, and explain why some are isolated by default but others aren’t.

I don’t think the second of these is actually an argument for consistency. Isolation and mutability are orthogonal concerns. It may not be very useful to have isolated immutable state, but that doesn’t make it inconsistent with the existing language model. It sounds to me like the actual argument being made is one for convenience: wanting to avoid boilerplate and annotation burden. Whenever Swift has chosen convenience over consistency, I think it’s turned out to be a mistake.

Take parameter labels as an example. An early version of the language treated the first argument to a function differently from the rest of the arguments to make it more convenient to write functions where the first argument was unlabeled. This was really confusing, and as Swift grew into its own style separate from Objective C, it also turned out that people actually wanted to label the first argument quite often. And so, luckily, it was changed. However, the inconsistency still lives on in subscript labels, I assume for the same reason (that most subscripts are unlabeled). This trips me up every single time I write a subscript. Instead of applying the general rule, I have to remember that there’s an exception, and do the opposite of what I’m used to. And so, what was meant to be convenient actually ends up being inconvenient because it’s inconsistent.

In this case, if lets and vars are treated consistently, to see which members in an actor are non-isolated you just need to look for nonisolated. But if lets are treated differently from vars, you also need to remember to look for let. And to even know that you need to look for let, you need to know that lets are non-isolated by default. Likewise, if you’re writing an actor where you want to be able to change a let to a var, just like you could in a class or a struct, you need to remember that let in actors works differently from in classes and structs, and make it an isolated let (would that even be possible?). It seems like this would result in a situation very much like that with subscript labels: instead of just applying the general rule, you have to remember that there’s an exception, and do the opposite of what you’re used to.

25 Likes

Amazing proposal and not much to add since this has gone trough many revision by people smarter than me. Huge +1 from me.

I just wanted to ask if there is any plan to somehow warn on dangerous uses due to reentrancy. For example the bank account snippet is fine, as the comment says, because the await is after the balance check and the change in balance ara done, not in between. Would it be feasible to have some warning if there was an await in the middle? I imagine this kind of static analysis may be tricky and maybe even tiresome for a programmer that knows what is doing. I was just wondering because after reading multiple times the reentrancy section I got a bit scared of even using the functionality :joy:

About let/var get. I'm fine with the suggested choice done by the proposal. I think that the message about let being safe is so interiorised by Swift developers that going against that feels weird. Although I understand the concerns and the alliterative is not that bad.

But given the current choice I think it feels very weird that in a protocol you can't define a let requirement. Until now there was no much difference but, if I'm reading the proposal correctly, it means that when using an actor directly (known type) we can use sync let but via a protocol we will be forced to use async (because we don't really know it's a let behind the scenes). It would be very nice if this could be improved IMO.

A last question I have is about the automatic transition from sync to async methods when they are cross-actor. This "magic" is only available in actors correct? In the rest of Swift concurrency the compiler won't magically convert sync functions to async. (I don't know how it could).

1 Like

While I don’t have hands-on experience at this point, I expect this to be more of a chicken-and-egg scenario in that how much immutable state actors will tend to carry may be contingent on how we design actors.

That said, the concurrency proposals very much already honor “lets are safe to share” in the form of Sendable. Conceptually, the clearest (thus most learnable) manifestation of actors would be one that focuses crisply on cross-actor isolation. Yes, certain members can be safe to share, but it is not a repudiation of the safe-to-share principle if actors do not by default share a member that’s safe to share: after all, the raison d’être of an actor is to isolate, not to share. It seems rather a conflation of two separate concerns to shoehorn an implicit exception for immutable members: I agree with Chris that it would be more consistent if it’s an explicit choice supported by a single feature (nonisolated) dedicated specifically to relaxing actor isolation.

That this would preserve the consistency between let and get-only var only strengthens the motivation here. I think the point above is well made by @Alejandro_Martinez that the knock-on consequences of making a distinction affect other aspects of language design:

But given the current choice I think it feels very weird that in a protocol you can't define a let requirement. Until now there was no much difference but, if I'm reading the proposal correctly, it means that when using an actor directly (known type) we can use sync let but via a protocol we will be forced to use async (because we don't really know it's a let behind the scenes). It would be very nice if this could be improved IMO.

13 Likes

Can't we slice this cake differently and eat it too?

It seems to me that the syntax for let and var property access could look isolated (i.e. await) in all cases, but actually be compiled as non-isolated in cases where the compiler could guarantee that it's accessing a true let property.

After all, await is only a potential suspension point. The fact that it would not suspend/isolate for some properties shouldn't be a barrier to its use as required syntax.

The only part of this that I'm not certain about is the resilience story across module boundaries. Is true let-ness even exposed across module boundaries?

1 Like

I agree with Chris too – at least with the fact that immutable let properties shouldn't be exposed as implicitly nonisolated. Much of Swift has been influenced by the idea of "progressive disclosure of complexity". It's one of the raison d'êtres of the Swift actor model – a concurrency model which is easy for one to get their head around thanks to its strict rules. So, people will be taught that properties are isolated to the actor, which means that we can't set the property if we're not isolated to that actor, and getting the property requires an await – so the best way to modify an actor is to use one of its methods which'll be passed into the actor's "mailbox". This consistent and predictable model will be one of the reasons why actors will become a go-to solution for modelling all kinds of concurrent systems, whether that be on apps or on the server.

People, once they get used to the actor model, will therefore subconsciously put an await before accessing one of the properties if they're not marked with "nonisolated" (which I hope will show up in the autocomplete popup). If we were to make lets implicitly nonisolated, every time someone instinctively adds an await before accessing an immutable property, the "No 'async' operations occur within 'await' expression" warning would show up and they'd have to manually remove the await keyword.

How could they have known that the property is a let without having had previously looked at the source code or the interface file? I think exposing immutable properties as implicitly nonisolated would create a big hole in the consistency of the actor model that developers will become accustomed to. However, like @QuinceyMorris mentioned, there's no reason why under the hood they couldn't be treated as nonisolated properties (if that presents some sort of performance gain). Indeed await only represents a potential suspension point – the only downside, and it's minimal, is that now we'd only be able to access immutable actor properties in async contexts. But that'll rarely be an issue anyways, since when we're interacting with an actor we're most likely already in an async context.

4 Likes

I think it would be nice if these "version 1" actors would be as easy as possible to make distributed when that time comes. It seems to me that non-isolated lets would make it harder to make an actor distributed.

This seems like it shouldn't be a goal given the vast majority of actors will never need to be distributed.

Right. Most actors will be local, and distributing an actor is not going to be just “flipping the distributed switch” with no other changes to source code. I consider the distributed argument for requiring “nonisolated” to not carry much weight because of this.

  • Doug
2 Likes

I'm very interested to hear the reasoning behind this decision. I don't recall the non-inheritance model having any advantages for the language or Swift users in the previous discussions, only a few implementation simplifications that didn't seem very valuable to anyone not implementing the language. So was there something I'm forgetting or missed?

I'm also interested in how the Core Team expects the evaluation of the real-world adoption of actor to work. Previous discussions on this subject have brought up a variety of examples of how inheritance plays a key role in the management of mutable state, so I'd think the we can already see how the need "plays out". But I'll explore the design space further, if I can get some guidance as to the types of things the Core Team is looking for to help inform their decision.

Without inheritance, what design patterns do the authors (@John_McCall, @Douglas_Gregor, @ktoso, @Chris_Lattner3) recommend as a replacement? There's obviously no 1:1 replacement but I see two main approaches (which were briefly touched on in the original inheritance discussion in December).

Classes + Actors

One approach is to combine the subtyping and storage of classes with actors to encapsulate mutable state and methods to act upon that state.

Advantages:

  • Existing class hierarchies can be adapted relatively easily, especially if they already encapsulate their mutable state in some way.
  • Superclasses are more flexible eraser types in Swift, as they don't need to deal with protocol limitations like the lack of self conformance.
  • Common interfaces and internal types are automatically shared.

Disadvantages:

  • No automatic checking at the type's "surface", only where it interacts with whatever state is encapsulated by the actor.
  • Relatedly, the class will likely duplicate some of the actor's interface with no advantage to the user.
  • Lose the ability to use AnyActor.

Actors + Protocols

Another approach I see is the direct use of actors to encapsulate all of a type's functionality with protocols to provide some subtyping.

Advantages:

  • Direct use of the actor verifies all access to internals.
  • Protocols may be useful in other contexts.

Disadvantages:

  • Requires separate encapsulation of shared state for each intended subtype. This could be dozens of properties.
  • Protocols duplicate the actor's interface, requiring maintenance to keep them in sync for functionality that needs to be shared.
  • Protocols must cover all mutable state in some way, either directly or through some shared encapsulation (perhaps another actor), leading to massive protocols.
  • Protocols are limited in the erasure they can provide and so may just not work in some scenarios.
  • Protocols may have limited use outside the subtyping relationship, but require a lot of investment.

So I'm a bit torn. Classes with actor internals is probably easier to implement, especially from an existing codebase, but loses the direct advantages of actors. They'll still be safe, just outside the actor system. Actors with protocols seems more Swifty but have quite the maintenance burden and rather poor design with the shared mutable state defined outside the actual actors themselves.

Can the authors, Core Team, or anyone else, come up with additional approaches?

2 Likes

IMO isolated let would be the safer approach here. If it turns out to be onerous, we could later have let imply non-isolation, and isolate will just do nothing (similar to internal).

I think another angle would be the similarity between var and let. You can generally switch between the two with almost no implication aside from extra mutability (there's this Codable shenanigans, but still). So it might work better to maintain consistency between these two.

In any case, I think we shouldn't use anything more complex than

All lets are X unless it is explicitly annotated as not X.


I feel like I fail to wrap my head around nonisolated let with non-sendable values :thinking:. I'm not sure how that should work when we can't even return a non-sendable value we created within the function call. We disallowed non-sendable types on actor properties too, right?


and therefore do not have (or need) features such as required and convenience initializers, overriding, or class members, open and final .

Ok, I kinda know all of these, but never realize that we have this many features unique to classes.

1 Like

The reason I like implicit nonisolated let is that it makes a clear distinction between Actor.let and await Actor.var, the user/programmer can explicitly recognize what type of variables they are referencing and whether they are safe(sync) or unsafe(async) to access.

If we combine all of let/vars into await Actor.* pattern you couldn't make a such differentiation through quick reading. Actor as a type isolator still has two parts/worlds :last_quarter_moon: inside it, it's better to preserve this duality outside too.

Last, it's a small change though, both ways are reasonable to accept.

As an aside, this isn't actually possible AFAIK. Property effects are not part of this or any concurrency proposal. It would be great to have them though.

A user of an Actor API wouldn't recognise if they're referencing an Actor.let or an Actor.var beforehand, though. So the differentiation would only come after the fact, once there's an Xcode warning telling them to remove the await.

Cross-actor references must be marked with await:

Cross-actor references to an actor property are permitted as an asynchronous call so long as they are read-only accesses

let constants are special for classes: you can't override them in a subclass, thus guarantying it'll always have the same value. Similarly, I think it's reasonable for let constants to be usable without await in actors.

Since lets are guarantied to be constant, there's no point in creating suspension points to access them. By reducing the number of suspension point where tasks can interleave each other, the code becomes easier to reason about, and this has the potential to reduce bugs.

We could require suspension points (await) for consistency's sake. It could make actors easier to teach, but the cost is more error-prone code due to more suspension points. It could make non-distributed actors more similar to distributed ones, but the cost is more error-prone code for the non-distributed case.

The only good reason to want let isolated by default IMO would be for evolution purpose, so you can change a let to a var later. Although even then, it should be mentioned that you can still change the let to a nonisolated var. But since Swift generally prefer non-commitment defaults for library code, it'd make sense to make let isolated by default (at least for out-of-module access). I'd still recommend people should make their let variable nonisolated whenever it makes sense to simplify reasoning around suspension points in client code.

3 Likes

I'm in favor of isolated let, if only for library evolution. There are times when changing a let constant to a computed var makes sense, like when some internal state evolves to need some additional processing before being returned to clients from that property's interface. If I'm sure that my property (computed or constant) is safe to be nonisolated, and the compiler agrees, I'd be sure to add that keyword. Otherwise, I'd rather clients of my library be thinking in a safe manner as they use my library. I'm okay with handling that minor cognitive load as I consider my library's interface at the use site.

There will be cases where library authors accidentally create let properties that can safely be accessed without async, without adding nonisolated to their declaration, but I think that is okay. If that property really makes sense to be nonisolated, then a simple PR or issue in the library's repository should suffice to bring up the issue with the author. I can imagine cases where an isolated let makes sense, and so discussion around those cases can happen on a per-library level.

In general, for continued consistency with get-only var, I support isolating let by default.

4 Likes