How do I make an actor's actions non-blocking?

Currently learning Rust and getting my hands dirty with Tokio as I was following along with this article on Tokio actors. I found myself in a scenario where an actor has its own state and methods to update its own state, but soon found out that going down this approach doesn't allow for concurrency on the actor's end, and since an actor is not thread-safe (holds a Receiver), I ended up shifting its state into the run_my_actor function and something about that doesn't sit right with me.

I'm looking for pointers on where I messed up/a better approach for this. I'm still fairly new to Rust so I appreciate any guidance.

Below is an example code snippet, omitted some functions and modified for brevity.

struct MyActor {
    name: String,
    receiver: mpsc::Receiver<ActorMessage>,
    processed_ids: Arc<RwLock<HashSet<u64>>>,
}

enum ActorMessage {
    Ping { respond_to: oneshot::Sender<String> },
    ProcessMessage { id: u64, body: String },
}

impl MyActor {
    fn handle_message(&mut self, msg: ActorMessage) {
        match msg {
            ActorMessage::ProcessMessage { id, body } => {
                self.processed_ids.write().unwrap().insert(id);
            }
            ActorMessage::Ping { respond_to } => {
                let _ = respond_to.send(format!("Pong from {}", self.name));
            }
        }
    }

    async fn purge_old_messages(&mut self) {
        // Sleep to simulate a long-running task
        tokio::time::sleep(Duration::from_secs(30)).await;
        self.processed_ids.write().unwrap().clear();
    }
}

async fn run_my_actor(mut actor: MyActor) {
    let mut ticker = tokio::time::interval(Duration::from_secs(60));
    ticker.set_missed_tick_behavior(MissedTickBehavior::Delay);

    // TODO: Allow processing of `Ping` while `purge_old_messages` is running
    loop {
        select! {
            _ = ticker.tick() => {
                actor.purge_old_messages().await;
            }
            Some(msg) = actor.receiver.recv() => {
                // Cannot tokio spawn here as actor is not thread safe
                actor.handle_message(msg);
            }
        }
    }
}

// Omitted Handle code

It's entirely normal for actors to only do one thing at the time. After all, one of the advantages of actors is that you get exclusive access to the actor's resources, but if you do many things at the time, the access is no longer exclusive.

That said, there are some options:

  • Spawn a background task that sends a message to the actor when it's done.
  • Keep a JoinSet of background tasks, and add a set.join_next() call to the select!.
  • Occasionally you can run it directly in the select!, but this is often tricky.

All of these complicate access to the actor's resources since it suddenly needs to be shared. Can you say more about why you need a long running task like this? And what kind of access does it need to the actor's resources?

1 Like

Something I was exploring was whether actors can replace passing Arc<RwLock<T>>>s around.
I find it quite pleasant to interface with the ActorHandle through the defined methods.

The mental model that I had is along the lines of each actor is a "server" that holds its own state and is in charge of doing its own specific thing within its domain (can modify its own internal state).

Other "servers" can communicate (only read/clone a subset another's state) to them via channels in order to complete their own jobs. There may be instances were some "servers" are more read-heavy than others, and it might be beneficial to allow parallel reads to their internal state.

This then brings up 2 questions:

  1. Are actors a valid use case for this "servers" scenario?
  2. If not, what would be a good approach to explore?

One possible approach would be to give the actor its own exclusively-owned copy of the state which it modifies, and after it computes the next state, it clones that state into the shared RwLock.

If, in addition to this, the clone is first sent on a channel to a separate task responsible for the rwlock.write(); then the actor no longer ever has to wait for anything.

The advantage of doing it this way is that the first actor now cannot participate in any deadlock, and it’s “definitely an actor” rather than “debatably an actor” because it completes its own actions solely by updating its state and sending messages.

1 Like