On State Machines

First up. How are people building state machines in Rust?

By state machine I mean something that runs around a loop forever, transitions from discrete state to discreet state as it run, with state transition determined from some input to the machine. Something like:

    let mut state = StateMachine::StateA(StateA { x });

    loop {
        state = state.run()?
    }

As an old C hand I would do this as simply as an enum and a switch on that enum wrapped in a loop. Which we can do in Rust with enum and match.

But of course this is Rust. Here I read of maintaining states in different types. So I came up with this: Rust Playground which is probably a bit simplistic. But I think that might be a bonus. It suffers ensuring valid type transitions at run time.

I read that it's possible to prevent disallowed state transitions using the type system at compile time. But when I look into how to do that it seems way over complicated and make the code unreadable. For example like the
BottleFillingMachine example here: Pretty State Machine Patterns in Rust Which contains states like this:

impl From<BottleFillingMachine<Waiting>> for BottleFillingMachine<Filling> {
    fn from(val: BottleFillingMachine<Waiting>) -> BottleFillingMachine<Filling> {
        println!("Bottle Filling Machine Waiting");

        BottleFillingMachine {
            shared_value: val.shared_value,
            state: Filling { rate: 1 },
        }
    }
}

I don't ever want to see that in my code. It seems to allow only transitioning to one specified state from any give state and making able to branch ands a whole lot more complexity. Is it worth it just to get compile time checks?

I guess there are crates out there to create state machines which might be interesting but that seems like heavy lifting for such a simple thing.

1 Like

If the way you want to execute your state machine is reasonably described by loop { state = state.next() }, then this idea, the type-state pattern, is probably not appropriate for your goal. Type-state is useful for when the structure of “input” to the state machine is set at compile-time by a sequence of calls. It's appropriate for builders (where you would like a static guarantee that the output of the builder is always fully initialized) or anything where the caller is obligated to complete a certain statically determined sequence of steps (or at least partially statically determined).

But if the input controlling the transition at every machine step is entirely determined at run time, perhaps because it is actual external input to your program, then type-state does not apply (or one could say, it collapses to the trivial case of having exactly one type-state).

Don’t worry you’re missing something — it’s just a solution for a different problem than you have.

7 Likes

If you don't care about the precise representation of your state machine and don't need any extra traits (e.g. Debug or Serialize/Deserialize), then imho the best approach is to use async blocks. That's their purpose - describing state machines in a compact and readable way. You need just the blocks, not the whole async ecosystem, and a simple runtime like pollster is enough.

1 Like

I build mine by hand exactly as per your playground example when I need them. I just don't have the is_transition_allowed() call. That logic is inside the run() implementation for each state.

Although sometimes when I want a central location to validate all the state transitions then I just move the transition out of the run() to the loop with a match. I then use From helpers to easily change between structs.

Rust Playground

Ok, thanks. After reading around state machines and the type-safe pattern in Rust I started to think I was missing a point somewhere.

I think I would need an example before I understand what you are getting at there. As it is none of my program is async and the state machines themselves make blocking calls. As such I imagine adopting async anywhere would require a major rework.

I kind of like my is_transition_allowed(). Ideally that check would only be done in debug builds for testing. It centralises the description of the state machine transitions into one place.

So I'm going to stick with my state machines as presented in the OP. More or less.

Thanks.

Here is a mostly verbatim translation of your linked example via async functions. Some notes on the implementation:

  • In real-world code, your state transition functions would likely stop so that you can execute some I/O operations, or run some code concurrently. For this If you're doing neither, like in this example, then you must insert explicit futures::pending!() calls at the points where you want to yield back control flow to the calling functions.
  • The linter may complain about blocking calls in async functions. If you don't plan to use your functions with standard async executors and want to run them just for the state machine, you can safely ignore it.
  • Terminal states may be represented by returning from an async function. This also allows you to produce a final value.
  • The StateMachine wrapper exists only to provide a more simple interface, without a dummy &mut Context parameter. Unfortunately, the only way to remove the dummy generic parameter in current Rust is to dyn-erase the type. This likely means Boxing the inner future, although local references may also be usable, depending on your context, like in the provided main function. Note that you want to use the StateMachine wrapper only with the final composed state machine, when you actually poll it. Staying with raw futures allows you to compose your state machines via the standard futures combinators.
  • async functions do not derive Unpin, so we must manually pin the future before polling it. If you can afford boxing your state machine, using Box::pin(fut) is the easiest solution to both pinning and type erasure problems.
  • We use a no-op Waker (futures::task::noop_waker_ref()), because we don't need the more complex waking features of futures. We're not waiting on external events and poll futures manually when we want to move to the next state.
  • Since we don't care about wakers, ideally we would use directly the underlying generators (gen { yield; }). They aren't stable, so we must emulate them via async blocks & functions.
1 Like

This topic was automatically closed 90 days after the last reply. We invite you to open a new topic if you have further questions or comments.