Back to Claude Scientific Skills

Process interaction and interrupts

skills/simpy/references/process-interaction.md

2.55.07.5 KB
Original Source

Process interaction and interrupts

Verified 2026-07-23 against SimPy 4.1.2.

Processes are event-yielding generators

A process function must return a generator object. env.process(generator) starts it through an urgent Initialize event. The generator executes until it yields an Event, then resumes with that Event's value or exception.

python
import simpy

def operation(env, duration):
    yield env.timeout(duration)
    return {"finished_at": env.now}

env = simpy.Environment()
process = env.process(operation(env, 3))
result = env.run(until=process)
assert result == {"finished_at": 3}

Store a Process reference when another process must wait for or interrupt it. Process is itself an Event.

Waiting for another process

python
def stage(env, label, duration):
    yield env.timeout(duration)
    return label

def workflow(env):
    first = env.process(stage(env, "first", 2))
    first_result = yield first

    second = env.process(stage(env, "second", 3))
    second_result = yield second
    return first_result, second_result

For parallel activities, create all Processes before yielding:

python
def parallel(env):
    a = env.process(stage(env, "a", 2))
    b = env.process(stage(env, "b", 3))
    results = yield a & b
    assert results[a] == "a"
    assert results[b] == "b"

Condition results map Event objects to values. Keep the original Process/Event references.

Shared one-shot events

A plain Event can passivate multiple waiters and broadcast one value:

python
def listener(env, signal, log, name):
    value = yield signal
    log.append((name, env.now, value))

env = simpy.Environment()
signal = env.event()
log = []
env.process(listener(env, signal, log, "a"))
env.process(listener(env, signal, log, "b"))
signal.succeed("go")
env.run()

Events are one-shot. For repeated signals, replace the shared Event after triggering it and ensure all participants read the same shared attribute:

python
class Clock:
    def __init__(self, env):
        self.env = env
        self.tick = env.event()

    def pulse(self):
        current = self.tick
        self.tick = self.env.event()
        current.succeed(self.env.now)

Passing separate local event variables to two loops and then "resetting" each local variable creates disconnected signals. Encapsulate ownership.

Interrupt delivery

target_process.interrupt(cause=None) schedules an urgent Interruption. When processed, it:

  1. removes the target process's resume callback from its current target Event;
  2. throws simpy.Interrupt(cause) into the generator immediately.
python
def worker(env, log):
    try:
        yield env.timeout(10)
        log.append(("finished", env.now))
    except simpy.Interrupt as interrupt:
        log.append(("interrupted", env.now, interrupt.cause))

def controller(env, target):
    yield env.timeout(3)
    target.interrupt("maintenance")

env = simpy.Environment()
log = []
worker_process = env.process(worker(env, log))
env.process(controller(env, worker_process))
env.run()
assert log == [("interrupted", 3, "maintenance")]

Interrupting a terminated process or the currently active process itself raises RuntimeError.

The target Event is not canceled

An interrupt removes the Process callback from the Event it was yielding; it does not cancel the Event. The generator can re-yield that same Event:

python
def temporarily_distracted(env, opening_event):
    while True:
        try:
            return (yield opening_event)
        except simpy.Interrupt:
            yield env.timeout(1)  # Handle interruption.
            # Loop and wait for the original opening_event again.

If the original Event occurred during handling, yielding it resumes immediately with its value.

Resource request events need an additional decision:

  • still waiting: re-yield the same request;
  • abandoning the wait: call request.cancel();
  • already acquired: release it when leaving.

Using the resource request as a context manager handles release/cancel on exception exit.

Resumable work

A Timeout cannot be "paused." On interruption, calculate completed work and create a new Timeout for the remainder:

python
def resumable_job(env, total_work, log):
    remaining = total_work
    while remaining > 0:
        started = env.now
        try:
            yield env.timeout(remaining)
            remaining = 0
        except simpy.Interrupt as interrupt:
            remaining -= env.now - started
            log.append(
                {
                    "cause": interrupt.cause,
                    "remaining": remaining,
                    "time": env.now,
                }
            )

Define whether interrupted setup is lost, retained, or repeated. The code above assumes linear preempt-resume work.

PreemptiveResource interrupts

A PreemptiveResource generates the interrupt, not application code. The cause is a Preempted record:

python
def preemptible(env, resource, priority, work):
    with resource.request(priority=priority, preempt=True) as request:
        try:
            yield request
            yield env.timeout(work)
        except simpy.Interrupt as interrupt:
            cause = interrupt.cause
            print(
                "preempted by",
                cause.by,
                "used since",
                cause.usage_since,
                "resource",
                cause.resource,
            )

Do not assume every Interrupt has those attributes; manually generated interrupt causes can be any object. Use isinstance(cause, simpy.resources.resource.Preempted) when the distinction matters.

Timeout/renege races

python
def impatient(env, resource, patience):
    with resource.request() as request:
        patience_event = env.timeout(patience)
        result = yield request | patience_event
        if request not in result:
            return {"outcome": "reneged", "time": env.now}
        yield env.timeout(2)
        return {"outcome": "served", "time": env.now}

At an exact tie, AnyOf may contain multiple events that occurred before the Condition was processed. Decide tie policy explicitly:

python
if request in result:
    # This policy treats simultaneous grant/patience as served.
    ...

The losing Timeout remains in the queue. The context manager cancels only a pending request, not arbitrary condition members.

Failure propagation

An uncaught process exception fails the Process. A parent yielding that Process receives the exception:

python
def broken(env):
    yield env.timeout(1)
    raise ValueError("model invariant failed")

def supervisor(env):
    try:
        yield env.process(broken(env))
    except ValueError:
        return "handled"

Do not broadly suppress failures merely to keep a simulation running. Convert only expected domain failures into explicit state; let invariant/programming errors fail tests.

Deadlock and liveness checks

env.run() returning because the queue is empty does not prove that every intended process completed. Processes can remain waiting on untriggered events with no future trigger. Keep references to required completion Processes and run until an explicit completion Event; if the schedule empties first, SimPy raises RuntimeError.

For production models:

  • set a numeric horizon and event/entity caps;
  • count completed and unfinished entities;
  • assert conservation of resources/items;
  • detect zero-delay recurrence;
  • trace a small deterministic scenario before stochastic experiments.

Sources

See sources.md for the versioned Process Interaction, Events, and resource guides and API references.