Pitch: Protocol-based Actor Isolation

Hi all,

I got a chance to write up some thoughts on how to address one of the major challenges with the recent actor model proposal: how do we ensure that values transferred between actors do not introduce shared mutable state.

I'd appreciate thoughts and feedback, thanks!

-Chris

40 Likes

At the quickest of quick glances:

This is exciting, and brings static analysis into realms where it’s longed seem poised to work in Swift, but the language has always shied away from it.

I wonder if the name ActorSendable is too narrow? It seems like there are broader use cases for semi-synthesized type system tracking of (1) value semantics in particular, and (2) thread safety in general.

It also seems like (1) and (2) are distinct cases: value semantics have use beyond thread safety, and (as Chris’s sketch points out) not all actor-safe types have value semantics. Should there perhaps be a ValueSemantics protocol that follows Chris’s recursive synthesis rules, and all ValueSemantics types are automatically ActorSendable?

3 Likes

Repeating a comment I made on the linked document here just to keep discussion consolidated to one platform:

The compiler rejects any attempts to pass data between actors when the argument or result does not conform to the ActorSendable protocol, and can/should reject any public declaration that does this for QoI reasons:

actor class SomeActor {
 // error: ‘NSMutableString’ is not ActorSendable; it may not be
 //        passed to a public actor method
 public func doThing(string: NSMutableString) async {..}
 // async functions are usable *within* the actor, so this
 // is ok...
 internal func doOtherThing(string: NSMutableString) async {..}
}

// ... but this cannot be called by other actors:
func f(a: SomeActor, mystring: NSMutableString) async {
 // error: ‘NSMutableString’ may not be passed across actors;
 //        it does not conform to ‘ActorSendable’
 await a.doOtherThing(string: mystring)
}

I’m not understanding the distinction between public and internal methods here. Why not apply the same restriction to public methods taking non-ActorSendable arguments, i.e., they are usable from within the actor but not across actors?

This seems to break the "access control is orthogonal to actor-isolation" principle, and would prevent actor authors from defining public methods which traffic in actor-internal types that are meant to be used by actor subclasses or extensions.

Also, of the five patterns listed in the proposal (Value Semantic Compostion, Immutable Classes, Internally Synchronized Reference Types, "Moving" Objects Between Actors, and Deep Copying Classes), it's relatively apparent to me how ActorSendable most of these (automatic synthesis, UnsafeActorSendableBySelfCopy, or custom implementation of unsafeSendToActor by deep copy).

The only one I'm having trouble picturing is how ActorSendable addresses the "Moving" Objects Between Actors pattern. Is ActorSendable supposed to provide a solution for this pattern? If so, what does that look like?

1 Like

This is not a core point to the proposal, and you're right that it breaks orthogonality. I'll drop this. It was intended to catch bugs earlier, but now that you mention it, it doesn't seem worth it.

Check out the UnsafeMove example in the "future work" section!

Thank you for the feedback,

-Chris

1 Like

Yes, I agree that these are distinct but related cases. I think the introduction of something like a ValueSemantics protocol could be a very interesting idea. I think the trick there is to figure out how to precisely specify it and understand the constraints.

There have been a lot of discussions about this historically, but I haven't paged them in. It would be interesting to discuss that in depth in another thread. I added this to the 'alternatives considered' section at the bottom so we keep the link. Thanks!

-Chris

2 Likes

Ah, thank you, exactly what I was looking for!

It seems to me that since the semantics of NSCopying already specify that "a copy must be a functionally independent object", we could treat the "deep copy" more like UnsafeActorSendableBySelfCopy:

extension NSCopying {
  func unsafeActorSendable() -> Self { return self.copy() }
}

The struct-wrapper idea makes total sense for UnsafeMove since we're concerned about the client's usage of particular instances of the passed type, but for NSCopying I think that conforming types have already promised that they are implicitly ActorSendable by copy() (and we could still force them to make that conformance explicit or not).

Thank you for writing this approach up @Chris_Lattner3, I think it looks really nice! I critiqued the global actor aspect of the design in a similar way. I know you had some questions about why that is useful. Maybe the examples I gave will demonstrate the utility.

I recall hearing of or reading about an actor system that had a similar notion. They called it "vats" - a vat could have many actors in it and it provided a serialization context for all of those actors, allowing code to take advantage of the knowledge that this context is shared. I haven't been able to dig up any references on this, but have used the idea in my own code and found it very useful.

On the topic of implicit vs explicit conformances, I think a significant drawback to implicit conformances is the site of the errors that will occur.

When the user passes a value of type T across contexts they will get an error about the missing conference of T. With implicit conformances, the actual reason for this error could be that some type U upon which T's conformance recursively depends is not provided. For example, maybe U is the NSMutableString from your example and a value of this type is several layers of types down, (i.e. T does not directly contain the string but one of the members of one of its members, etc... contains the string).

The actual logic error the programmer probably made is in the type that stores an NSMutableString may not be mentioned anywhere in the error. Even with a detailed understanding of how the type system machinery works, I can imagine myself taking a while to track down the actual problem.

If the compiler is smart enough to sort this out and consistently provide high quality error messages targeted at the programmer's likely misunderstanding of the code then maybe implicit conformances would work. But I think we should be careful about going down this path without having some experience.

This was my immediate thought on reading your proposal as well. It would be extremely exciting to see Swift understand types that have value semantics. The way I conceptualize this constraint is that heap allocated data is treated as copy on write.

I don't know how to specify all the details but I think one easy thing for the compiler to verify is that all public stored properties must have value semantics. With that rule in place, we only need to specify how lower level copy on write behavior is verified for code within the declaring module (or that the code is annotated as "trust me" similar to how we do with "unsafe").

Nice, this would be a great extension to the ObjC overlays. I mentioned this in the future work section, thanks!

Yes, I agree. This is what I meant by "eager" error production. I added a link to your description here to provide more details, because this is exactly what I meant (but had trouble expressing in a bullet :).

Thank you for the feedback @anandabits and @Jumhyn!

-Chris

Yes please! Actually I think his UnsafeActorSendableBySelfCopy protocol should actually be ValueSemantics. For the rare case where a non-value-semantic type is sendable by self copy, I really don't mind asking people to write the conformance manually!.

1 Like

Ok, I see what you are saying here now, sorry I was very confused for a bit :-). The concept of UnsafeActorSendableBySelfCopy was to get the default implementation, which I don't think is appropriate for a base ValueSemantic protocol.

-Chris

I can suggest one more pattern - a cookie. Actor is passing to another actor a reference to a piece of mutable state that belongs to the first actor. The second actor is not allowed to read or write the data. It can only pass it back to the first actor.

This also could be solved with a wrapper struct. For safety, wrapper struct should carry a reference to the owner and check it when unwrapping.

A custom case of this is an async closure. It carries inside it the reference to the executor, and when called, switches executors if needed.

So async closures should be fine to pass between the actors, which implies that async closures should conform to ActorSendable.

I don't think I understand. It sounds like you're saying some part of this declaration would be inappropriate:

protocol ValueSemantic: ActorSendable {
  func unsafeSendToActor() -> Self { self }
}

If that's what you're saying, what part, and why do you think it's inappropriate? If not… what are you saying?

1 Like

Dave, the problem with you is that when you explain things clearly, you make me realize that I'm hopelessly confused. :slight_smile: :slight_smile: You're right again, and I'll incorporate this into the proposal, thanks!

-Chris

8 Likes

I think precisely specifying the semantics expected of a manual conformance to ValueSemantic is the most important part of this protocol. Should discussion of that topic be considered in scope for this thread?

2 Likes

I've started a new thread for that.

This includes generic structs, as well as its core collections: for example, Dictionary<Int, String> can be directly shared across actor boundaries.

I was skeptical of this, because it looks like the implementation of CoW within dictionary uses an unsynchronized check-then-act pattern. I'm trying to craft a race-condition scenario, but I failed to do so:

  1. Actor A has a public let dict: Dictionary<Int, String>, uniquely owns its backing storage (ref_count = 1)
  2. Actor A begins a mutating operation on the dict.
  3. Actor A calls isUniquelyReferenced (somewhere inside of the implementation of the mutating method). A determination is made that isUnique = true.
  4. Actor B accesses a.dict
    1. This increases the backing storage's ref count to 2
  5. All mutation operations B attempts will lead to a copy because the ref count is 2.
  6. Actor A continues down its code-path for in-place modification (since isUnique was true)
  7. Everything works out fine?

I suppose that this lock-less check-than-act is safe, because by the time you have the ability to call isUniquelyReferenced, you already have a newly-retained owning reference which pulls you out of the contented ref_count = 1 scenario.

Hypothetically, this can race in a situation where you have two references to the same back storage, but a retain count of 1. Obviously this should never happen.

Is this correct?

1 Like

I don't think such a protocol exists, because the properties we associate with "value semantics" generally arise from operations, not the types themselves. But I don't think that such a protocol is necessary for the subject at hand—if we're talking about inter-actor communication, there is in a specific operation whose properties we're interested in, sending a value between actors. The question is similar to Codable in that it comes down to how much data comes along for the ride; for what we think of as "value types", it's just the value itself, but classes can also conform to Codable and serialize related object graphs, so that you can create a fresh orthogonal object graph somewhere else.

2 Likes

It's fine with me if we use different terminology, but as Chris pointed out a refining protocol with semantics that support a trivial default implementation of unsafeSendToActor is worthwhile. I posted more detailed thoughts in the ValueSemantic thread.

1 Like

One way to think about it might be that a "value type" is one where Codable/Sendable is a pure operation.

That’s exactly right, yes. That is why checking for a unique reference doesn’t have to be part of some complicated atomic scheme: to get a race, you either have to have two threads mutating the same reference (and thus a higher-level race that you can’t “fix”) or two different references (in which case at least one of them should observe a reference count greater than one).

The lack of an atomic sequence does mean that it’s unreliable whether any thread observes a unique reference. In an ideal world, you’d like one thread to see a unique reference and just modify the object in-place. But to do that, you’d need these checks to be much more expensive, and you’d have to serialize all make-unique operations; it’s not the right trade-off to make.

1 Like