Tired of mutex hell and spaghetti event handlers? Here's a single-threaded FSM methodology that keeps your async logic clean — forever. Just #include "uniflow.hpp".
We've all been there. A module that starts simple —
connect, send, wait for ack — and six months later looks like this:
bool connecting_, connected_, cmd_sent_, waiting_ack_, draining_, fault_;
void Update() {
if (estop_) {
connecting_ = false;
cmd_sent_ = false;
waiting_ack_ = false;
// forgot draining_ — ghost ack bug waiting to happen
...
}
if (!connected_) { ... }
else if (!cmd_sent_) { ... }
else if (waiting_ack_) { ... }
// where does fault_ get handled? what if estop_ and fault_ fire together?
}
Flags that must move in pairs. Resets that get forgotten.
E-stop logic that has to know about every stage.
Two developers write the same flow in completely different shapes.
**uniflow** keeps what works about the tick-based FSM — cooperative,
single-thread, no mutex — and fixes what doesn't.
Each stage becomes a named step function:
StepResult Step1_Connect() { device_.BeginConnect();
return Next(UF_FN(Step2_WaitConnected)); }
StepResult Step2_WaitConnected() { if (!device_.IsConnected()) return Stay();
return Next(UF_FN(Step3_WaitRequest)); }
StepResult Step3_WaitRequest() { if (!input_.HasRequest()) return Stay();
device_.Send(input_.Take());
return Next(UF_FN(Step4_WaitAck)); }
StepResult Step4_WaitAck() { if (device_.HasAck()) return Done();
return StayUntil(3000ms, UF_FN(Step5_Timeout)); }
One function = one state. Entry is explicit. Transitions are pinned in code.
No hidden jumps. No forgotten resets. Brace depth stays flat forever.
---
**How it works**
One `Runtime` owns one pump thread. Attach as many modules as you want.
The pump visits each module once per round — cooperative, round-robin.
uniflow::Runtime rt;
Flow_XAxis x_axis{rt};
Flow_YAxis y_axis{rt};
Flow_Conveyor conveyor{rt};
x_axis.ctx_home_.StartFlow(); // start homing X
y_axis.ctx_home_.StartFlow(); // start homing Y — simultaneously, no thread, no mutex
Modules on the same Runtime share the single-thread invariant.
No locks needed. Ever.
The pump adapts its sleep to the situation:
- Back-to-back transitions? No sleep, full speed.
- Polling with Stay()? 20ms yield. CPU near 0.
- All idle? 1ms — ready to wake immediately.
External event arrives? Call `rt.Wake()` from any thread. No waiting out a sleep cycle.
---
**Blocking work? No problem.**
Heavy I/O goes to the built-in thread pool via SubmitAsync.
The pump never blocks. Every other module keeps running.
StepResult Step1_Fetch() {
AsyncId job = SubmitAsync(UF_FN(DoFetch), 5000ms, url_);
return Next(UF_FN(Step2_Process), job);
}
StepResult Step2_Process(AsyncId job) {
auto r = AsyncResult<std::string>(job);
if (r.pending()) return StayUntil(5000ms, UF_FN(Step_Timeout));
if (!r.ok()) return Fail();
data_ = *r.return_value;
return Next(UF_FN(Step3_Save));
}
---
**Built-in tracing — zero instrumentation code**
Because every execution is "a step function was called once,"
one measurement point inside the pump sees everything:
[JobWorker] FLOW START
[JobWorker] Entry -> Step2_Validate elapsed=0.01ms
[JobWorker] ASYNC SUBMIT CallApi
[JobWorker] ASYNC DONE CallApi wait=124.38ms
[JobWorker] Step2_Validate -> Step3_WaitSave elapsed=124.42ms
[JobWorker] FLOW END DONE wall=143.21ms
Which step, how long, where it slowed down — visible without touching your logic code.
---
**What it's not**
- Not Boost.Asio — no new type system to learn, zero deps, doesn't absorb your objects
- Not C++20 coroutines — C++17, and style is
Post #25491
8