Hi everyone,
I'm building a WebSocket client using the actor model with tokio
, inspired by Alice Ryhl's article "Actors with Tokio". The article suggests splitting a connection into a dedicated reading task and a writing task:
Multiple actors sharing a handle
Just like you can have multiple handles per actor, you can also have multiple actors per handle. The most common example of this is when handling a connection such as aTcpStream
, where you commonly spawn two tasks: one for reading and one for writing. When using this pattern, you make the reading and writing tasks as simple as you can — their only job is to do IO. The reader task will just send any messages it receives to some other task, typically another actor, and the writer task will just forward any messages it receives to the connection.
I'm trying to model this, but I've run into a design question regarding dependencies, particularly how to handle messages from the reader.
Here's a simplified sketch of my code:
// The high-level actor that handles business logic.
// It needs to send commands to the connection (e.g., via ConnHandle).
struct LogicActor {
// Used to send commands to the connection
conn_handle: ConnHandle,
}
// The handle for low-level connection
struct ConnHandle {
// Used to send messages to the writer task.
writer_tx: mpsc::Sender<Message>,
// How to receive messages from the reader task?
???: ???
}
// The reader task reads from the socket and needs to forward
// messages to the LogicActor.
struct ReaderActor {
stream: SplitStream<...>,
// How do I send messages to the LogicActor?
// If I hold a `LogicActorHandle`, it creates a dependency cycle during setup.
logic_handle: ???,
}
impl ConnHandle {
// Send message to the writer task via channel.
async fn send(message: Message) { ... }
// How to design the api for receiving messages from the reader task?
???
}
// Main setup logic
fn setup() {
// 1. Create the LogicActor, get its handle.
let logic_handle = ...;
// 2. Create the connection actors.
// The ReaderActor needs the `logic_handle` to be constructed.
let reader = ReaderActor::new(stream, logic_handle);
// 3. But what if the LogicActor also needs the `ConnHandle`
// to send messages? Now we have a circular dependency for construction.
}
My questions are:
-
The article seems to suggest that the reader and writer actors can share a common handle. However, their communication patterns are different: the writer receives messages to send, while the reader produces messages for others. How is a shared
ConnHandle
typically designed for this? -
This leads to my main problem: What is the idiomatic way for a
LogicActor
to receive data from theReaderActor
? If theReaderActor
holds a handle to theLogicActor
, it creates a circular dependency during construction. -
How can I resolve this construction-time dependency cleanly, especially when the
LogicActor
also needs theConnHandle
to send data out?