Skip to content

Queues and Capacity

In its busy hour the shop gets 1.8 orders a second, and packing still takes 1.5 seconds. One packer cannot keep up. This page asks: how many packers keep the mean wait under 10 seconds?

What You Add

More orders, then more packers. The shop is the one from First Simulation, with the arrival rate of the busy hour, a run of one hour, and the number of packers as a setting you change between runs.


Work Out the Load

Before running anything, work out how busy the packers would be. Utilisation is the share of time a resource is busy:

utilisation = arrival rate × mean service time ÷ number of units

The load is the top half of that: 1.8 orders a second × 1.5 seconds = 2.7 packers' worth of work arriving every second. One packer would be busy 270% of the time, which is impossible: it falls further behind with every order.

  • With utilisation below 1 a model is stable: the queue rises and falls, but stays bounded.
  • At 1 or above it is not: the queue grows for as long as the run lasts, and every order waits longer than the one before.

So the shop needs at least 3 packers (2.7 ÷ 3 = 0.9). Each resource in a model has its own utilisation, and the one closest to 1 is the bottleneck: it decides how long everything waits.

Here is the busy hour. The arrival rate is 1.8, and until is one hour of simulation time:

docs/snippets/yaml/queues_and_capacity.yaml:load
simulation:
  sim_id: Shop
  factor: 0  # unpaced: the hour runs in about a second
  random_seed: 42

services:
  packing: {dist: constant, value: 1.5 s}

arrivals:
  # The busy hour: 1.8 orders a second, more than one packer can pack
  orders: {dist: exponential, rate: 1.8, spawn: pack_order}

tasks:
  pack_order:
    service: packing
    resource: packers
    payload: {event_type: order_packed}

egress:
  - type: Console

run:
  until: 1 h
docs/snippets/tutorials/queues_and_capacity.py:load
app = SimulationContext(sim_id="Shop", factor=0, random_seed=42)  # unpaced
app.add_service("packing", dist="constant", value=1.5)
# The busy hour: 1.8 orders a second, more than one packer can pack
app.add_arrival("orders", dist="exponential", rate=1.8)
docs/snippets/tutorials/queues_and_capacity_low_level.py:load
service={"packing": DistributionConfig(dist="constant", value=1.5)},
# The busy hour: 1.8 orders a second, more than one packer can pack
arrival={"orders": DistributionConfig(dist="exponential", rate=1.8)},

A blueprint takes durations wherever it takes a time, such as until: 1 h and value: 1.5 s; a plain number is seconds. factor: 0 runs the hour unpaced, in about a second, because nobody needs to watch it in real time.


Run It With More Packers

The number of packers is the resource's current_cap. Each run sets it once:

docs/snippets/yaml/queues_and_capacity.yaml:packers
resources:
  # The number of packers comes from the PACKERS variable, 1 when it is not set
  packers:
    current_cap: ${PACKERS:-1}
    max_cap: 10

${PACKERS:-1} reads the environment variable PACKERS, and uses 1 when it is not set.

docs/snippets/tutorials/queues_and_capacity.py:packers
app.add_resource("packers", current_cap=packers, max_cap=10)
docs/snippets/tutorials/queues_and_capacity_low_level.py:packers
resources={"packers": CapacityConfig(current_cap=packers, max_cap=10)},

A DynamicResource with a current_cap of 3 behaves like a SimPy resource with three units: a request is granted as soon as any unit is free, so three orders are packed at once. Resources describes how it is built and why.

To read the mean wait, switch on the built-in statistics at the typical level, which keeps the mean wait of every task; Measuring a Run explains the levels.

docs/snippets/yaml/queues_and_capacity.yaml:stats
# The mean wait of pack_order, published once an hour and at the end
stats: {level: typical, interval: 1 h}
docs/snippets/tutorials/queues_and_capacity.py:stats
# The mean wait of pack_order, kept by the run and read at the end
app.with_stats("typical", interval=3600)
docs/snippets/tutorials/queues_and_capacity_low_level.py:stats
# The mean wait of pack_order, kept by the environment and read at the end
env.setup_stats("Shop", "typical", interval=3600)

Now run the hour with 1, 2, 3 and 4 packers:

The run publishes the mean wait as Shop.stats.tasks.pack_order.wait_mean at the end, among every event. grep keeps that line:

for n in 1 2 3 4; do
  PACKERS=$n desflow run queues_and_capacity.yaml 2>&1 | grep wait_mean
done
docs/snippets/tutorials/queues_and_capacity.py:table
def mean_wait(packers: int) -> float:
    """Runs the busy hour with `packers` packers and returns the mean wait."""
    app = shop(packers)
    app.run(until=UNTIL)
    return app.env.stats.readings()["Shop.stats.tasks.pack_order.wait_mean"]


if __name__ == "__main__":
    for packers in range(1, 5):
        print(f"packers {packers}: mean wait {mean_wait(packers):8.2f} s")
docs/snippets/tutorials/queues_and_capacity_low_level.py:table
def mean_wait(packers: int) -> float:
    """Runs the busy hour with `packers` packers and returns the mean wait."""
    env = run(packers)
    return env.stats.readings()["Shop.stats.tasks.pack_order.wait_mean"]


if __name__ == "__main__":
    for packers in range(1, 5):
        print(f"packers {packers}: mean wait {mean_wait(packers):8.2f} s")

The Builder and Low-level scripts print one line per run. The YAML runs print the same numbers, as the value of their wait_mean lines:

packers 1: mean wait  1127.21 s
packers 2: mean wait   426.31 s
packers 3: mean wait     1.27 s
packers 4: mean wait     0.20 s

Waits Near Full Use

With 2 packers, utilisation is 1.35. The queue grows all hour, so the mean wait says only how long the run was: run two hours and it roughly doubles. With 3 packers, utilisation is 0.9 and the wait settles. With 4 it is 0.675, and almost nobody waits.

A stable model can still wait a long time. Waits grow sharply as utilisation approaches 1, and more variable times make it worse. These are measured mean waits in the queue for one packer with a mean service of 1.5 seconds, each over 200,000 simulated seconds:

Measured mean wait in the queue for one packer with mean service 1.5 s: at utilisation 0.5, 1.5 s with exponential service and 0.8 s with constant service; at 0.75, 4.4 and 2.3 s; at 0.9, 13.0 and 7.0 s; at 0.95, 31.3 and 14.8 s

  • With exponential service times, the mean wait for one unit is utilisation ÷ (1 − utilisation) × mean service time. At 0.75 that is 3 × 1.5 = 4.5 seconds. Going from 0.9 to 0.95 more than doubles it.
  • With constant service times, as in this shop, the wait is half of that, because no unusually long job holds up the queue. Spread in the service time, or in the gaps between arrivals, always lengthens waits; Choosing Inputs measures it.

Result

How many packers keep the mean wait under 10 seconds? 3 packers, as the mean wait for 1 to 4 packers shows:

Mean wait in the busy hour for 1 to 4 packers on a log scale: 1127 seconds with 1, 426 with 2, 1.27 with 3 and 0.20 with 4; the 10-second line separates 2 from 3

With 3 packers the mean wait is 1.27 seconds. With 2 packers it is over 400 seconds and still growing when the hour ends. The answer is the first number of packers that brings utilisation below 1, because the shop's packing time is constant and 0.9 is not yet close enough to 1 for the wait to pass 10 seconds.

Why It Works

Three packers serving one queue is the M/M/c model of queueing theory when both the gaps and the service times are exponential, and M/D/c when the service time is constant, as here. For both, the wait, measured in service times, depends on utilisation alone once c is fixed, and grows without limit as utilisation reaches 1, which is what the table shows. To bring a resource below 1, change any term of the utilisation: add units (current_cap), work faster (a shorter service time) or take fewer arrivals (a lower rate). Each is a live parameter, so a running model can be relieved, or overloaded on purpose, as Live Parameters shows. Statistics describes how the mean wait is measured.


Full Files

Full files
docs/snippets/yaml/queues_and_capacity.yaml
# The Queues and Capacity tutorial: the shop in its busy hour, as a blueprint.
# Run it once per number of packers, and read the mean wait from the last lines:
#   PACKERS=3 desflow run queues_and_capacity.yaml 2>&1 | grep wait_mean
simulation:
  sim_id: Shop
  factor: 0  # unpaced: the hour runs in about a second
  random_seed: 42

services:
  packing: {dist: constant, value: 1.5 s}

arrivals:
  # The busy hour: 1.8 orders a second, more than one packer can pack
  orders: {dist: exponential, rate: 1.8, spawn: pack_order}

tasks:
  pack_order:
    service: packing
    resource: packers
    payload: {event_type: order_packed}

egress:
  - type: Console

run:
  until: 1 h

resources:
  # The number of packers comes from the PACKERS variable, 1 when it is not set
  packers:
    current_cap: ${PACKERS:-1}
    max_cap: 10

# The mean wait of pack_order, published once an hour and at the end
stats: {level: typical, interval: 1 h}
docs/snippets/tutorials/queues_and_capacity.py
"""The Queues and Capacity tutorial: the shop's busy hour, with the builder.

Orders arrive at 1.8 a second and each takes 1.5 seconds to pack. The script runs the
hour once for each number of packers, unpaced, and prints the mean wait of each run.
"""

from desflow import SimulationContext

UNTIL = "1 h"  # the busy hour


def shop(packers: int) -> SimulationContext:
    """The shop in its busy hour, with `packers` packers."""
    app = SimulationContext(sim_id="Shop", factor=0, random_seed=42)  # unpaced
    app.add_service("packing", dist="constant", value=1.5)
    # The busy hour: 1.8 orders a second, more than one packer can pack
    app.add_arrival("orders", dist="exponential", rate=1.8)
    app.add_resource("packers", current_cap=packers, max_cap=10)
    # The mean wait of pack_order, kept by the run and read at the end
    app.with_stats("typical", interval=3600)

    @app.task(service_id="packing", resource_id="packers")
    def pack_order(task_id):
        return {"event_type": "order_packed"}

    @app.arrival_loop("orders")
    def order_generator(context):
        task_id = 0
        while True:
            order = yield context.wait_for_arrival("orders")
            context.spawn(pack_order(task_id, entity=order))
            task_id += 1

    return app


def mean_wait(packers: int) -> float:
    """Runs the busy hour with `packers` packers and returns the mean wait."""
    app = shop(packers)
    app.run(until=UNTIL)
    return app.env.stats.readings()["Shop.stats.tasks.pack_order.wait_mean"]


if __name__ == "__main__":
    for packers in range(1, 5):
        print(f"packers {packers}: mean wait {mean_wait(packers):8.2f} s")
docs/snippets/tutorials/queues_and_capacity_low_level.py
"""The Queues and Capacity tutorial: the shop's busy hour, on the low-level API.

Orders arrive at 1.8 a second and each takes 1.5 seconds to pack. The script runs the
hour once for each number of packers, unpaced, and prints the mean wait of each run.
"""

from desflow import (
    CapacityConfig,
    DistributionConfig,
    DynamicRealtimeEnvironment,
    DynamicResource,
    Sampler,
    SimParameter,
)

UNTIL = 3600  # the busy hour, in seconds


def params(packers: int) -> SimParameter:
    """The shop in its busy hour, with `packers` packers."""
    return SimParameter(
        sim_id="Shop",
        service={"packing": DistributionConfig(dist="constant", value=1.5)},
        # The busy hour: 1.8 orders a second, more than one packer can pack
        arrival={"orders": DistributionConfig(dist="exponential", rate=1.8)},
        resources={"packers": CapacityConfig(current_cap=packers, max_cap=10)},
    )


def pack_order(env, sampler, packers, task_id):
    """One order, which reports each step to the statistics."""
    path_id = "Shop.service.packing"
    env.stats.task_queued("pack_order", task_id)
    with packers.request() as request:
        yield request
        env.stats.task_started("pack_order", task_id)
        yield env.timeout(sampler.sample(env.registry.get_config(path_id)))
    env.stats.task_finished("pack_order", task_id)


def order_generator(env, sampler, packers):
    arrival = env.registry.get_config("Shop.arrival.orders")
    task_id = 0
    while True:
        yield env.timeout(sampler.sample(arrival))
        env.process(pack_order(env, sampler, packers, task_id))
        task_id += 1


def run(packers: int, until=UNTIL, factor=0.0):
    """Runs the busy hour with `packers` packers, unpaced, and returns the environment."""
    env = DynamicRealtimeEnvironment(factor=factor)
    env.registry.register_sim_parameter(params(packers))
    # The mean wait of pack_order, kept by the environment and read at the end
    env.setup_stats("Shop", "typical", interval=3600)
    packers_resource = DynamicResource(env, "Shop", "packers")
    sampler = Sampler(seed=42)
    env.process(order_generator(env, sampler, packers_resource))
    try:
        env.run(until=until)
    finally:
        env.teardown()
    return env


def mean_wait(packers: int) -> float:
    """Runs the busy hour with `packers` packers and returns the mean wait."""
    env = run(packers)
    return env.stats.readings()["Shop.stats.tasks.pack_order.wait_mean"]


if __name__ == "__main__":
    for packers in range(1, 5):
        print(f"packers {packers}: mean wait {mean_wait(packers):8.2f} s")

Next, Randomness and Seeds asks why each run differs, and how to repeat one.