Skip to content

Commit 0cfb38c

Browse files
committed
docs(async): rewrite with progressive narrative, move to Engine section
Reorganize async.md for clarity: - Open with key insight: API is the same, engine switches automatically - Promote initial state activation as first gotcha after basic example - Simplify engine selection table (4 columns, no definition list) - Clarify concurrent event sending is async-engine exclusive - Remove redundant sections (versionadded 2.3.0, "StateChart async support", duplicate "Asynchronous Support" heading) Move async from Advanced to Engine in index.md — it's about how the engine processes callbacks, not an advanced feature.
1 parent 19548c6 commit 0cfb38c

2 files changed

Lines changed: 81 additions & 156 deletions

File tree

docs/async.md

Lines changed: 80 additions & 155 deletions
Original file line numberDiff line numberDiff line change
@@ -1,100 +1,28 @@
11
(async)=
2-
# Async
2+
# Async support
33

4-
```{versionadded} 2.3.0
5-
Support for async code was added!
6-
```
7-
8-
The {ref}`StateChart` fully supports asynchronous code. You can write async {ref}`actions`, {ref}`guards`, and {ref}`events` triggers, while maintaining the same external API for both synchronous and asynchronous codebases.
9-
10-
This is achieved through a new concept called **engine**, an internal strategy pattern abstraction that manages transitions and callbacks.
11-
12-
There are two engines, {ref}`SyncEngine` and {ref}`AsyncEngine`.
13-
14-
15-
## Sync vs async engines
16-
17-
Engines are internal and are activated automatically by inspecting the registered callbacks in the following scenarios.
18-
19-
20-
```{list-table} Sync vs async engines
21-
:header-rows: 1
22-
23-
* - Outer scope
24-
- Async callbacks?
25-
- Engine
26-
- Creates internal loop
27-
- Reuses external loop
28-
* - Sync
29-
- No
30-
- SyncEngine
31-
- No
32-
- No
33-
* - Sync
34-
- Yes
35-
- AsyncEngine
36-
- Yes
37-
- No
38-
* - Async
39-
- No
40-
- SyncEngine
41-
- No
42-
- No
43-
* - Async
44-
- Yes
45-
- AsyncEngine
46-
- No
47-
- Yes
48-
49-
```
50-
51-
Outer scope
52-
: The context in which the state machine **instance** is created.
53-
54-
Async callbacks?
55-
: Indicates whether the state machine has declared asynchronous callbacks or conditions.
56-
57-
Engine
58-
: The engine that will be utilized.
59-
60-
Creates internal loop
61-
: Specifies whether the state machine initiates a new event loop if no [asyncio loop is running](https://docs.python.org/3/library/asyncio-eventloop.html#asyncio.get_running_loop).
62-
63-
Reuses external loop
64-
: Indicates whether the state machine reuses an existing [asyncio loop](https://docs.python.org/3/library/asyncio-eventloop.html#asyncio.get_running_loop) if one is already running.
65-
66-
67-
68-
```{note}
69-
All handlers will run on the same thread they are called. Therefore, mixing synchronous and asynchronous code is not recommended unless you are confident in your implementation.
4+
```{seealso}
5+
New to statecharts? See [](concepts.md) for an overview of how states,
6+
transitions, events, and actions fit together.
707
```
718

72-
(syncengine)=
73-
### SyncEngine
74-
Activated if there are no async callbacks. All code runs exactly as it did before version 2.3.0.
75-
There's no event loop.
76-
77-
(asyncengine)=
78-
### AsyncEngine
79-
Activated if there is at least one async callback. The code runs asynchronously and requires a running event loop, which it will create if none exists.
80-
81-
82-
83-
## Asynchronous Support
9+
The public API is the same for synchronous and asynchronous code. If the
10+
state machine has at least one `async` callback, the engine switches to
11+
{ref}`AsyncEngine <asyncengine>` automatically — no configuration needed.
8412

85-
We support native coroutine callbacks using asyncio, enabling seamless integration with asynchronous code. There is no change in the public API of the library to work with asynchronous codebases.
13+
All statechart features — compound states, parallel states, history
14+
pseudo-states, eventless transitions, `done.state` events — work
15+
identically in both engines.
8616

8717

88-
```{seealso}
89-
See {ref}`sphx_glr_auto_examples_air_conditioner_machine.py` for an example of
90-
async code with a state machine.
91-
```
18+
## Writing async callbacks
9219

20+
Declare any callback as `async def` and the engine handles the rest:
9321

9422
```py
9523
>>> class AsyncStateMachine(StateChart):
96-
... initial = State('Initial', initial=True)
97-
... final = State('Final', final=True)
24+
... initial = State("Initial", initial=True)
25+
... final = State("Final", final=True)
9826
...
9927
... keep = initial.to.itself(internal=True)
10028
... advance = initial.to(final)
@@ -114,13 +42,12 @@ Result is 42
11442

11543
```
11644

117-
## Sync codebase with async callbacks
118-
119-
The same state machine with async callbacks can be executed in a synchronous codebase,
120-
even if the calling context don't have an asyncio loop.
121-
122-
If needed, the state machine will create a loop using `asyncio.new_event_loop()` and callbacks will be awaited using `loop.run_until_complete()`.
45+
### Using from synchronous code
12346

47+
The same state machine can be used from a synchronous context — even
48+
without a running `asyncio` loop. The engine creates one internally
49+
with `asyncio.new_event_loop()` and awaits callbacks using
50+
`loop.run_until_complete()`:
12451

12552
```py
12653
>>> sm = AsyncStateMachine()
@@ -134,88 +61,63 @@ Result is 42
13461

13562

13663
(initial state activation)=
137-
## Initial State Activation for Async Code
13864

65+
## Initial state activation
13966

140-
If **on async code** you perform checks against the `configuration`, like a loop `while not sm.is_terminated:`, then you must manually
141-
await for the [activate initial state](statemachine.StateChart.activate_initial_state) to be able to check the configuration.
142-
143-
```{hint}
144-
This manual initial state activation on async is because Python don't allow awaiting at class initalization time and the initial state activation may contain async callbacks that must be awaited.
145-
```
146-
147-
If you don't do any check for configuration externally, just ignore this as the initial state is activated automatically before the first event trigger is handled.
148-
149-
You get an error checking the configuration before the initial state activation:
67+
In async code, Python cannot `await` during `__init__`, so the initial
68+
state is **not** activated at instantiation time. If you inspect
69+
`configuration` immediately after creating the instance, it won't reflect
70+
the initial state:
15071

15172
```py
152-
>>> async def initialize_sm():
73+
>>> async def show_problem():
15374
... sm = AsyncStateMachine()
15475
... print(list(sm.configuration_values))
15576

156-
>>> asyncio.run(initialize_sm())
77+
>>> asyncio.run(show_problem())
15778
[None]
15879

15980
```
16081

161-
You can activate the initial state explicitly:
162-
82+
To fix this, explicitly await
83+
{func}`activate_initial_state() <statemachine.StateChart.activate_initial_state>`
84+
before inspecting the configuration:
16385

16486
```py
165-
>>> async def initialize_sm():
87+
>>> async def correct_init():
16688
... sm = AsyncStateMachine()
16789
... await sm.activate_initial_state()
16890
... print(list(sm.configuration_values))
16991

170-
>>> asyncio.run(initialize_sm())
92+
>>> asyncio.run(correct_init())
17193
['initial']
17294

17395
```
17496

175-
Or just by sending an event. The first event activates the initial state automatically
176-
before the event is handled:
97+
```{tip}
98+
If you don't inspect the configuration before sending the first event,
99+
you can skip this step — the first `send()` activates the initial state
100+
automatically.
101+
```
177102

178103
```py
179-
>>> async def initialize_sm():
104+
>>> async def auto_activate():
180105
... sm = AsyncStateMachine()
181-
... await sm.keep() # first event activates the initial state before the event is handled
106+
... await sm.keep() # activates initial state before handling the event
182107
... print(list(sm.configuration_values))
183108

184-
>>> asyncio.run(initialize_sm())
109+
>>> asyncio.run(auto_activate())
185110
['initial']
186111

187112
```
188113

189-
## StateChart async support
190114

191-
```{versionadded} 3.0.0
192-
```
115+
## Concurrent event sending
193116

194-
`StateChart` works identically with the async engine. All statechart features —
195-
compound states, parallel states, history pseudo-states, eventless transitions,
196-
and `done.state` events — are fully supported in async code. The same
197-
`activate_initial_state()` pattern applies:
198-
199-
```py
200-
>>> async def run():
201-
... sm = AsyncStateMachine()
202-
... await sm.activate_initial_state()
203-
... result = await sm.send("advance")
204-
... return result
205-
206-
>>> asyncio.run(run())
207-
42
208-
209-
```
210-
211-
### Concurrent event sending
212-
213-
```{versionadded} 3.0.0
214-
```
215-
216-
When multiple coroutines send events concurrently (e.g., via `asyncio.gather`),
217-
each caller receives its own event's result — even though only one coroutine
218-
actually runs the processing loop at a time.
117+
A benefit exclusive to the async engine: when multiple coroutines send
118+
events concurrently (e.g., via `asyncio.gather`), each caller receives
119+
its own event's result — even though only one coroutine runs the
120+
processing loop at a time. The sync engine does not support this pattern.
219121

220122
```py
221123
>>> class ConcurrentSC(StateChart):
@@ -249,28 +151,51 @@ actually runs the processing loop at a time.
249151

250152
Under the hood, the async engine attaches an `asyncio.Future` to each
251153
externally enqueued event. The coroutine that acquires the processing lock
252-
resolves each event's future as it processes the queue. Callers that couldn't
154+
resolves each event's future as it processes the queue. Callers that didn't
253155
acquire the lock simply `await` their future.
254156

255157
```{note}
256158
Futures are only created for **external** events sent from outside the
257-
processing loop. Events triggered from within callbacks (reentrant calls)
258-
follow the existing run-to-completion (RTC) model — they are enqueued and
259-
processed within the current macrostep, and the callback receives ``None``.
159+
processing loop. Events triggered from within callbacks (via `send()` or
160+
`raise_()`) follow the {ref}`run-to-completion <rtc-model>` model — they
161+
are enqueued and processed within the current macrostep.
260162
```
261163

262164
If an exception occurs during processing (with `error_on_execution=False`),
263165
the exception is routed to the caller whose event caused it. Other callers
264166
whose events were still pending will also receive the exception, since the
265167
processing loop clears the queue on failure.
266168

267-
### Async-specific limitations
268169

269-
- **Initial state activation**: In async code, you must `await sm.activate_initial_state()`
270-
before inspecting `sm.configuration`. In sync code this happens
271-
automatically at instantiation time.
272-
- **Delayed events**: Both sync and async engines support `delay=` on `send()`. The async
273-
engine uses `asyncio.sleep()` internally, so it integrates naturally with event loops.
274-
- **Thread safety**: The processing loop uses a non-blocking lock (`_processing.acquire`).
275-
All callbacks run on the same thread they are called from — do not share a state machine
276-
instance across threads without external synchronization.
170+
(syncengine)=
171+
(asyncengine)=
172+
173+
## Engine selection
174+
175+
The engine is selected automatically when the state machine is
176+
instantiated, based on the registered callbacks:
177+
178+
| Outer scope | Async callbacks? | Engine | Event loop |
179+
|---|---|---|---|
180+
| Sync | No | SyncEngine | None |
181+
| Sync | Yes | AsyncEngine | Creates internal loop |
182+
| Async | No | SyncEngine | None |
183+
| Async | Yes | AsyncEngine | Reuses running loop |
184+
185+
**Outer scope** is the context where the state machine instance is created.
186+
**Async callbacks** means at least one `async def` callback or condition is
187+
declared on the machine, its model, or its listeners.
188+
189+
```{note}
190+
All callbacks run on the same thread they are called from. Mixing
191+
synchronous and asynchronous code is supported but requires care —
192+
avoid sharing a state machine instance across threads without external
193+
synchronization.
194+
```
195+
196+
197+
```{seealso}
198+
See {ref}`processing model <macrostep-microstep>` for how the engine
199+
processes events, and {ref}`behaviour` for the behavioral attributes
200+
that affect processing.
201+
```

docs/index.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ listeners
3131
3232
processing_model
3333
error_handling
34+
async
3435
validations
3536
behaviour
3637
```
@@ -39,7 +40,6 @@ behaviour
3940
:caption: Advanced
4041
:maxdepth: 2
4142
42-
async
4343
invoke
4444
models
4545
integrations

0 commit comments

Comments
 (0)