用一个电影院售票厅,把 SimPy 的条件事件讲透

先说这个例子在演示什么

这个电影院的例子换了一个角度,重点展示 SimPy 里另外两个非常实用的机制:条件事件(Condition Events)共享事件(Shared Events)

场景很生活化:一家电影院,一个售票窗口,同时上映三部电影,每部只卖当场的票。观众陆续到来,随机选一部电影,随机想买 1 到 6 张票。关键在于------一旦某部电影票快卖完了,还在排队等这部电影的观众会集体离队(renege)。

"集体离队"这件事,用普通的轮询逻辑来实现会很麻烦。SimPy 的做法是:把"售罄"定义成一个事件,让所有等待这部电影的观众同时监听它。事件一触发,所有人同时收到信号,各自决定走还是留。这就是共享事件的精髓。


这个例子新增了哪些核心概念

在机器车间的基础上,这个例子引入了三个新东西:

概念 对应用法 作用
普通资源 simpy.Resource 有容量限制的队列资源,先来先得
条件事件 `event_a event_b`
共享事件 simpy.Event + .succeed() 一个事件被触发,所有监听它的进程同时收到通知
事件结果检查 if my_turn not in result 判断是哪个事件触发了条件,决定后续行为

其中条件事件 | 是这个例子的灵魂。它让一个进程可以同时等待两件事,谁先发生就响应谁------这在现实仿真里极其常见。


代码逐行拆解

参数与导入

python 复制代码
from __future__ import annotations  # 支持类型注解的前向引用(Python 3.10 以下需要)

import random
from typing import Dict, List, NamedTuple, Optional  # 类型注解工具

import simpy

RANDOM_SEED        = 42    # 随机种子,保证结果可复现
TICKETS            = 50    # 每部电影的初始票数
SELLOUT_THRESHOLD  = 2     # 剩余票数低于此值时触发"售罄"事件
SIM_TIME           = 120   # 仿真时长(分钟)

SELLOUT_THRESHOLD = 2 的设计很有意思:不是等票卖完才宣布售罄,而是剩余不足 2 张时就触发。这模拟了现实中"最后几张票无法满足多人购买需求"的情况。


Theater 数据结构:用 NamedTuple 组织仿真状态

python 复制代码
class Theater(NamedTuple):
    counter: simpy.Resource            # 售票窗口,容量为 1(同时只服务一位顾客)
    movies: List[str]                  # 电影名称列表
    available: Dict[str, int]          # 每部电影的剩余票数,键为电影名
    sold_out: Dict[str, simpy.Event]   # 每部电影对应一个"售罄"事件
    when_sold_out: Dict[str, Optional[float]]  # 记录每部电影的售罄时刻
    num_renegers: Dict[str, int]       # 记录每部电影因售罄而离队的人数

NamedTuple 在这里只是一个轻量级的数据容器,把电影院相关的所有状态打包在一起,方便在进程间传递。

sold_out 字典是理解这个例子的关键------每部电影对应一个独立的 SimPy 事件对象 。这个事件初始未触发,一旦调用 .succeed(),所有正在 yield 等待它的进程都会同时被唤醒。


主程序:搭建电影院

python 复制代码
print('Movie renege')
random.seed(RANDOM_SEED)              # 固定随机种子

env = simpy.Environment()             # 创建仿真环境

movies = ['Python Unchained', 'Kill Process', 'Pulp Implementation']

theater = Theater(
    counter=simpy.Resource(env, capacity=1),
    # ★ 普通资源,容量为 1,同一时刻只有一位观众在窗口买票
    # 其余人排队等待,按到达顺序服务

    movies=movies,

    available=dict.fromkeys(movies, TICKETS),
    # 用 dict.fromkeys 快速创建字典,每部电影初始票数均为 50
    # 结果:{'Python Unchained': 50, 'Kill Process': 50, 'Pulp Implementation': 50}

    sold_out={movie: env.event() for movie in movies},
    # ★ 为每部电影创建一个独立的 SimPy 事件对象
    # env.event() 创建一个"未触发"的事件,等待后续调用 .succeed() 激活

    when_sold_out=dict.fromkeys(movies),
    # 初始值均为 None,售罄时记录仿真时刻

    num_renegers=dict.fromkeys(movies, 0),
    # 初始值均为 0,统计因售罄而离队的人数
)

env.process(customer_arrivals(env, theater))  # 启动观众到来进程
env.run(until=SIM_TIME)                       # 运行仿真 120 分钟

customer_arrivals:源源不断地制造观众

python 复制代码
def customer_arrivals(env, theater):
    """持续生成新观众,直到仿真结束"""
    while True:
        # 观众到达间隔服从指数分布,均值为 0.5 分钟
        # expovariate(1/0.5) = expovariate(2),平均每 0.5 分钟来一位
        yield env.timeout(random.expovariate(1 / 0.5))

        movie = random.choice(theater.movies)        # 随机选一部电影
        num_tickets = random.randint(1, 6)           # 随机想买 1~6 张票

        if theater.available[movie]:
            # 只有该电影还有余票,才生成这位观众的进程
            # 若已售罄(available=0),直接忽略,不进入队列
            env.process(moviegoer(env, movie, num_tickets, theater))

这里有个小细节:if theater.available[movie] 是在观众到达时 做的预检查。但这个检查并不完全可靠------观众到达时还有票,但排队等待的过程中可能售罄。真正的售罄处理逻辑在 moviegoer 进程里。


moviegoer:整个例子的核心进程

这个函数集中展示了条件事件和共享事件的用法,值得一行一行细看。

python 复制代码
def moviegoer(env, movie, num_tickets, theater):
    """
    一位观众尝试购买某部电影的票。
    她面临两种可能:轮到自己买票,或者电影在等待中售罄。
    """

    with theater.counter.request() as my_turn:
        # ★ 向售票窗口申请服务,返回一个"请求事件"
        # my_turn 是一个事件对象:当轮到这位观众时,它会被触发
        # with 语句确保离开时自动释放窗口(无论是买到票还是中途离开)

        # ★★ 核心:条件事件,同时等待两件事
        # my_turn        ------ 轮到我了(获得窗口服务权)
        # theater.sold_out[movie] ------ 这部电影售罄了
        # | 运算符创建"或"条件:任意一个事件触发,yield 就返回
        result = yield my_turn | theater.sold_out[movie]

        # result 是一个字典,包含所有已触发的事件
        # 通过检查 my_turn 是否在 result 中,判断是哪个事件触发了 yield

        if my_turn not in result:
            # 售罄事件先触发了,我还没轮到就没票了
            theater.num_renegers[movie] += 1  # 离队人数 +1
            return                            # 直接离开,with 块自动释放请求

        # 能走到这里,说明轮到我了(my_turn 在 result 中)

        # 检查剩余票数是否满足需求
        if theater.available[movie] < num_tickets:
            # 票不够了,和售票员争论一番后离开
            yield env.timeout(0.5)  # 争论耗时 0.5 分钟
            return                  # 离开,不计入 renegers(这是正常购票失败)

        # 购票成功
        theater.available[movie] -= num_tickets  # 扣减库存

        if theater.available[movie] < SELLOUT_THRESHOLD:
            # ★ 剩余票数低于阈值,触发"售罄"共享事件
            # .succeed() 会唤醒所有正在等待这个事件的进程
            # 所有还在排队等这部电影的观众,都会在下一个调度周期收到通知
            theater.sold_out[movie].succeed()

            # 记录售罄时刻
            theater.when_sold_out[movie] = env.now

            # 将剩余票数置 0(不足阈值的零散票不再出售)
            theater.available[movie] = 0

        # 正常购票流程耗时 1 分钟
        yield env.timeout(1)

条件事件的工作原理,画一张图

这是整个例子最值得反复理解的部分。

scss 复制代码
观众 A(等 Python Unchained)
    │
    │── yield my_turn | sold_out['Python Unchained']
    │        │                    │
    │        │                    │
    │   等待窗口轮到我          等待售罄信号
    │        │                    │
    │        └──── 谁先触发 ───────┘
    │                  │
    │         ┌────────┴────────┐
    │         │                 │
    │    轮到我了            电影售罄了
    │    去买票              离队走人
    │    (my_turn in result)  (my_turn not in result)

共享事件的广播效果:

arduino 复制代码
sold_out['Python Unchained'].succeed() 被调用
         │
         ├──▶ 观众 A 的 yield 返回(result 中只有 sold_out 事件)
         ├──▶ 观众 B 的 yield 返回
         ├──▶ 观众 C 的 yield 返回
         └──▶ 观众 D 的 yield 返回
              (所有等待这个事件的进程同时被唤醒)

一次 .succeed(),所有监听者同时响应。这就是"共享事件"名字的由来------一个事件,被多个进程共享监听。


结果输出与分析

python 复制代码
for movie in movies:
    if theater.sold_out[movie]:
        # ★ 直接对事件对象做布尔判断
        # SimPy 的 Event 对象在触发后(.succeed() 被调用后)布尔值为 True
        # 未触发时为 False,这里用来判断该电影是否售罄过

        sellout_time  = theater.when_sold_out[movie]
        num_renegers  = theater.num_renegers[movie]
        print(
            f'Movie "{movie}" sold out {sellout_time:.1f} minutes '
            f'after ticket counter opening.'
        )
        print(f'  Number of people leaving queue when film sold out: {num_renegers}')

输出结果:

csharp 复制代码
Movie "Python Unchained" sold out 38.0 minutes after ticket counter opening.
  Number of people leaving queue when film sold out: 16
Movie "Kill Process" sold out 43.0 minutes after ticket counter opening.
  Number of people leaving queue when film sold out: 5
Movie "Pulp Implementation" sold out 28.0 minutes after ticket counter opening.
  Number of people leaving queue when film sold out: 5

"Pulp Implementation"最快售罄(28 分钟),却只有 5 人离队------说明它售罄时排队的人不多。"Python Unchained"售罄时有 16 人离队,说明它更受欢迎,积累了更长的队伍。


两个例子对比,SimPy 的机制全景

把机器车间和电影院放在一起看,SimPy 的核心机制基本就覆盖完了:

机制 机器车间 电影院
进程 Machine.working / break_machine moviegoer / customer_arrivals
超时 env.timeout(done_in) env.timeout(1)
资源请求 repairman.request(priority=1) theater.counter.request()
抢占资源 PreemptiveResource ---
中断 process.interrupt() ---
条件事件 --- `my_turn
共享事件 --- sold_out[movie].succeed()

读完这个例子,真正理解了什么

SimPy 的事件系统比很多人想象的更灵活。env.event() 创建的不只是一个"等待点",它是一个可以被任意进程触发、被任意数量的进程监听的信号对象

| 运算符把两个事件组合成条件,让进程可以同时等待多件事,谁先发生响应谁------这种模式在排队系统、超时重试、竞争资源等场景里极其常见。

更深一层的设计哲学是:SimPy 把"等待某件事发生"这个动作,从业务逻辑里抽象出来,变成一个可以组合、可以传递、可以共享的一等公民。这让仿真代码的表达能力远超普通的状态机或轮询写法。

相关推荐
日月云棠4 小时前
9 Double 与 Float —— IEEE 754 浮点数在 Java 中的实现
java·后端
日月云棠4 小时前
5 StringBuffer —— 线程安全的可变字符串
java·后端
砍材农夫5 小时前
物联网 基于netty核心实战-会话管理
后端
元宝骑士5 小时前
MySQL 8.0 递归 CTE:树形结构一键生成层级 Path 并更新回表
后端·mysql
Mahir085 小时前
MyBatis 深度解密:从执行流程到底层原理全解
java·后端·面试·mybatis
孟林洁5 小时前
Java转AI应用开发速成(3)—— 第一个 SpringAI 聊天应用
java·spring boot·后端·ai·机器人
小村儿6 小时前
连载11- Claude code 的 Agent Teams——当子 Agent 开始互相说话
前端·后端·ai编程
折哥的程序人生 · 物流技术专研6 小时前
《Java 100 天进阶之路》第35篇:Java异常处理最佳实践
java·开发语言·后端·面试·求职招聘
用户8356290780516 小时前
使用 Python 创建 PowerPoint SmartArt 图形
后端·python