Rust State Machine Part 2
tldr
- Pass in a
SendertoCommand tokio::spawn()takes in a future.- tokio’s rx.recv() returns
OptionnotResult pub async fn blah(),asyncis betweenpubandfn- Two Way Communication code only is here
- Full Part 2 Code using tokio is here
- There are some unresolved issues but those would be handled in part 3
Overview
In part1 we have something but there were two issues left.
- The remote control is essentially fire and forget. In order for the client to get any sorts of responses back the client would need to poll the state.
- The main function requires a
sleep()to wait for the threads to print statuses.
Add a Sender To The Command
Our current TrafficLightRemote looks like the following
|
|
We would be making two changes here to support two way communication.
Step 1: Update Command and TrafficLightRemote
We first add std::sync::mpsc::Sender<Result<(), String>> to each variant of Command
Result<(), String> means if it is a success we want () and if something is wrong we just have a String.
A few things that could potentially be touched up later
- Switch
Result<(), String>toResult<(), Box<dyn std::error::Error + Send + Sync>>so the error could have more information than just aString - Instead of passing a
Sender<Result<(), String>>in sayToGreen, pass inOption<Sender<Result<(), String>>>for cases where we actually want to fire and forget
With the change above when we call to_green we could block on the receiving side of the sender passed in to the enum.
|
|
Most important things are commented in code. But let’s go over it again.
When to_green is called, we create a new std::sync::mpsc::channel, this gives you a pair of tx, rx where anything send to tx would come out from rx.
We pass the tx to Command::ToGreen(tx). So later when we pattern match on the ToGreen command the tx channel could be pulled out and have information passed back to it.
We then block by match rx.recv(). If we get an Ok() we just forward the result.
rx.recv()returnsResult<Result<(), String>, std::sync::mpsc::RecvError>, we need to pull out theinner_resultfrom it which type isResult<(), String>
If we get an Err we return the to_string of that error.
So far our TrafficLightRemote’s to_green can wait for the results, it’s no longer a fire and forget.
But we still need to handle the other part where it actually sends things to the tx in Command::ToGreen(tx).
Step 2: Update Client Code And Send Information To The tx In Command
Let’s have a look at the current code
|
|
The part that we are going to change is the match arms of match (state, cmd) {}.
We pattern match out the tx channel that was in the enum and send information back to it then we are all set.
|
|
There you go, the successful state transitions would send back Ok(()) to tx and the errors would be sent back as Err("invalid state command combination".to_string())
Let’s test this out in main without sleep(). There’s still a timing issue with the final output, which I’ll fix in Part 3.
|
|
shengy@Mac ~/repos/ruststuff/practice-statemachine-types ((part2-two-way-communication)|clean) [12:54:58]
> cargo run
Compiling practice-statemachine-types v0.1.0 (/Users/shengy/repos/ruststuff/practice-statemachine-types)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.48s
Running `target/debug/practice-statemachine-types`
start as red
red to green
Ok(())
green to yellow
Ok(())
yellow to red
Ok(())
red to green
Ok(())
all threads done
Switch To tokio
The end goal of this state machine is to learn to write a BlazingMQ client.
tokio is the defacto library for networking and is the defacto async executor.
To plug the state machine in there it’s best to move from std::sync::mpsc to tokio::sync::mpsc.
into_split might be a separate post
At high level the steps should be
- Add tokio as a dependency
- Switch all
std::sync::mpsctotokio::sync::mpsc. For the reply channel we just created,tokio::sync::oneshotis actually a better fit - Update
TrafficLightRemote’s methods - Update
spawn_clientcode from threads to async - Update
mainto be a tokio main
Step 1: Install tokio
This is simple, cargo add tokio -F full and voilà.
Technically speaking you might want to go over the actual features you need but I’m a bit lazy for that now.
Step 2: Switch Channels To tokio
Replace std::sync::mpsc to tokio::sync::mpsc, but for the channel in Command we use tokio::sync::oneshot
Relevant snippet below
|
|
Step 3: Update TrafficLightRemote methods
This would be touching the same code we just did, I’ll use the to_green function as an example.
- Now
sender.sendis returning a future, so we would need to.awaiton it - Because we are
.awaiting in the function,to_greenneeds to beasync - The way we wait for the channel
rxisrx.await, note that this is special for the oneshot channel, fortokio::sync::mpscchannels to await forrxisrx.recv().await(We would see this in thespawn_clientfunction later).
Step 4: Update spawn_client
The notable changes are
let (tx, mut rx) = tokio::sync::mpsc::channel(32);rxneeds to bemutchanneltakes in32which is the buffer size, note that32here means32messages
std::thread::spawn()totokio::spawn()- The biggest difference here is
thread::spawn(move || {})takes inFnOncewheretokio::spawn(async move {})takes in a future created by an async move block.
- The biggest difference here is
while let Some(cmd) = rx.recv().await {}rx.recv().awaitreturnsOptionnotResult
See code below
|
|
Step 5: Update main To Be a tokio main
This is just slapping on the #[tokio::main] procedural macro to main and call .await on the remote control
|
|
shengy@Mac ~/repos/ruststuff/practice-statemachine-types ((part2)|clean) [13:34:05]
> cargo run
Compiling practice-statemachine-types v0.1.0 (/Users/shengy/repos/ruststuff/practice-statemachine-types)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.26s
Running `target/debug/practice-statemachine-types`
start as red
red to green
Ok(())
green to yellow
Ok(())
yellow to red
Ok(())
red to green
Ok(())
Further things to think about
- How does
async/awaitwork in Rust? - async-await-in-asyncio was something I wrote before on python’s asyncio
- A few issues remaining with the code that would be addressed in part3
- The reply is sent before the state change happened.
- How to actually determine “all threads done”
Review Notes Written by Codex
This section, including the diagrams, was written by Codex, the AI assistant that reviewed this post. The explanations and suggested fixes below are Codex’s review notes.
There are two separate timing issues here, and both apply to the threaded and Tokio versions.
First, the worker sends Ok(()) before calling the state transition method. The caller can receive that reply and continue while the transition is still pending. For the last command, main could exit before the transition prints its output. So the reply currently means “command accepted,” not “transition completed.” The suggested fix for Part 3 is to perform the transition and store the new state before sending the reply.
One possible ordering in the threaded version looks like this. Time moves downward:
In the Tokio version, the caller can also receive the reply before the transition finishes. Returning from main starts runtime shutdown; receiving that reply is not a guarantee of task completion.
Second, receiving the last reply does not mean the worker has finished. The println!("all threads done") runs after the receive loop exits. In this example, that happens once all remote handles are dropped and the queued commands have been processed. Dropping the handles lets the worker exit, but does not wait for it to do so, which means the final message is not guaranteed to appear.
Even after fixing the reply ordering, these are separate events:
|
|
The suggested shutdown fix is to keep the JoinHandle returned by std::thread::spawn or tokio::spawn. After dropping all remote handles, including any clones, call .join() for the thread or .await the Tokio task’s handle. Dropping the remotes needs to happen first; otherwise the worker can keep waiting for another command while main waits for the worker. Also, “worker done” would be a more accurate message: finishing this one worker does not mean all threads have finished.
With both fixes, the intended sequence is:
|
|
See Also
- Rust State Machine Part 1
- Map Flatmap and Options
- Rust Learning: Modules
- Rust Polars Parquet
- Rust Learning: Deref