Process generators
Waveflow models concurrency with SimPy, which expresses every
time-consuming activity as a Python generator function — one that uses yield. A
SimObj’s run_proc is exactly such a generator. If you have not used SimPy before,
the yield keyword in these methods can be mysterious; this page is the mental model.
Events and process generators
Two ideas underpin everything:
- An event is something that completes at a simulation time — a timeout elapsing, another
process finishing, an item arriving on a queue.
self.timeout(5)andself.event()both return events. - A process generator is a generator function that
yields events. SimPy runs it until ayield, pauses it there, lets simulation time advance (other processes run), and resumes it on the next line when the yielded event fires.
Its type alias is ProcessGen[T] — literally
Generator[Event, Any, T], where T is the type the generator eventually returns (or None).
yield: pausing on an event
yield <event> means “pause this process until that event completes.” While paused, the rest of
the simulation keeps running. The classic event is a timeout:
from waveflow.simulation.simobj import ProcessGen, SimObj
class Blinker(SimObj):
def run_proc(self) -> ProcessGen[None]:
print(f"on at t={self.now}")
yield self.timeout(5) # pause 5 time units; time advances here
print(f"off at t={self.now}") # resumes at t=5
Without the yield, the method would run start-to-finish at t=0 — nothing in a simulation would
take any time. The yield is what lets simulated time pass.
When to use a process generator
Make a method a process generator (give it yields and a -> ProcessGen[...] hint) whenever it
consumes simulation time or waits on another object — transfers, polling, handshakes, delays.
Pure computation that takes no simulated time stays an ordinary method or function — you call it
normally, no yield. A component’s lifecycle entry (run_proc, or on_start) is always a process
generator; see SimObj: Its lifecycle.
Sequential vs. parallel: yield from vs. self.process(...)
This is the distinction that trips up most newcomers. There are two ways to invoke another process generator, and they mean different things:
| Form | Runs | Blocks the caller? | Returns |
|---|---|---|---|
result = yield from sub() |
inline, in the current process | yes — runs to completion before the next line | the sub-generator’s return value, directly |
proc = self.process(sub()) |
as a separate, concurrent process | no — starts it and returns immediately | a Process handle (join later with yield proc) |
Use yield from for a sub-step you want to do now and wait for. Use self.process(...) to start
work that should run alongside the caller.
Spawning a non-blocking parallel process
self.process(gen) registers and starts a generator as a
concurrent process and hands back a handle without blocking. Start several, then wait for them all
with self.env.all_of([...]):
class Fork(SimObj):
def run_proc(self) -> ProcessGen[None]:
a = self.process(self.work("A", 3)) # both start now and
b = self.process(self.work("B", 5)) # run concurrently
yield self.env.all_of([a, b]) # resume once BOTH finish
print(f"all done at t={self.now}") # t=5, not t=8
def work(self, name: str, dur: float) -> ProcessGen[None]:
yield self.timeout(dur)
print(f"{name} done at t={self.now}")
Because A and B run in parallel, the join completes at t=5 (the longer of the two), not t=8.
Returning a value
A process generator may return a value. After it completes, SimPy stores that value on the process
handle as proc.value. Two ways to get a sub-result, matching the two forms above:
class Compute(SimObj):
def run_proc(self) -> ProcessGen[None]:
# (1) inline — runs in this process, value comes back directly
squared = yield from self.square(10)
print("inline:", squared) # 100
# (2) concurrent — spawn, join, then read .value
proc = self.process(self.square(12))
yield proc # wait for it to finish
print("joined:", proc.value) # 144
def square(self, x: int) -> ProcessGen[int]:
yield self.timeout(1) # pretend it takes a cycle
return x * x
The proc.value pattern is exactly how a memory read returns its data — see
MMIFMaster.read (proc = self.process(...); yield proc; return proc.value)
and the interface overview.
Plain events for hand-rolled signaling
For an ad-hoc rendezvous, self.event() makes a bare event:
one process yields it; another fires it with evt.succeed(payload), and the payload arrives as
evt.value.
gate = self.event()
# consumer process: value = yield gate
# producer process: gate.succeed("ready") # wakes the consumer; value == "ready"
Type hinting
Annotate the return as ProcessGen[T], where T is what the generator returns:
-> ProcessGen[None]— a process that does work but returns nothing (mostrun_procbodies).-> ProcessGen[int]/ProcessGen[Words]/ProcessGen[bool]— returns a value read viaproc.valueoryield from(e.g.aximm_queue.py’scount() -> ProcessGen[int],try_write() -> ProcessGen[bool]).
These hints are not just documentation: for a @synthesizable method, the code generator reads the
annotation and unwraps ProcessGen[T] to T for the generated C++ return type — see
Component Code Generation.
Quick reference
- A process generator is a function that
yields events;yieldpauses it until the event fires. yield self.timeout(d)— letdtime units pass;self.nowis the current time.yield from sub()— runsubinline and get itsreturnvalue.self.process(sub())— runsubconcurrently;yieldthe handle to join, readproc.valuefor its result.yield self.env.all_of([p1, p2])— wait for several concurrent processes.- Hint as
ProcessGen[T](T = Nonewhen nothing is returned).