SE-0306 (Second Review): Actors

Making some lets be nonisolated by default

I remain in the camp that let properties should be isolated by default in an actor. Many of the arguments have already been made, but I'll try to pull them together into one argument.

The proposed "make some let properties nonisolated by default" approach is syntactic sugar that eliminates the need for one keyword on some properties in actors, but it comes with several real costs to complexity and the future evolution of Swift:

  1. API Evolution and API Resilience (as well as binary resilience) are key parts of Swift's design - this is DNA inherited from Cocoa, which showed an amazing feat of API evolution from the NeXT days. The proposed design breaks a key part this. Today you can upgrade any let property to a computed get-only property, mutable property or property with a private setter without breaking source or ABI, and this breaks that property. The replacement it offers (switch to a nonisolated var) muddles the story for API evolution, making the Swift API evolution story more complicated.

  2. The proposed model doesn't allow all let's to be used synchronously, it only applies to @Sendable ones. The first big problem with this is for API evolution reasons: a type can become "newly sendable" as its API evolves. This would make any properties of that type implicitly nonisolated (a non-local effect!), causing new warnings/errors about unnecessary awaits in client code. I haven't seen this discussed before.

  3. The proposed model doesn't allow all let's to be used synchronously, it only allows @Sendable ones - and many mutable reference types and closures will never adopt. This muddles the explanation of let properties in actors and will make them difficult to teach: people will probably only teach the special case. This will be experienced by people saying "why do I have to await this let property, I thought they were immutable" because only the simple part of the model got explained to them. In my preferred model you'd get a simple "non-sendables types cannot be nonisolated" error when you tried to mark the let as nonisolated.

  4. It would eliminate the ability to model "I have a let property that I want to be accessed through the mailbox". This is the only model supportable by distributed actors, but there may be other reasons we want this for non-distributed actors in the future (e.g. an API only wants to be usable from an async context for some reason). Eliminating this makes Swift less consistent and more difficult to teach. Further, when distributed actors are introduced, we will need to teach both the inherent differences between distributed and nondistributed ones (error handling etc), as well as this elective difference that isn't inherent. This will muddle the distributed actor story.

  5. The proposed model is inconsistent with the rest of nonisolated: why do you need the keyword on funcs, subscripts etc but not lets? All the logic that works with these properties (including computed properties derived from it) will need to be marked nonisolated. This makes nonisolated more difficult to teach because it now has a special case, undermining it and making its conceptual model more complicated.

  6. This whole topic is directly related to global variables (which can be both let and vars), but we don't have a confirmed model for how they will handle isolation. It seems wise to nail that down before even considering this as they are more primal to the language than a bit of syntactic sugar.

  7. The argument that "let and var are already inconsistent in closure capture lists" is true, but there is a huge difference with this proposal: this feature is about APIs, not about the internals of function bodies. APIs are subject to resilience, are impossible to change once established, and are the interfaces between code compiled with different swift versions. The rules for capture lists are easy to evolve and relax, and the discussion about these capture rules in the Sendable proposal indicate that the model we're going with today may change in the future (through the use of dataflow analysis) because they are about by-copy captures, they aren't about let properties. If and when this model evolves, the difference may go away.

  8. It isn't clear this is going to be a widely /valuable/ bit of syntactic sugar. While it is completely believable that actors will have immutable state, this proposal only removes a keyword in the case when those are public or otherwise intended to be used in a cross actor way (otherwise the absence or presence of the keyword doesn't matter). We as a community have almost no experience using these concurrency features, and it sounds like most of the usage experience folks at Apple have is migrating pre-actor code to actors. While the design of actors is obviously derived from common patterns in existing Swift code, I don't think the decisions about the need for syntactic sugar is easily derivable from that experience. We should base these decisions based on the experience people have /intentionally writing code for Swift actors/ since that is the long-term thing to optimize for.

Zooming out: it is important to understand that this is fundamentally a "syntactic sugar" feature, and that it is a one-way door. If we start actors with this sugar then we are stuck with it and can never remove it. However, if we start without it, we can gain experience, let the overall Swift Concurrency design gel a bit, then weigh the complexity / convenience tradeoff with a /lot more experiential data/ and make a call later.

One thing I find a bit concerning is that folks are arguing that taking the feature would be aligned with the idea of progressive disclosure of complexity: I disagree about this. Progressive disclosure in Swift isn't generally about sweeping the hard cases under the rug with an inconsistent model (that is what Perl 5 does, infamously) - it is about keeping the concepts simple and orthogonal, but prevent you from having to learn them until you need them.

In this case, there is no reason for a Swift programmer first learning actors to dive into any of this stuff. Then can start out by learning about the idea of a mailbox and isolation. This is a simple and consistent feature of actors. When they get to the point where they need nonisolation (e.g. when conforming to a legacy protocol) they can learn about nonisolation as a simple and consistent unit of technology that is there to solve a problem.

Conclusion: Making some let's be nonisolated by default simply waters down the actor model, the nonisolation model, and paints us into a corner that we may well regret in the future. In my opinion, doing this proactively at this early stage of the Swift Concurrency model design is unwarranted and extremely unwise.

-Chris

48 Likes

I guess one solution to that could be to place them all in a single nested struct and exposing that through a single nonisolated property.

2 Likes

True, and with keypath member lookup, it could look something like this:

@dynamicMemberLookup
actor MyActor {
    struct Person {
        let name: String
        let age: Int
        let address: String
    }
    
    nonisolated let person: Person
    
    init(name: String, age: Int, address: String) {
        self.person = Person(name: name, age: age, address: address)
    }
    
    @inline(__always)
    nonisolated subscript<T: Sendable>(dynamicMember keypath: KeyPath<Person, T>) -> T {
        person[keyPath: keypath]
    }
}

let test = MyActor(name: "John", age: 25, address: "Lincoln Av.")
let address = test.address
let name = test.name

This makes the annotation burden minimal, and the change invisible to the API user.

I agree with Chris here, the advantages of isolated lets far outweigh the disadvantages.

6 Likes

Aside from the nonisolated let issue, the proposal is looking really great.

The only other minor issue I see is that the name of the Actor protocol is still inconsistent with other type erasers - the most analogous of which is its direct super class AnyObject. This proposal is basically suggesting that AnyObject was misnamed and should have been called Object. If that we think that AnyObject is misnamed then calling this Actor is consistent and makes sense to me.

Overall the proposal is a huge step forward for swift. The adaptations to remove inheritance (eg using @objc) are great. This will be a huge step forward for swift, as well as the industry. Thank you for the continued iteration on this!

-Chris

17 Likes

Might as well throw in some reflections here, seeing as I'm co-authoring but not part of the core discussions so didn't have much chance to share thoughts other than directly with some members of the team:

Reentrancy

This remains quite nerve nerve-racking, and perhaps somewhat under the radar, but given the tradeoffs we have to take, I think we're doing the best we can here – so I'm okey with the current proposal where we move this off into future work.

To answer some questions that came up about it:

Sadly that's not really something tooling-detectable -- any kind of reentrancy is potentially dangerous. For now we'll have to live with "be careful, notice that there's an await somewhere".

The tricky thing with these things is that sometimes it is actually exactly what you want -- e.g. some helloCount being incremented by interleaving hello() calls is exactly what you'd want. But in other cases while executing transferMoney() some interleaving call changing destination is terrifying. So sadly there's no good tooling as it all depends on what the "meaning" of variables are.

And rightfully so! It is very hard to program a fully consistent actor in face of re-entrancy which server developers know all to well... But I recently also had UI developers realize and become worried about it. There are workarounds to "pretend" non-reentrancy, and perhaps I should write them up soon as it might be helpful to others facing this issue. The patterns boil down down to "per request/work actor" spawning, such that they may not be re-entered by anyone since they only have one entry point kicking off the work. So in that sense, we have workarounds until we're able to re-visit the topic and solidify the best practice into a language feature -- this sounds like the right order to me.

Summary: :+1: I remain very nervous about introducing an actor type that does not really protect from race conditions; unlike any other actor runtime out there (no, this is not a case of "everyone is doing it wrong"), but I agree we can address this with future work and fine-grained reentrancy control. It would have been best to solve this in one go, but time wise that's sadly not realistic.

Implicitly nonisolated Sendable lets

It's very true that is is a pretty weird concept and one that was quite surprising to me as well as we developed the design. Though this isn't one of the topics that had me worried about the design, and I mostly thought "oh well, seems it'll make the app developers happy".

I will say though that connecting this with distributed actors is a bit far fetched, as IMHO we should not be allowing distributed properties to begin with anwyay, even even if we did, it would not be implicit but require opting in (same as a distributed func needs the contextual keyword to be distributed). But I digress :slight_smile:

Summary: :+1: This implicit feature has not really shown up in practice as much as one might have thought while converting applications and it indeed is very weird in the actor sense of the world. I'm happy that we're not pushing for it as it indeed makes one less edge case in the design :+1:

I am more than happy to spell out nonisolated let when necessary and think that also is clearer than inferring it based on the type.


I'd like to address a small side note if this affects the distributed actor design or not really that has popped up:

Yeah, that's all correct.

I don't think the implicit-non-isolated-let ever was an option anyway for distributed actors at all, because there's by far more limitations imposed by such type (e.g. serializability of exposed types, necessarily throwing on operations because network boundaries etc.).

And, last but not least, since I'd also be strongly against allowing distributed var anyway, meaning that only distributed func are IMHO allowable to be accessed on distributed actors -- this is in order to encourage proper programming style with such types; It really isn't the right way to think about distribution as "oh, just set a field remotely"


actor inheritance

Way back when we discussed this at first in the initial pitch threads I was arguing that we should support this just because it does not "cost much" and indeed there are a few use-cases I had in mind.

In this review iteration I'd like to revise my previous remarks on this -- as I've just spent months of battling initializer semantics :wink: Indeed, I had not fully internalized how painful they are in Swift, with the various rules governing class initialization. It indeed is quite painful and perhaps we can do a better job if the feature becomes necessary.

I do have a number of "would be nice to do only as a library, without compiler work" designs in mind, e.g. PersistentActor style APIs, that I would like to explore in the future, and lack of inheritance makes them infeasible or pretty ugly.

But on the other hand, perhaps this is yet another reason to do-the-right-thing™ and double down on compile-time-metaprograming (see also Serialization in Swift) rather than abusing inheritance. That would be the best outcome for all kinds of reasons.

Summary: :+1: on banning actor inheritance, and if we ever need it, let's revisit and perhaps introduce some simplified model of it.


All in all, this is shaping up excellent and I'm very pleased with phrasing everything in terms of "actor-isolated" and Sendable types and closures -- it really clicks quite well, even if we'll need another year to handle all edge cases.

10 Likes

Thanks for addressing some of my concerns @ktoso.

I thought so, and it makes sense.

I would recommend adding a bit more guidance on the proposal itself. Usually with this kind of new feature the proposal text has a big impact on how is explain to the entire community. Content creators will start with it and whatever they say will spread to everybody else. Because of that I think it's very important to have some guidance on it, specially since this specific topic is quite new to "ui developers" and at a first glance doesn't seem that dangerous. In may case, whenever I heard about "actors" I always understood that they solved all these problems so it came as a surprise to me that we still need to be that careful with them.

I'm not aware of the timelines here, but I agree that in a sense it would have been better to ship a complete and safer model. Hopefully it can be improved soon with more real world experience.

5 Likes

+1 to this

From where I stand, it makes sense to share immutable state (let) synchronously between actors. But it is clearly better for resilience if explicit.

I don't know if it makes sense for a public let property to default to nonisolated in the same module, and isolated publicly. It would favour resilience, while reducing annotations if only used in the same module. Yet I expect to call actors more frequently from another module anyway.

Yes

Yes

No. I played a little with the implementation in progress, but have no real-world experience of actors.

I followed on and off the proposal threads, and related threads.

I agree 100% - I make a lot of mistakes based on convenience features. I'm much happier with needing to add that underscore than getting something for free. And I agree that immutable isolated state isn't super useful, but it won't be confusing behavior. adding an additional nonisolated to your lets is kinda like needing to add private to your vars in a by default internal class. Sure, sometimes annoying, but always explicit.

Edit: Also, I had experience with actors in my programming language class and my brief time in HPC, and this implantation is by far the easiest to understand of the ones I've used. I invision wrapping MPI or similar using async/await and actors and being able to write pretty fast code quickly without the need for C. Will it beat the academic unreadable hand tuned nonsense I used to write? Probably not. Will I be able to write it faster and remember what the hell it does without reverse engineering it six months later? I hope so.

5 Likes

What would you suggest as a replacement in the meantime for type which need to share a lot of mutable state and functionality?

Could that state be extracted into a separate actor of its own?

Otherwise, if this is for Alamofire, I guess it's not unreasonable to make the "network stack" a global actor. In that case I guess you could just use classes and confine them to the "network stack" global actor? They would then share the same execution context (also each instance), but shouldn't that work out OK? I guess the downside would be less concurrency, but that could also be a plus (less context switches), right?

That was one of the alternative I posted above. You lose a layer of actor encapsulation with that approach, but it's probably what I'll start with: a class hierarchy that uses actors internally to manage access to the state.

A single global actor for all networking doesn't make sense given the arbitrary number of contexts users may need. We usually suggest on URLSession / Session per host, but even that's not absolute, as users may need specialized Sessions on the same host. I don't think there's a way to create global actor's per Session here. Currently Session does act as the source of the concurrency domain for all of its requests by sharing its DispatchQueue to all of the Requests as their target. I was hoping to at least have the option of replicating something similar with actors, but it's probably going to be difficult if I only use actors internally. Of course, since actors are much lighter than queues, it may not be necessary for me to manage the underlying context as closely.

I'm not so sure about that, as processing inside Alamofire should hopefully be light, and there should be no blocking when using async/await - even if confined to a single concurrency context. I'm of course not talking about Sessions or other contexts, only the execution context.

Processing inside Alamofire itself is lightweight, but users can perform arbitrary work in a variety of areas that Alamofire interacts with. Users can also create an arbitrary number of Requests at once, which was one of Alamofire 4's biggest issues, as each Request had its own DispatchQueue, so creating a ton at once could cause queue issues. I still don't think it makes sense to model Alamofire's context as a global actor.

That's indeed a fun use-case and I think it would be achievable with some future distributed work; or just a bunch of manual work writing wrappers today :slight_smile:

1 Like

Reentrancy
I still don‘t understand why reentrancy is something desirable.

Deadlocks
Take the following snippet of a non-reentrant actor which is then used to demonstrate a deadlock problem:

// assume non-reentrant
actor DecisionMaker {
  let friend: DecisionMaker
  var opinion: Judgment = .noIdea

  func thinkOfGoodIdea() async -> Decision {
    opinion = .goodIdea                                   
    await friend.tell(opinion, heldBy: self)
    return opinion // ✅ always .goodIdea
  }

The deadlock only happens because telling the friend is waited for. That is something I haven‘t got wrapped my head around yet. I always expected sending a message to an actor being asynchronous, i.e. the telling would be put into the mailbox of the friend and then the method immediately continues. No danger of a deadlock.

Unnecessary blocking
The following example from the proposal is intended to demonstrate unnecessary blocking due to non-reentrancy:

// assume non-reentrant
actor ImageDownloader { 
  var cache: [URL: Image] = [:]

  func getImage(_ url: URL) async -> Image {
    if let cachedImage = cache[url] {
      return cachedImage
    }
    
    let data = await download(url)
    let image = await Image(decoding: data)
    return cache[url, default: image]
  }
}

This can be easily rewritten by not putting the result into the cache but instead a future computing it:

func getImage(_ url: URL) async -> Image {
  let future = ... // download and convert to image
  return cache[url, default: future].get()
}

This even solves the problem of doing the same work twice.

In essence I am having trouble to recognize the proposed actors as actors because the single thread isolation idea break down due to reentrany and messaging actors seems not to be messaging at all. The simple mental model of actors as I know it is broken and I still don‘t get what we are supposed to get back instead.

1 Like

If I understand correctly, the issue with the way you describe it is "and then the method immediately continues". That is not correct. async just means the actor can be potentially suspended until the function call returns. It doesn't mean the actor will continue executing the next line. Then the problem is that the function this actor (self) is calling friend.tell(opinion, heldBy: self) is gonna make a call to the same actor and thus it has to wait for self to return too. So now both actors are waiting for each other.

May be different from other async frameworks but this is how I understand it after reading the structured concurrency proposal. In the context of a function the code executes sequentially as normal functions, is just that in some places it can suspend and wait without blocking the thread.

I guess you can look at it this way: With async/await we can finally get rid of blocking. Without reentrancy each actor method would not only block while executing (as expected) but any async method called from that actor method (like downloading an image), which would normally not lead to any blocking, will actually block the whole actor. This is not really the way of async/await :slightly_smiling_face:
Edit: With "blocking" here I do not mean blocking the thread, but blocking the actor from processing further work. As you mention downstream this is really due to the choice to make actor methods always wait for a response.

So the actor is blocked because it's currently executing an actor method, then the friend is "mailed" (keeping the first actor blocked), and then the friend tries to "mail" the first actor which is blocked. So the friend will wait indefinitely on the first actor, but the first actor is blocked while waiting for the friend to finish its method...

So you are missing some sort of await future there, and with that in place the getImage() actor method has to block the whole actor while waiting on that future, as far as I can see. Oh, sorry, so you are storing the future in the cache! We do not really have futures, but I guess you could store a handle to a detached task :+1: Edit: But then you would always download? I think I missed something here.

1 Like

It might be worth nothing that Actors and Tasks together can create deadlocks. Reposting example from an earlier thread:

actor Deadlock {
	var task: Task.Handle<Int>?
	func getValue() async -> Int {
		if task == nil {
			task = detachedTask {
				return await computeValue()
			}
		}
		return await task!.get()
	}
	private func computeValue() async -> Int {
		return await getValue() + 1
	}
}

Here we end up with a task depending on its own result, so any call to the actor's getValue will await indefinitely. A deadlock of the infinite suspension kind.

Claims that actors can't deadlocks have to be taken with a grain of salt. It's true if you consider actors in isolation, but as part of a bigger system they can still end up deadlocked.

My conclusion is that we don't need non-reentrant actors because we already have them. :smirk:

1 Like

Yes, my problem is that friend.tell is an async call and not a really asynchronous call (or just putting a new message in the mailbox of the other actor to be executed on that actor‘s single „thread“). Using async to call other actors creates a strong coupling of their execution and breaks the simple mental model of actors running on a single thread which processes messages from a mailbox.

1 Like

In my mental model of actors „mailing“ another actor should not be blocking. It should just put the message in the other actor‘s mailbox and return.

So, on one hand using async for calling other actors is designed to reduce blocking but on the other hand it introduces new blocking scenarios and complicates the mental model of actors and writing an actor method.

1 Like