Pitch: Protocol-based Actor Isolation

I agree with Doug. Values have "value semantics" when there's no (or at least minimal) high-level semantic difference between a value-copy and a deep-copy. But if there's a high-level semantic difference between these kinds of copies, we shouldn't be doing a deep copy implicitly; readers will not anticipate the semantic impact of a deep copy when they see something that just looks like an asynchronous call.

Now, I can imagine types for which implicit deep copies would be both necessary and semantically acceptable. To be sharable at all with equivalent semantics, they would have to have value-like semantics and not be inherently semantically tied to a single thread/actor. So there would have to be something about their representation that's not safe to share across threads/actors, which basically means using non-atomic reference counts — I don't know what else this could be, really. To me, it's hard to argue that that kind of opt-in optimization is worth complicating the entire async model over, since people using it could still presumably use whatever facilities there are to request explicit deep copies. (It also creates new performance problems — we'd sacrifice a lot of code size to these deep-copy operations, and it would require real heroics to turn a deep copy back into a value copy when e.g. passing an Array over an async boundary.) That is, unless you're suggesting that we should start doing this sort of optimization in the core library types like Array, which I do not think would be the right performance trade-off even if it weren't problematic for the ABI.

If deep copies have to be explicit somehow, then we still need a separate language mechanism that lets you pass most value types without that explicit step. It really feels like that mechanism should be at the center of the async restriction rather than the deep-copy mechanism, as long as whatever way we come up with for doing a deep copy lets you pass the result safely.

10 Likes

In the formulation that I ask about above, there would be no such generic code, because there would be no ActorSendable (just as there is no StringConvertible), only a protocol to opt into customized “actor-sendability” (à la CustomStringConvertible).

Right, as noted above, I’m interested in how you’d feel about the option of not having a marker protocol for “actor-sendability,” but rather (as in the case of CustomStringConvertible) exposing only a protocol for custom implicit work at actor boundaries. To my understanding, supporting such customizability is what @Chris_Lattner3 wants to make sure is available from the get-go, so that classes that can’t be supported otherwise aren’t just left out of the actor isolation story as a potential correctness footgun.

So my question is whether the cost of exposing such a facility is high if formulated as described above, as an opt-in for custom work and not as a marker protocol.

Put another way, is your objection based on a view that no type should be able to perform implicit work at actor boundaries, or that you wouldn’t want a design where supporting that possibility would lead to pervasive questions as to when and where there could be arbitrary work for any type? I’m wondering because I think the latter issue is separable from the former while still supporting types that require such customizability, if designed in the way described above—would you agree?

1 Like

I have been thinking about this proposal, and while I very much appreciate the intent to make reference types safer to use in concurrent code, I'm afraid that the well-lit path created by this proposal will lead users to do the wrong thing.

If someone has a reference type, and they want to make it usable across actors, they are encouraged to implement unsafeSendToActor() that performs a deep copy. With that, the reference type pretends to be a value type, but only when passed across actor boundaries. I don't think that is the right thing to do in the common case. I think that if the user has defined a reference type, it is because they wanted shared mutable state. Un-sharing that state when passing an instance across an actor boundary wouldn't be correct.

Furthermore, types that implement unsafeSendToActor() as deep copy have different semantics when passed to sync and async functions. When passed to sync functions, modifications done by the callee are visible to the caller. When passed to async functions, modifications are not visible. I think that's quite subtle and surprising, given the amount of effort the async/await proposals spend on trying to make sync and async function calls look and feel similar to each other.

15 Likes

Doug, the crux of your concern seems to be that people will incorrectly conform types to ActorSendable and then go out of their way to implement unsafeSendToActor incorrectly. Why are you concerned about this?

This is equivalent in my mind to the early concerns that people would just use T! (ImplicitlyUnwrappedOptional) everywhere to not have to think about nullability. In practice, every blog post and article everywhere did a good job explaining the issue and while some code did bad things (given a community the size of Swift, this isn't unexpected) the community quickly figured things out, learned new things, and still used IUO where appropriate (e.g. interacting with legacy C code).

There was a similarly concern when we were discussing DynamicMemberLookup - that people would conform types to it inappropriately and the world would come unraveled. This also didn't happen.

Coming back to this proposal, there is some chance of incorrect implementation and bugs (see below) but I haven't seen a counter proposal of how to solve these problems in a better way. You made some claims in your previous post that you had a model in mind for how to fix this in a better way - can you elaborate on that with a baked model? I consider the actor proposal without a solution for this problem to be a complete non-starter (rationale in the proposal).

Good question: an incorrect conformance turns into an incorrectly shared reference between actors, which turns into race conditions and other memory safety problems. This would have to be debugged using the same techniques one uses today to diagnose this.

Keep in mind that there is no proposal on the table that addresses this concern. The base proposal without this pitch has this BY DEFAULT for every reference type with zero protection at all. In contrast, the ActorSendable proposal requires you to go out of your way to incorrectly implement an unsafe protocol requirement to achieve the bug. This pitch makes the actor model far far safer and less prone to encountering problems in the wild than the actor proposal without it.

There is no alternative proposal on the table to address this in a better way. If there were, then we could have a nice tradeoff discussion between them. The current counterproposal is gross unsafety in all actor code everywhere.

I think this is a pretty significant misunderstanding of the model here ((edit: it turns out that it was my misunderstanding, see the post 66 below!)). In the vastly most common case, the implementation of the hook is trivial and inlined, there should be zero overhead for normal value types. If you are worried about the uncommon cases, then we could have a nice concrete discussion about adding new witnesses for this or something. This is all solvable if you think that performance is an issue.

I agree that deepcopy is not the right thing for arbitrary graphs and an explicit @NSDeepCopy thing is a good way to go in general. My personal belief is that the language should allow the type author to do the right thing for their type since they have the best knowledge about what is right for their type. Also, while I'm a huge believer in COW, it is important to support legacy code and other design patterns. Again that doesn't mean that the copy has to be implicit! :slight_smile:

Let me underscore that again: 99% of my concern is covered by being able to transparently share internally synchronized class references across actor boundaries, combined with the ability to define struct wrappers like NSDeepCopy and UnsafeTransfer that allow explicit APIs for weird cases. My concern about deep copying is only a 1% concern, and I would happily throw that under the bus if there is a better model that covers the important cases!!

-Chris

1 Like

I'd be a lot happier with a feature that emphasizes internally synchronized types, rather than copying. It could quite possibly be that it is the same proposal, but with different library components for deep copying -- that would leave a trace in each async function signature and at each call site to remind the user of the un-sharing of shared mutable state.

After thinking about this a bit more, I think there are a couple directions we could take this to improve areas of concern.


There is a concern about abuse, equivalent to the original concern for DynamicMemberLookup where people would add conformances willy-nilly, which has a very large impact on users of a type at large.

To solve this, we can apply the same mitigation that was originally applied to DML: we can forbid retroactive conformance. This achieves my goal of "putting the type author in control", while reducing the chances of gross misuse of the protocol by folks who don't know any better. This also allows the construction of things like NSDeepCopy (which require explicit implementations, not just return self) while providing certainty that imported classes would not get this behavior implicitly in any case.

Going further we can make the more explicit in other ways, e.g. requiring an extra attribute in cases that explicitly implement the unsafe hook. I don't think this is necessary above and beyond the "unsafe" marker in the method, but we could investigate this if there was interest.


There is a concern about the runtime cost of the deep application of unsafeSendToActor(). I didn't understand this last night, but I can see a concern with the implicit protocol synthesis when applied to very deep aggregates (structs of structs of structs) whose implementations don't get inlined due to resilience boundaries. It is also a problem for Array and Dictionary which need to "deep copy" themselves to handle elements that are ActorSendable but not ValueSemantic. I agree that this is a serious issue.

To solve this, we can pull ValueSemantic into the proposal, and change the implicit conformance generation to apply to ValueSemantic instead of ActorSendable. This would mean that the recursive copy is just through the existing copy witness, which has to be applied for argument passing anyways. This makes the proposal "actually zero cost" for ValueSemantic types.

In my opinion, ActorSendable would still be a different protocol, but ValueSemantic would refine it (implementing the hook with return self) so you don't get the deep application of unsafeSendToActor. This eliminates the yuckiness where the Array conformance to ActorSendable is implemented in terms of map which made me uncomfortable.

ActorSendable would remain in the proposal to allow implementation of NSDeepCopy and conformance by internally synchronized reference types that are ActorSendable but not `ValueSemantic.


What do folks think about any of these changes? I'm also very interested in evaluating alternate models if someone has ideas here.

-Chris

1 Like

At first glance I think these changes sound good. You talk about "requiring an extraneous attribute" but still talk about an ActorSendable protocol as well. Can you elaborate on this? DML only uses the attribute, not a protocol, so it sounds like what you have in mind is slightly different.

My understanding of your proposed revision is something like this:

// both attribute and explicit conformance are required for manual conformances
@actorSendable
class InternallySynchronized: ActorSendable { ... }

// implict `ValueSemantic` and `ActorSendable` conformance 
// for trivially / recursively value-semantic types
struct ID {
    let rawValue: String
}

struct GenericValue<T> {
    var value: T
}
// explicit `ValueSemantic` conformance required when constraints are necessary?
// no `@actorSendable` attribute required because the conformance
// is trivial / recursive when the constraints are met?
extension GenericValue: ValueSemantic where T: ValueSemantic {} 

Can you confirm whether I understand the revisions you're proposing correctly or not?

1 Like

Sorry, yes you're right, I misremembered the history here. I meant that a redundant attribute could be required for in cases that have an explicit implementation of the unsafe protocol requirement.

Just to be clear, I don't think this is a necessary or good idea, just throwing it out for discussion. I think that forbidding retroactive conformance would be a conservative good step though.

I'll edit the post above to clarify.

Agree, I think that is probably a good idea regardless of syntax.

1 Like

The thing with CustomStringConvertible is that we pay a very high cost for the existence of this protocol. Any type is printable with a bunch of reflection, but every time we want to print a type we have to go look up whether it is CustomStringConvertible to see if there's an override. That kind of runtime lookup isn't acceptable at actor boundaries. Maybe that's not what you're suggesting?

I fully expect that we'll have some kind of UnsafeTransfer<T> wrapper struct that you can manually put a T into to turn off checking. Then your call site might be something like:

otherActor.doSomething(UnsafeTransfer(myClassInstance))

and that's fine. You can do work in the UnsafeTransfer initializer.

I accept that UnsafeTransfer can be a property wrapper so that the "unsafe" bits are hidden in the declaration of doSomething if/when parameters can have property wrappers on them, e.g.,

func doSomething(@UnsafeTransfer _: MyClass) { ... }

and the call site won't be as obvious:

otherActor.doSomething(myClassInstance)  // implicitly wrapped in UnsafeTransfer

because this keeps all of the custom work out of the actors mechanism.

Doug

I think the lesson we learned from DynamicMemberLookup is that this kind of mitigation is useful to mollify design concerns in the short term, but begins to feel frivolous once a feature gains usage experience. If we add such a restriction, it should be because we have a solid implementation reason to do so.

It makes it "actually zero cost" for types that are concrete enough to be statically known to conform to ValueSemantic, but if you're calling cross-actor with a T that is only known to be ActorSendable, or an Array<T> where T is only known to be ActorSendable, you're paying the whole cost of the recursive traversal.

It doesn't eliminate the yuckiness. It makes the yuckiness kick in less often. You'll still have two conditional conformances for Array:

extension Array: ActorSendable where Element: ActorSendable {
  func unsafeSendToActor() -> [Element] { map { $0.unsafeSendToActor() } }
}

extension Array: ValueSemantic where Element: ValueSemantic {
  func unsafeSendToActor() -> [Element] { self }
}

... and as we know, you don't always get the self-returning implementation of unsafeSendToActor if you use an Array<T> generically when you only know that T is ActorSendable.

In a previous reply, you said:

So let's throw it under the bus: remove unsafeSendToActor completely from the model. All of the types we care about--types that provide value semantics, internally-synchronized class types, immutable classes--just return self anyway. Make that the model: ActorSendable means the normal copy witness does the right thing. It's a marker, and nothing more.

NSDeepCopy and UnsafeTransfer can exist as wrapper structs that always conform to ActorSendable. As I noted in my reply to @xwu, these can be explicit at the call site or at the declaration site (and your proposal alludes to these as well). It's not that one cannot do deep copying or unsafe with actors, it's that the actor model never performs these operations for you.

If you accept the removal of unsafeSendToActor, I don't think you gain anything from pulling in the notion of ValueSemantic.

Doug

5 Likes

While I agree with the idea of making a deep copy explicit at the call site, someone might write this a bit naively "to avoid duplicating work" and the compiler will happily allow it:

let copy = NSDeepCopy(thing)
await doIt(copy)
await doItAgain(copy)

How do we ensure each async call gets its own distinct copy in this case?

Move-only types. :-) But until then, encourage using argument property wrappers rather than instantiating the helpers directly.

1 Like

One contrast between the proposed unsafeSendToActor API and other unsafe-flavored APIs is that the result of unsafeSendToActor is Self, whereas the result of existing unsafe APIs usually is an Unsafe type like UnsafePointer. I believe this is a significant drawback of the proposed design, since when I use an UnsafePointer I am informed that this thing is unsafe to use, but the proposed API just creates what looks like a safe value that may have very unsafe semantics which can cause undefined behavior down the line.

I'm not sure if this is feasible, but it would be great if whatever system was in place to manage actor-isolation could provide stronger guarantees than the existing unsafe family of API, and more akin to how we do bounds checking for accessing Array items by index. For instance, the system could deterministically crash if a reference type violated the law of exclusivity due to a concurrency issue (I'm not sure what the behavior would be in the current actor proposal). This would obviously come at a great performance cost since every access would need to be synchronized, but I wonder if it would be possible to make the performance acceptable in most situations. Value-semantic copy-on-write types types like String (and other types which are internally synchronized) could opt out of this check explicitly (maybe via a Threadsafe: AnyObject protocol). Situations where a reference is sent to an actor and then never accessed again in the caller can also have this check optimized out. Down the line, I wonder if it would even be possible for a reference type to track which execution contexts it is referenced in and elide synchronization for when it is only referenced in a single execution context.

The advantage of deterministically crashing over something like unsafeSendToActor would be that errors in the implementation of unsafeSendToActor would still be painful debugging sessions, whereas a crash would show exactly where a value is violating the law of exclusivity and the task for the developer would be to trace down how that value ended up being propagated to several concurrent actor contexts.

I can see where you're coming from, but I personally learned a different lesson from that. I learned from it that the fear of misuse is common when new features are proposed ("I would never do the bad thing, but someone else might and I get stuck with their decisions"), but die down later. This happens when the community gets more experience and the documentation and blog posts explaining it get written.

With this viewpoint, I don't think that it is necessarily bad to start locked down and relax later, so long as it doesn't add long term complexity to the language to support that.

I respect that this is your opinion. However, stating this as fact doesn't seem warranted.

I think you're misunderstanding my new proposal. I'm suggesting we drop the conditional conformance of arrays whose elements are "merely ActorSendable" completely, and just maintain the conformance of ValueSemantic when the elements are ValueSemantic.

This doesn't come at any expense of generality (someone writing an aggregate type containing an array of merely ActorSendable types can still write their own transfer function) - such a change reduces the potential for accidental performance cliffs.

To be clear, I'm saying that the first conformance implemented in terms of map is not included.

I can be convinced of this, but two problems I see:

  1. You just made the proposal less safe. In the base proposal, you have to conform to the protocol, then implement the requirement incorrectly. In this approach you drop the second step. This is perhaps unavoidable for ValueSemantic anyway, so maybe I should just forget about this.

  2. I don't see how you implement NSDeepCopy, because it's hook needs to do something other than returning self.

Right, that is exactly what I'm proposing. This is a key point of the whole proposal. The other thing about this is that there is nothing framework specific about this - this structs are library defined, so if you want to interoperate with another existing framework (the GNOME UI Framework?) you can have your own domain specific adaptors as needed.

The whole point is to drop the map implementation of the Array conditional conformance (and all other collections). ValueSemantic and ActorSendable are two related but different concepts.

Please let me know if the above makes sense to you. I understand that it is hard to imagine a diff on top of a standing proposal. If it helps, I can make an alternate version of my pitch that incorporates the changes I'm suggesting. I probably won't get to it for a few days tho.

-Chris

But that's the whole point of the proposal: ConcurrentHashTable and MyImmutableTree are both perfectly safe to use even though they implement the unsafe hook. Implementing the hook incorrectly isn't "unsafe", it is incorrect (a bug).

This is key to how the proposal works - it allows you to use the unsafe API to vend a safe design point for your type. This is the same thing as how Array is implemented in terms of UnsafePointer and doesn't expose unsafe pointer out of its API (well, not implicitly). Your example of bounds checking is a great example of this.

Swift has a long history of allowing safe APIs to be built out of unsafe bits and pieces. This fits directly with that model.

I don't think this is the right way to go. Unsafe APIs are the "general case" that covers the long tail of weird things when specialized solutions don't work.

Instead of trying to make unsafe APIs safe, I think we should introduce alternatives that are safe by construction and happen to be ActorSendable in the right cases. There are many theoretical things on the docket that could fit in here, including various proposals for unique ownership, cow modeling, move semantics, etc. When those come into the system, we should encourage people to use them for the subsets of the problem they cover (and the long tail can remain unsafe).

There are strong analogies to this in other parts of Swift. For example, for memory management, we support both ARC and the unsafe Unmanaged<> API. This forces an unfortunate choice between "safety with less performance" (in some cases) or "unsafety with full performance". I hope that someday we introduce ownership semantics to provide a new "safety at the cost of additional type system annotations" point in the space. Such a move won't define away the need for unsafe APIs entirely, but should cover a lot of common use cases and make swift better in general. Such an approach makes just as much sense for ActorSendable in the years ahead.

-Chris

Removing the conditional conditional conformance of Array to ActorSendable is likely to be a bit... confusing. It's semantically sound, and very convenient, so I expect we'll end up getting many overlapping retroactive conformances of "Array of something" to ActorSendable. We can absolutely say "don't do that, wrap it in a struct that does the same thing," but it's an unfortunate bit of advice we would have to give.

Ah, I should have just written it out. Doing it as a property wrapper on a function parameter might just be the best way:

@propertyWrapper
struct NSDeepCopy<Wrapped: NSCopying>: ActorSendable {
  let wrappedValue: Wrapped

  init(wrappedValue: Wrapped) {
    self.wrappedValue = wrappedValue.copy() as! Wrapped
  }
}

actor class MyActor {
  func method(@NSDeepCopy string: NSAttributedString) async { ... }
}

You'll implicitly get the copy when calling the method. The actual NSDeepCopy<NSAttributeString> that ends up getting passed to the function uses the normal copy witness.

This takes implicit customization for sending values out of the actor system and made it explicitly use an existing feature, simplifying the actor system itself---and explicit marking those places where the API contract involves a deep copy.

Yes, they are different concepts. I am saying that, for both ValueSemantic and ActorSendable, we want to to be a normal "copy witness" copy to transfer a value across actors.

I know what you're going for, but I think the NSDeepCopy approach I've detailed above is simpler overall, and has the benefit of eliminating the need for the unsafeSendToActor requirement at all. ActorSendable reduces to a marker protocol indicating that it's okay to use the type across actor boundaries.

Doug

1 Like

Apologies if this has been answered in the meantime (and it may not be relevant anymore given @ktoso's reply here that "actor groups" in Swift could be modeled by multiple actors sharing the same executor), but are you thinking of the E language? I know nothing about E, but I found references to the term "vat" on E's Wikipedia page:

In E, all values are objects and computation is performed by sending messages to objects. Each object belongs to a vat (analogous to a process). Each vat has a single thread of execution, a stack frame, and an event queue.

And in Message Passing and the Actor Model:

The E language implements a model which is closer to imperative object-oriented programming. Within a single actor-like node of computation called a “vat” many objects are contained. This vat contains not just objects, but a mailbox for all of the objects inside, as well as a call stack for methods on those objects. There is a shared message queue and event-loop that acts as one abstraction barrier for computation across actors.

Aha, that makes sense.

Agreed, very nice, I'll incorporate this when I get a chance, thanks!

-Chris

I don't recall hearing about E so I don't think so. I thought I encountered this idea in a talk given by Carl Hewitt but haven't been able to find a reference so that may not be correct. In any case, it does look like E incorporates this concept roughly as I understand it.

I think global actors in the currently proposed design get us close, but would like for them to be better incorporated into the type system. There is a subthread in the discussion of the main actor proposal where I show how that could be done and how it might be used. Relevant posts are:

This subthread died without any clear resolution. If you're interested in this topic maybe we should continue over there?

1 Like