在动手写仿真代码之前,有三个问题必须想清楚:监控什么、什么时候监控、数据往哪放。这三个问题的答案,几乎决定了你整个监控方案的走向。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 里最有意思也最烧脑的部分。Resource、Container、Store 这些内置类的源码你改不了,想在它们的操作前后插入逻辑,就得用一个听起来有点野路子、但其实非常优雅的技术------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 做中间缓冲层,仿真结束后再转换格式。这是性能和灵活性之间最省力的平衡点,踩过坑的人都懂。
参考资料
- SimPy 官方文档 --- Monitoring:simpy.readthedocs.io/en/latest/t...
- SimPy 官方文档 --- Overview:simpy.readthedocs.io/
- Winterflower Blog --- What is going on in this SimPy Monitoring example?:winterflower.github.io/2017/04/05/...