I'm building an actor library and have ran into a sticking point. For context, the library is powered by macros, so I have a lot of leeway in terms of generating or modifying the implementation for a given actor on top of the code provided by the user. However, two things I want to support in the design are:
- I want you to be able to implement traits on your actors and upcast them into trait objects (or equivalent) so the API to create an actor of type
Actorhands outArc<Actor>. I need to useArcspecifically here because being able to upcast "through" a type, i.e. to produce aArc<dyn Trait>, is governed byCoerceUnsizedwhich is unstable. Except for the issue I'm about to explain, the lifecycle of the actor is supposed to be tied to theArcvalues as you'd expect - the actor persists, receiving any messages sent to it, until allArcvalues pointing to it are dropped, at which point it processes any remaining messages and drops its internal state. (It is also possible for the actor to stop for a couple of reasons without all of theArcs dropping, but I know how to handle that.) Conceptually, the actual value behind theArcis a queue-sender connected to the actor's message loop. - when the actor's message handler runs, there is an API available to it that gives out a copy of the actor's own
Arc<Actor>. This is provided because various patterns used for actors and async queues involve passing your own address as a destination/receiver, and forcing a client that needs to do that to manually make it explicitly part of the actor's state is unnecessarily awkward. Naively, we might do this by storing aWeakinternally so as to not disturb the actor's lifetime and then upgrade it as needed.
However, now there is a problem: the Arc values are owned on other tasks (in the async sense) by whatever client code wants access to the actor, whether other actors or other arbitrary parts of the system. OTOH, the user's message handlers run on the actor's message loop, which is defined as its own separate task. This means that there is a scenario where the actor's last Arc might drop while there are still messages waiting to be processed. The handler for that message might then call the API for a copy of its own address and... the Weak fails to upgrade, so we can't return the contracted Arc.
Potentially, the API could instead return a Weak (or Option<Arc<_>>) and let the user handle the potential failure to upgrade, but I don't like this option because I expect that in almost all cases, asking for the address and not being able to get it can only be handled by the actor self-destructing by panic, and forcing every client to deal with that is conceptually messy and leads to a boilerplate else { unimplemented!() }. Additionally, it seems to cut against the entire point of the actor model managing state changes by tying them to messages being passed - instead, we effectively have the actor (irrevocably) changing from a "full" to "walking dead" state, spontaneously and untethered from any information the actor has access to.
Instead, I have come up with various solutions that allow the API to return Arc and always succeed even if all external handles have disappeared. However, they all have tradeoffs:
- Store the strong
Arcinside the event loop, so we can always hand it out. This creates a situation where if all the externalArcs disappear when we don't have any messages pending, the event loop hangs forever because it is waiting on a sender that remains alive because the event loop itself is holding one of its theArcs, and (because it is waiting) it does not get an opportunity to realise that its internalArcis the only one in existence and it should shut down. To fix the hang, I put a timer inside the event loop, so the actor artificially "gets a message" even with no other senders, at which point the check can happen and close everything down. While this solves the hang, it also means dead actors do not release their memory when their lastArcdrops, but instead potentially as late as the next time their timer fires. While I can offer the user control over how often the timer goes off, this would still mean idle actors create some non-zero CPU load simply checking if they need to stay alive, although I expect it'd only be a few operations per actor per wakeup in practice. - Store the strong
Arcas above, but instead of each individual actor having a timer, have a single global "supervisor" actor that unblocks loops by sending all other actors occasional messages even if no other senders exist. This means the CPU load for collecting garbage no longer scales with the number of actors in existence, but is nonetheless still not zero. This is the option I've investigated least, and in particular I have not dug into issues like how such a supervisor should work so as to both 1) not potentially flood the mailbox of actors that are still alive and/or still receiving messages, 2) not allow a dead actor to hang around indefinitely. However, I think this is least intrusive as far as user code goes, because subscribing to supervisor can be done entirely behind the scenes by the macros that produce the actor machinery in the first place and any knobs it has to tweak are less important. That said, I do not like this option because it creates a point of centralisation and bottleneck, various overheads, and the rest of the library has avoided having global structures of any kind up until now. - Store a
Weakand allow theArcto drop, but recover the underlying sender and store it within the event loop. This means in the original problem scenario where the message handler asks for itsArc, and the originalArchas dropped, the underlying sender is still available, meaning we can allocate the sender into a newArc, and hand that out. This seems to maintain the API, at least as far as typing and relational logic goes - the handler gets anArc, and thatArcpoints to the sender corresponding to the same message queue the handler is processing. However, it has a potentially surprising downside: from the POV of a component holding only a weak reference to an actor, it is possible for the "same" actor to reappear with a different address at a later point. (This can only happen if all of the strongArcs drop and, afterwards, the message handler hands out a copy of the address. So long as anyone anywhere holds a strongArc, the address is stable.) I would appreciate any comments on the impact this might have in practice, because I cannot think of any use cases where we would hold aWeak[1] but it is somehow semantically important that 1) even thoughptr_eqreturns false, 2) the reallocated "same" actor can't be treated as a completely new object. That said, the upsides of this method are appealing: there are no knobs to tweak, no CPU load us created by idle actors, and it Just Works™ if you don't care about using the address of the actor as an identifier. (AFAICT using theArcimplementations ofEqorHashdo not involve the underlying address so any comparison using those would be unaffected.)
I've come here to ask for feedback because I'm not sure which method to choose. If you were using this library in a project, which of these do you think offers the most in terms of making it easier to reason about actor interactions and how your software will behave? Which is most understandable, and/or easiest to parse in terms of performance characteristics?
Please feel free to mention any other solution that I might have missed too.
which by nature might be dead at any time ↩︎