SimPy 监控与数据收集:完整指南

在动手写仿真代码之前,有三个问题必须想清楚:监控什么、什么时候监控、数据往哪放。这三个问题的答案,几乎决定了你整个监控方案的走向。SimPy 本身不提供"开箱即用"的自动监控------它更像一个工具箱,把钩子和接口留给你,让你用 Python 原生的方式自由组合。

维度 可选方向
监控什么 进程状态变量 / 资源使用率 / 全局事件流
何时监控 固定时间间隔 / 状态变化时触发
如何存储 Python 列表 / 文件 / 数据库 / NumPy 数组

一、监控你自己的进程

这是最直接的切入点。进程代码是你自己写的,想在哪里插采集逻辑就在哪里插,没有任何障碍。

最简单的列表收集

定义一个列表,状态变量有变化就 append 进去------就这么简单。

python 复制代码
import simpy

data = []

def test_process(env, data):
    val = 0
    for i in range(5):
        val += env.now
        data.append(val)       # 在此收集数据
        yield env.timeout(1)

env = simpy.Environment()
p = env.process(test_process(env, data))
env.run(p)
print('Collected', data)
# 输出: Collected [0, 1, 3, 6, 10]

data 作为参数传入进程,而不是用全局变量,这个习惯养成了之后会少踩很多坑。env.now 随时可读,每次 yield 前后都是插入采集逻辑的好时机。

同时记录多个字段

一旦需要记录的字段超过两个,裸 tuple 就开始让人头疼了。这时候 namedtuple 是个好帮手,结构清晰,取值时也不用靠下标猜意思:

python 复制代码
from collections import namedtuple
import simpy

Record = namedtuple('Record', ['time', 'value', 'state'])
data = []

def my_process(env, data):
    state = 'idle'
    for i in range(3):
        state = 'busy'
        data.append(Record(env.now, i * 2, state))
        yield env.timeout(1)
        state = 'idle'
        data.append(Record(env.now, i * 2, state))
        yield env.timeout(0.5)

env = simpy.Environment()
env.process(my_process(env, data))
env.run()
print(data)

性能这件事,别等出问题再想

如果你的目标存储是数据库或 NumPy 数组,千万别在仿真过程中每次都直接写入。正确姿势是先缓存到 Python 列表,仿真跑完再批量转换:

python 复制代码
import numpy as np

buffer = []

# 仿真过程中只操作 buffer(纯 Python list,极快)
buffer.append((env.now, value))

# 仿真结束后一次性转换
arr = np.array(buffer, dtype=[('time', float), ('value', float)])

道理很简单:Python list 的 append 是摊销 O(1),而 NumPy 数组逐行插入是 O(n)。规模小的时候感觉不出来,一旦仿真跑到百万级事件,差距会让你印象深刻。


二、资源监控:Monkey Patching 登场

资源监控是 SimPy 里最有意思也最烧脑的部分。ResourceContainerStore 这些内置类的源码你改不了,想在它们的操作前后插入逻辑,就得用一个听起来有点野路子、但其实非常优雅的技术------Monkey Patching(猴子补丁)

什么是 Monkey Patching

说白了,就是在运行时把对象的某个方法偷偷换掉。原来的方法还在,只是被你包了一层,执行前后可以做任何事。SimPy 官方文档里推荐的 patch_resource 函数,正是这个思路的标准实现:

python 复制代码
from functools import partial, wraps
import simpy

def patch_resource(resource, pre=None, post=None):
    """
    为资源的 put/get/request/release 方法注入前置/后置回调。
    pre(resource)  → 操作执行前调用
    post(resource) → 操作执行后调用
    """
    def get_wrapper(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            if pre:
                pre(resource)
            ret = func(*args, **kwargs)
            if post:
                post(resource)
            return ret
        return wrapper

    for name in ['put', 'get', 'request', 'release']:
        if hasattr(resource, name):
            setattr(resource, name,
                    get_wrapper(getattr(resource, name)))

@wraps 这个装饰器干什么用的

@wraps(func) 来自 functools,它把被包装函数的元数据------__name____doc__ 之类------复制到 wrapper 上。没有它,你包装完之后调试时会发现所有方法都叫 wrapper,排查问题时会很抓狂。加上它,包装后的函数"看起来"还是原来那个函数。

python 复制代码
# 没有 @wraps:wrapper.__name__ == 'wrapper'
# 有 @wraps: wrapper.__name__ == 'request'(原函数名)

完整的资源监控示例

python 复制代码
from functools import partial, wraps
import simpy

def patch_resource(resource, pre=None, post=None):
    def get_wrapper(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            if pre:  pre(resource)
            ret = func(*args, **kwargs)
            if post: post(resource)
            return ret
        return wrapper
    for name in ['put', 'get', 'request', 'release']:
        if hasattr(resource, name):
            setattr(resource, name,
                    get_wrapper(getattr(resource, name)))

def monitor(data, resource):
    item = (
        resource._env.now,
        resource.count,
        len(resource.queue),
    )
    data.append(item)

def user(env, name, resource):
    with resource.request() as req:
        yield req
        print(f'{name} using resource at {env.now}')
        yield env.timeout(1)

env = simpy.Environment()
resource = simpy.Resource(env, capacity=1)
data = []

patch_resource(resource, post=partial(monitor, data))

for i in range(3):
    env.process(user(env, f'User-{i}', resource))

env.run()
print('\n监控数据 (time, count, queue_len):')
for row in data:
    print(row)

输出:

sql 复制代码
User-0 using resource at 0
User-1 using resource at 1
User-2 using resource at 2

监控数据 (time, count, queue_len):
(0, 1, 2)
(0, 1, 1)
(1, 1, 1)
(1, 1, 0)
(2, 1, 0)
(3, 0, 0)

各类资源的可监控属性

资源类型 关键属性 含义
Resource .count 当前占用数量
Resource .capacity 总容量
Resource .queue 等待请求列表
Container .level 当前容量水位
Store .items 当前存储的物品列表
PreemptiveResource .users 当前使用者(含优先级)

三、全局事件追踪:在环境层面动手脚

有时候你想追踪的不是某个进程或某个资源,而是仿真里发生的所有事件 。这就得在 Environment 这一层做文章了。

子类化 Environment

SimPy 允许你继承 Environment 并重写 step() 方法。每次事件被处理时,step() 都会被调用一次------在这里插入追踪逻辑,就能捕获整个仿真的事件流:

python 复制代码
import simpy

class MonitoredEnvironment(simpy.Environment):
    def __init__(self):
        super().__init__()
        self.event_log = []

    def step(self):
        if self._queue:
            event_time, _, _, event = self._queue[0]
            self.event_log.append({
                'time': event_time,
                'event_type': type(event).__name__,
            })
        super().step()

env = MonitoredEnvironment()

def simple_proc(env):
    yield env.timeout(1)
    yield env.timeout(2)

env.process(simple_proc(env))
env.run()
print(env.event_log)

partial 的妙用

functools.partial 在监控代码里出现频率极高。它的作用是预先绑定部分参数,生成一个新的可调用对象,让回调的写法更干净:

python 复制代码
# 不用 partial,用 lambda
post=lambda r: monitor(data, r)

# 用 partial,更简洁
post=partial(monitor, data)

两者完全等价,但在需要组合多个回调的场景里,partial 的可读性明显更好。


四、定时采样还是事件驱动?

这是监控策略里绕不开的一个选择,没有绝对的对错,只有适不适合你的场景。

定时采样是在固定时间间隔采集数据,适合需要时间序列分析的场景:

python 复制代码
def monitor_process(env, resource, data, interval=1.0):
    while True:
        data.append({
            'time': env.now,
            'utilization': resource.count / resource.capacity,
            'queue_len': len(resource.queue),
        })
        yield env.timeout(interval)

env = simpy.Environment()
res = simpy.Resource(env, capacity=3)
data = []

env.process(monitor_process(env, res, data, interval=0.5))
env.run(until=10)

数据点均匀,绘图和统计都省心;代价是可能错过两个采样点之间发生的短暂峰值。

事件驱动采样则是每次状态变化时立即记录(也就是上面的 Monkey Patching 方案)。好处是不遗漏任何变化,数据完整;麻烦在于数据点密度不均,想算时间加权平均值得多做一步后处理。

两种方案经常混用------用事件驱动捕捉精确的状态转换,用定时采样生成用于绘图的均匀序列。


五、数据往哪放

内存(Python List)

最简单,中小规模仿真的首选,没什么好多说的:

python 复制代码
data = []
data.append((env.now, value))

文件(CSV / JSON)

需要持久化或跨进程共享时用:

python 复制代码
import csv

with open('simulation_data.csv', 'w', newline='') as f:
    writer = csv.writer(f)
    writer.writerow(['time', 'count', 'queue'])
    writer.writerows(data)  # 仿真结束后批量写入

NumPy / Pandas

仿真跑完后转换,科学计算和可视化都方便:

python 复制代码
import pandas as pd

df = pd.DataFrame(data, columns=['time', 'count', 'queue_len'])
print(df.describe())
df.plot(x='time', y='count')
avg_util = df['count'].mean() / capacity

数据库

大规模仿真场景下,先缓冲到列表,再批量插入,避免频繁 I/O 拖慢仿真:

python 复制代码
import sqlite3

conn = sqlite3.connect('sim.db')
conn.executemany('INSERT INTO events VALUES (?, ?, ?)', data)
conn.commit()

六、综合实战:一个完整的服务台监控系统

把上面所有技术拼在一起,看看真实场景下长什么样:

python 复制代码
from functools import partial, wraps
from collections import namedtuple
import simpy
import random

Record = namedtuple('Record', ['time', 'count', 'queue_len'])
resource_log = []
process_log  = []

def patch_resource(resource, pre=None, post=None):
    def get_wrapper(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            if pre:  pre(resource)
            ret = func(*args, **kwargs)
            if post: post(resource)
            return ret
        return wrapper
    for name in ['put', 'get', 'request', 'release']:
        if hasattr(resource, name):
            setattr(resource, name,
                    get_wrapper(getattr(resource, name)))

def resource_monitor(data, resource):
    data.append(Record(
        resource._env.now,
        resource.count,
        len(resource.queue)
    ))

def customer(env, cid, server, proc_log):
    arrive = env.now
    with server.request() as req:
        yield req
        wait = env.now - arrive
        service_time = random.expovariate(1.0)
        yield env.timeout(service_time)
        proc_log.append({
            'id': cid, 'arrive': arrive,
            'wait': wait, 'service': service_time
        })

def arrivals(env, server, proc_log):
    cid = 0
    while True:
        yield env.timeout(random.expovariate(0.8))
        env.process(customer(env, cid, server, proc_log))
        cid += 1

random.seed(42)
env    = simpy.Environment()
server = simpy.Resource(env, capacity=2)

patch_resource(server, post=partial(resource_monitor, resource_log))
env.process(arrivals(env, server, process_log))
env.run(until=20)

avg_wait = sum(r['wait'] for r in process_log) / len(process_log)
avg_util = sum(r.count for r in resource_log) / (len(resource_log) * server.capacity)

print(f"平均等待时间: {avg_wait:.3f}")
print(f"平均资源利用率: {avg_util:.1%}")
print(f"资源日志条数: {len(resource_log)}")
print(f"完成服务顾客数: {len(process_log)}")

收尾:四个工具,打遍天下

SimPy 的监控哲学说到底就一句话:框架不替你做决定,但把该给的工具都给你了。把下面这张表记住,绝大多数监控场景都能覆盖:

技术 适用场景 核心机制
列表 append 进程内状态变量 直接插入采集代码
Monkey Patching 资源使用监控 patch_resource + @wraps
定时采样进程 均匀时间序列 独立 while True 进程
子类化 Environment 全局事件追踪 重写 step() 方法

有一个习惯值得从一开始就养成:永远用 Python list 做中间缓冲层,仿真结束后再转换格式。这是性能和灵活性之间最省力的平衡点,踩过坑的人都懂。


参考资料

相关推荐
卷无止境1 小时前
Event Latency:把"等待"这件事,交给电缆来负责
后端
武子康1 小时前
Java-10 深入浅出 MyBatis 一对多与多对多查询配置详解
java·后端
一 乐1 小时前
网上订餐系统|基于springboot的网上订餐系统设计与实现(源码+数据库+文档)
java·数据库·spring boot·后端·论文·毕设·网上订餐系统
XovH1 小时前
第14篇 Docker Compose 开发环境最佳实践:热重载与调试
后端
.Cnn2 小时前
SpringBoot 文件上传与阿里云 OSS 集成
java·spring boot·后端·阿里云
XovH2 小时前
Docker从0到1再到 Kubernetes 实战:第15篇Compose 中的服务依赖、健康检查与启动顺序
后端
XovH2 小时前
Docker 从 0 到 1 再到 Kubernetes 实战:第13篇 Compose 环境变量与配置管理
后端
楼田莉子2 小时前
C++20新特性:Range库
开发语言·c++·后端·学习·c++20