参考文章:
离散事件仿真库Simpy的执行效率之所以很高,关键在于生成器的使用,在Python中通过yield
来暂时停止协程中的子线程,再次调用时才从中断的位置开始。前面的文章《关于复制SimPy仿真环境的生成器的讨论》中我们介绍了生成器的相关特性。
本文基于简单的例子,介绍Simpy仿真环境的事件流过程。是如何控制调用生成器抛出的事件,以及如何控制仿真过程的结束。以下是一个公开找到的simpy仿真案例:
import simpy
def main():
env = simpy.Environment()
env.process(traffic_light(env))
env.run(until=140)
print('simulation done')
def traffic_light(env):
while True:
print(f'light green at :{env.now}')
yield env.timeout(30)
print(f'light yellow at :{env.now}')
yield env.timeout(5)
print(f'light red at :{env.now}')
yield env.timeout(20)
if __name__ == '__main__':
main()
其中,先通过simpy创建了一个仿真环境,在环境中定义了一个 process
方法,并将我们自定义的 traffic_light
作为参数传入该方法,然后运行之后,会不断地在仿真环境 env
调用 traffic_light
方法。返回结果如下:
light green at :0
light yellow at :30
light red at :35
light green at :55
light yellow at :85
light red at :90
light green at :110
simulation done
上述案例的运行逻辑是:先通过 simpy.Environment()
创建仿真环境 env
,然后在环境中添加一个事件 process(traffic_light(env))
,事件调用了一个函数 traffic_light(env)
,该函数的 env
为指明该函数的仿真环境,函数下的事件 env.timeout(30)
推进的是 env
的仿真事件。
在调用函数 traffic_light
时,不断地打印输出相应内容,以及推进仿真事件,当仿真事件被推进到终止的时间点时 env.run(until=140)
,仿真结束;在还未到达结束事件之前,事件会基于上次中断的位置,一直抛出事件并推进仿真。
创建的仿真环境 env
的内容:这是基于事件的模拟执行环境,随着仿真事件的步进,一步步地从一个事件推进到另一个事件来进行模拟。simpy.Environment()
可以传入仿真环境的初始时间(申明传入的值可以为 int 类型或 float 类型),若为空则默认初始时间为 0。并且在该环境中,将常见的事件命名为三类对象:process
,timeout
,event
。
依次介绍 Event 类的属性和方法:
_now
:仿真时间;_queue
:当前待处理的事件,一个列表,元素为四元元组,元组的信息包含一个数值、事件优先级、事件ID、事件对象;_eid
:迭代器生成事件ID,每次有事件安排进 _queue
时,都会赋予被安排事件一个iD;_active_proc
存储当前正在执行的 Process 事件;BoundClass.bind_early(self)
用于将 BoundClass 类型的属性绑定到环境实例上(以优化属性搜索时的开销,但会引入复杂性或影响代码的可维护性)(property) now()
:返回环境的仿真时间,被 @property 修饰为一个只读属性,可以直接视为一个属性进行访问,例如 env.now
无需加括号即可返回环境类的当前仿真时间;active_process()
:返回环境类当前执行中的 process 对象;schedule()
:基于事件给定的优先级和时间(now+delay),通过 heqppush 将事件(四元元组)插入到环境的事件队列 _queue
中,按照排程时间、优先级从小到大依次有序地从堆顶存放到堆底;peek()
:函数返回时间队列下一个待处理事件的排程时间,若没有顺位的事件,则返回 Infinity(无穷大的浮点数);step()
函数处理下一个事件(从 _queue
的堆顶弹出下一个处理事件最早的事件),如果没有下一个事件则抛出 EmptySchedule 错误;run()
:环境类的入口函数,通过该函数让整个仿真环境运行起来,其中,参数 until
可以传入整数、浮点数以及事件对象。如果没有传入值,则仿真环境会运行至没有需要加工的事件;如果传入的是一个事件,则仿真环境会持续执行加工直到该事件被触发,如果在该事件被触发之前已经没有待处理事件了,则弹出 RuntimeError 错误;如果是一个数值,则仿真环境会持续执行直到仿真时间到达该数值,具体操作为:创建一个在 now+until 触发的截止事件,该事件的优先级为0,当运行到该事件时,则触发 StopSimulation,结束仿真。因此,结束仿真事件的优先级为0,因此如上面的交通灯的案例,如果最后一个时间的触发的时间为140,且仿真环境的until=140,则最后一个事件不会被处理,仿真优先被终止。
这是仿真中用来定义事件的类,每个事件都会在某个时间点发生。有三种状态:可能发生(未被触发,triggered=False
)、正在发生(被触发,triggered=True
)、已经发生(processed=True
)。每个事件在初始化的时候都处于未被触发状态,被触发后会被安排进环境的 _queue
待加工队列,当被触发时,事件的 ok()
与 value()
会被修改。
依次介绍 Event 类的属性和方法:
_ok
:布尔变量_defused
:布尔变量_value
:默认值为 PENDING 对象,__init__(env)
:定义事件所在的 仿真环境,初始化事件的 callbacks
为一个空列表;__repr__()
:返回事件的类名、事件id;(property) triggered()
:当事件被触发且它的 callbacks
即将被调用是返回 True,也就是 self._value is not PENDING
,如果没有被触发,则 _value
的值还是 PENDING(这是一个具体的对象,用来唯一标识 _value
);(property) processed()
:当事件已经完成(它的callbacks
已经被调用)时返回 True,此时 callbacks is None
;(property) ok()
:返回 _ok
属性的值,当事件被触发时,改属性值为 True,如果事件在被触发之前被访问,则抛出 AttributeError 错误;(property) defused()
:返回对象是否存在 _defused
的判断,当一个事件失败后,则失败的事件的 value
会变成一个错误类型,在被调用该事件时被重新抛出;(defused.setter) defused()
:不论传入何值,都将事件的 _defused
赋值为 True;(property) value()
:当事件没有被触发,此时 _value
的值还是 PENDING,调用该方法会抛出 AttributeError,反之,则会返回 _value
的值;trigger()
:将事件的 _ok
和 _value
的值更新为传入事件的 _ok
和 _value
的值,并将本事件排进环境的 _queue
;succeed()
:触发事件,如果 _value is not PENDING
,则抛出 RuntimeError,说明事件在之前已经被触发了;反之,则修改 _ok
的值为 True,并对 _value
进行赋值,并将该时间排进环境的 _queue
;fail()
:触发事件失败,传入一个错误类型;如果事件未被触发,则将 _ok
赋值为 False,并将 _value
赋值为传入的错误类型,将该时间放入到环境的 _queue
中;__and__
:返回一个 Condition
类对象,只有当前事件与 other 事件都加工完,这个 Condition
类对象被触发;__or__
:返回一个 Condition
类对象,只有当前事件或 other 事件都加工完,这个 Condition
类对象被触发;用来处理生成器产生的事件,也被成为协程,可以通过产生事件来暂停执行。process 在时间发生后,使用该事件的值恢复生成器的执行。对于失败的事件,生成器会抛出异常。
Process 本身也是一个事件,每当生成器返回或抛出异常,它就会被触发,它的 value
就是生成器的返回值或接到的抛出的异常情况。process 在执行过程中可以通过方法 interrupt
进行中断。
依次介绍 Process类的属性和方法:
env
:指明 process对象所在的仿真环境;callbacks
:空列表_generator
:传入的生成器(带 yield
的函数);_target
:初始化 Initialize
,开始 process 的执行;_desc()
:返回 process 的类名,以及生成器名;(property) target()
:返回 process 时间的 _target
,即 process 当前等待执行的事件,若 process 被中断,则该方法返回 None; (property) is_alive()
:返回 True 直到退出生成器;interrupt()
:提供一个原因并中断该 process 事件,如果该 process 事件已经被中断,则不能再中断;_resume()
:基于事件的值恢复process事件的执行(通过向生成器发送当前事件的值,并获取生成器的下一个事件),如果生成器退出,则 process 事件会基于生成器返回的 value
以及报错信息进行触发。基于上面提到的小案例,我们将详细分析这个案例在 simpy 中的底层执行逻辑。再回顾下代码:
import simpy
def main():
env = simpy.Environment()
env.process(traffic_light(env))
env.run(until=140)
print('simulation done')
def traffic_light(env):
while True:
print(f'light green at :{env.now}')
yield env.timeout(30)
print(f'light yellow at :{env.now}')
yield env.timeout(5)
print(f'light red at :{env.now}')
yield env.timeout(20)
if __name__ == '__main__':
main()
根据 main()
函数,首先创建了仿真环境 Environment
对象,仿真环境的默认起始时间为 0。
env = simpy.Environment()
接着实例化一个 Process
对象,传入的参数为 traffic_light()
生成器,其中,env
传入该生成器是为了在生成器当中调用仿真环境的相关属性:
env.process(traffic_light(env))
接着传入 until=140
给仿真环境的 env.run()
函数,开始执行仿真过程,并且将仿真结束时间定为 140。
env.run(until=140)
具体进入 run()
,首先传入的 until
不为空,此时判断传入的 until
是个事件还是个数值,现在传入的是数值,将 until
的值赋给局部变量 at
(如果这个仿真结束时间小于等于仿真环境的当前时间,则抛出 ValueError),基于 untile
的值创建一个在 until+now
时候触发的事件,且该事件的优先级为最高优先级0,并给给这个事件的 callbacks
添加 StopSimulation.callback
。
循环地 try
仿真环境的 step()
函数,当执行到仿真环境终止的 until
事件,抛出的 StopSimulation 错误会被捕获,仿真过程会被终止;当step()
函数中 heappop(self._queue)
取不出下一个待处理事件,则抛出 EmptySchedule 错误,捕获该错误时说明全部的事件都被执行结束,如果 until
事件还没被触发,则会抛出 RuntimeError 错误,错误信息如下;反之,则正常结束仿真。
raise RuntimeError(f'No scheduled events left but "until" event was not triggered: {until}')
run()
方法是关于如何把仿真环境当中的 _queue
按顺序执行完,而 process()
方法会基于生成器将一个个事件通过 env.schedule(self)
加入到仿真环境的 _queue
,这里 schedule()
的默认优先级为 NORMAL=1,默认的 delay=0,说明生成器的事件是等到要处理时被加入到 _queue
。
具体的,env.process(traffic_light(env))
生成了一个 process 事件对象 Process(self, traffic_light(env))
,在该 process 事件对象中,优先初始化了一个 Initialize()
事件,并以最高优先级0将该初始化事件加入到仿真环境的 _queue
当中,表示 process 事件开始执行。在初始化事件 Initialize().__init()
,将传入的 Process
对象的 _resume
方法加入到初始化事件的 callbacks
列表当中(这个列表在 env.step()
当中会被执行,即在处理下一步事件时需要先处理的事件),因此在初始化事件当中,就需要先处理 Process
对象的 _resume
方法,它不断地从 process 中获取下一个待执行的事件。