At $dayjob I've taken to just implementing my own actor-like model when I need interior mutability across tasks. Something like:
struct State { counter: usize, control: mpsc::Receiver<Msg> }
struct StateActor { addr: mpsc::Sender<Msg> }
enum Msg {
Increment { reply: oneshot::Sender<()> }
}
impl StateActor {
pub async fn increment(&self) {
let (tx, rx) = oneshot::channel();
let msg = Msg::Increment { reply: tx };
self.addr.send(msg).await.unwrap();
rx.await.unwrap();
}
}
impl State {
fn start(self) {
tokio::spawn(async move {
/* ... tokio::select from self.control in a loop, handle messages, self is mutable */
/* e.g. self.counter +1 1; msg.reply.send(()) */
})
}
}
// in main
some_state_actor.increment().await // doesn't return until the message is processed
A StateActor can be cheaply cloned and used in multiple threads at once, and methods on it are sent as messages to the actual State object which loops waiting for messages. Shutdown can be sent as an in-band message or via a separate channel, etc.
To me it's simpler than bringing in an entire actor framework, and it's especially useful if you already have control loops in your program (say, for periodic work), and want an easy system for sending messages to/from them. That is to say, if I used an existing actor framework, it solves the message sending/isolation part, but if I want to do my own explicit work inside the tokio::select loop that's not strictly actor message processing, I already have a natural place to do it.
To me it's simpler than bringing in an entire actor framework, and it's especially useful if you already have control loops in your program (say, for periodic work), and want an easy system for sending messages to/from them. That is to say, if I used an existing actor framework, it solves the message sending/isolation part, but if I want to do my own explicit work inside the tokio::select loop that's not strictly actor message processing, I already have a natural place to do it.