離散事件仿真:從資源排隊到系統(tǒng)優(yōu)化)
簡介一份面向Python開發(fā)者與系統(tǒng)仿真初學(xué)者的完整技術(shù)文檔以SimPy離散事件建模為主線幫助讀者從零掌握復(fù)雜系統(tǒng)仿真的核心方法。文檔內(nèi)容系統(tǒng)完整先從離散事件系統(tǒng)的基本概念講起再深入講解SimPy的環(huán)境、進程、事件、資源等核心組件并提供了醫(yī)院急診室、交通路口、供應(yīng)鏈系統(tǒng)三大真實場景的完整仿真案例實現(xiàn)每個案例都包含問題描述、模型假設(shè)、代碼實現(xiàn)與結(jié)果分析。同時還涵蓋了仿真結(jié)果的數(shù)據(jù)收集與可視化分析、自定義事件與進程、資源優(yōu)先級管理、多環(huán)境并行仿真等進階應(yīng)用技巧。資源為1個PDF文件共32頁大小4.98MB支持目錄章節(jié)跳轉(zhuǎn)與閱讀器大綱定位排版清晰完整。已有207人學(xué)習(xí)使用適合需要快速上手SimPy進行復(fù)雜系統(tǒng)建模與仿真的技術(shù)開發(fā)人員和高校學(xué)生。1. 為什么拿 SimPy 做離散事件建模先解決「資源排隊」這類算不清的賬做排班優(yōu)化、產(chǎn)線擴容或者急診室流程改造的時候最頭疼的問題不是找不到方案而是沒人說得清方案投下去到底有沒有用。拿醫(yī)院急診室舉例加一個醫(yī)生到底能讓平均等待時間降多少新增一臺設(shè)備值不值這類問題靠 Excel 手工算算不動靠寫多線程模擬要處理大量并發(fā)和時間管理成本又太高。SimPy 正是為這種場景設(shè)計的它基于 Python 的生成器機制用事件驅(qū)動的思路模擬「誰在什么時刻做了什么、占用了什么資源、等了多少時間」。這篇筆記會從核心概念、建模范式、三個實戰(zhàn)案例到高頻踩坑逐個拆讓你拿到就能照著搭自己的仿真模型。2. 核心組件與基本建模范式環(huán)境、進程、事件怎么配合2.1 環(huán)境與進程仿真世界的「時鐘」和「演員」先建立兩個最基本的認知環(huán)境Environment管時間進程Process管行為。Environment 內(nèi)部維護一個事件隊列和當(dāng)前仿真時刻env.now所有的事件都按時間順序排列環(huán)境只負責(zé)按次序把事件拿出來執(zhí)行。進程則是寫在生成器函數(shù)里的行為邏輯用yield暫停和恢復(fù)執(zhí)行。這里的關(guān)鍵是理解yield的語義進程遇到y(tǒng)ield env.timeout(n)時相當(dāng)于說「我先睡 n 個時間單位到點再叫我」。環(huán)境把這條等待記錄掛進事件隊列然后把執(zhí)行權(quán)交給其他進程。SimPy 是協(xié)作式調(diào)度不是多線程搶占所有進程都在同一個 Python 線程里依次切換不存在共享內(nèi)存競爭問題。import simpy def car(env): while True: # 停車階段 print(f開始停車當(dāng)前時間: {env.now}) yield env.timeout(5) # 行駛階段 print(f開始行駛當(dāng)前時間: {env.now}) yield env.timeout(2) env simpy.Environment() env.process(car(env)) env.run(until15)邏輯說明car是個無限循環(huán)生成器env.timeout(5)讓進程暫停 5 個時間單位env.timeout(2)再暫停 2 個。env.run(until15)的意思是讓仿真時鐘一直推進到 15 為止進程會循環(huán)執(zhí)行停車和行駛的動作。參數(shù)until可以設(shè)成整數(shù)也可以是datetime.timedelta對象后者常用于真實日期時間的仿真。跑這段代碼會看到時間點 0、5、7、12、14 交替出現(xiàn)這就是環(huán)境按事件時間戳調(diào)度進程的直觀效果。一個重要認知進程的執(zhí)行順序不取決于env.process()的調(diào)用順序而取決于事件被喚醒的時間順序。哪怕你先注冊了后到的進程只要它的喚醒時間靠后它就必須等前面的時間事件先處理完。理解這一點對調(diào)試多進程交互非常重要。2.2 事件機制yield 暫停與恢復(fù)的底層邏輯事件Event是 SimPy 的基本調(diào)度單位所有能讓進程暫停的結(jié)構(gòu)本質(zhì)上都是 Event。最常用的是env.timeout(delay)它在env.now delay時刻觸發(fā)。還有event.succeed()手動觸發(fā)的同步事件以及simpy.Resource的資源請求事件。進程通過yield event來「等待」事件一旦觸發(fā)進程從yield處恢復(fù)繼續(xù)往下執(zhí)行。import simpy def event_example(env): print(f當(dāng)前時間: {env.now}) # 創(chuàng)建一個超時事件 event env.timeout(5) # 等待事件觸發(fā) yield event print(f超時事件觸發(fā)當(dāng)前時間: {env.now}) env simpy.Environment() env.process(event_example(env)) env.run()邏輯說明env.timeout(5)拿到的是一個事件對象yield event把進程掛起直到事件觸發(fā)。當(dāng)仿真環(huán)境處理到這個超時事件時進程被喚醒并繼續(xù)執(zhí)行后續(xù)代碼。注意env.run()不帶參數(shù)時會一直運行到所有活躍進程全部終止對這個例子來說事件處理完進程即結(jié)束仿真自然停止。參數(shù)層面timeout的延遲單位完全由你的模型自定義。你可以約定 1 個單位 1 分鐘也可以 1 小時SimPy 本身不做單位換算。這點從建模第一天就必須明確不然后期分析和真實系統(tǒng)對比數(shù)據(jù)時容易出偏差。我一般會在腳本頭部寫清楚時間單位的注釋并統(tǒng)一用同一個單位常量。3. Resource 與隨機建模排隊系統(tǒng)的核心套路3.1 Resource代表服務(wù)臺、醫(yī)生、工位這些有限容量資源離散事件仿真里最常遇到的場景就是排隊顧客到收銀臺結(jié)賬、患者到診室看病、工件到機床加工。這類系統(tǒng)的共同點是資源有限、需求隨機。SimPy 的 Resource 組件正是為此設(shè)計的它內(nèi)部維護一個容量計數(shù)和等待隊列capacity參數(shù)決定同時可服務(wù)的數(shù)量。import simpy import random # 平均顧客到達間隔時間 ARRIVAL_INTERVAL 5 # 平均服務(wù)時間 SERVICE_TIME 3 def customer(env, name, counter): print(f{name} 于 {env.now} 到達超市) with counter.request() as req: yield req print(f{name} 于 {env.now} 開始接受服務(wù)) service_time random.expovariate(1 / SERVICE_TIME) yield env.timeout(service_time) print(f{name} 于 {env.now} 完成服務(wù)離開超市) def customer_arrivals(env, counter): i 0 while True: yield env.timeout(random.expovariate(1 / ARRIVAL_INTERVAL)) i 1 env.process(customer(env, f顧客 {i}, counter)) env simpy.Environment() counter simpy.Resource(env, capacity1) env.process(customer_arrivals(env, counter)) env.run(until20)邏輯說明customer進程用with counter.request() as req申請收銀臺資源申請成功后才進入服務(wù)狀態(tài)。with塊的好處是無論正常結(jié)束還是中途異常資源都會自動釋放這是 SimPy 最推薦的寫法。customer_arrivals進程用指數(shù)分布生成隨機到達間隔每到一個顧客就調(diào)env.process()創(chuàng)建新進程。capacity1表示只有一個收銀臺如果改成 3 就意味著三個收銀臺并行服務(wù)這是實驗參數(shù)里最值得調(diào)優(yōu)的變量。這里有個容易被忽略的細節(jié)request()返回的請求對象在進入with塊時可能不會立即滿足——如果當(dāng)前資源被占滿進程在這個yield處掛起進入資源的等待隊列。資源空閑時按隊列順序喚醒對應(yīng)的進程。SimPy 默認是 FIFO 隊列PriorityResource則支持按優(yōu)先級插隊這在后面的高級章節(jié)會展開。3.2 隨機分布與仿真參數(shù)為什么用指數(shù)分布來模擬到達大多數(shù)排隊系統(tǒng)里顧客到達過程不是均勻間隔的。進店的客人總是一陣一陣來有時連續(xù)兩三個人有時五分鐘沒人。指數(shù)分布模擬這種「無記憶性」的隨機到達最合適——它對應(yīng)泊松過程的到達間隔每個時間點到達概率恒定與已經(jīng)等了多少時間無關(guān)。代碼里random.expovariate(1 / ARRIVAL_INTERVAL)的lambd參數(shù)是到達率1 / 5表示平均每 5 分鐘來一個。import simpy import random import numpy as np import matplotlib.pyplot as plt ARRIVAL_INTERVAL 5 SERVICE_TIME 3 SIMULATION_TIME 100 waiting_times [] def customer(env, name, server): arrival_time env.now with server.request() as req: yield req waiting_time env.now - arrival_time waiting_times.append(waiting_time) service_duration random.expovariate(1 / SERVICE_TIME) yield env.timeout(service_duration) def customer_arrivals(env, server): i 0 while True: yield env.timeout(random.expovariate(1 / ARRIVAL_INTERVAL)) i 1 env.process(customer(env, f顧客 {i}, server)) env simpy.Environment() server simpy.Resource(env, capacity1) env.process(customer_arrivals(env, server)) env.run(untilSIMULATION_TIME) # 用 NumPy 計算平均等待時間 average_waiting_time np.mean(waiting_times) print(f平均等待時間: {average_waiting_time}) # 用 Matplotlib 繪制等待時間直方圖 plt.hist(waiting_times, bins20, edgecolork) plt.xlabel(等待時間) plt.ylabel(顧客數(shù)量) plt.title(顧客等待時間直方圖) plt.show()邏輯說明這段代碼在上一例基礎(chǔ)上增加了數(shù)據(jù)收集——waiting_times列表記錄每個顧客從到達資源請求到真正獲得服務(wù)的等待時長。仿真結(jié)束后用 NumPy 算平均值用 Matplotlib 畫直方圖看分布形態(tài)。參數(shù)until100把仿真時長拉長到 100 個時間單位為了讓采樣量足夠大統(tǒng)計結(jié)果才有代表性。參數(shù)選擇上要注意SERVICE_TIME 3是平均服務(wù)時長指數(shù)分布的方差等于均值平方所以波動很大。真實場景中如果想模擬更穩(wěn)定的服務(wù)時間可以用正態(tài)分布random.gauss或三角分布需要根據(jù)實測數(shù)據(jù)的形態(tài)來定。做敏感性分析時一般會把這幾個參數(shù)設(shè)為變量跑多組取對比。4. 三個復(fù)雜系統(tǒng)案例拆解從模型假設(shè)到可運行代碼4.1 醫(yī)院急診室分診護士和醫(yī)生的兩級排隊急診室的典型場景是患者到達后先分診、再就診。分診護士是第一個瓶頸醫(yī)生資源是第二個瓶頸。建模的核心是先畫流程、再定資源容量和到達分布。模型假設(shè)患者平均每 6 分鐘到一位分診服務(wù)時間服從均值為 2 的指數(shù)分布醫(yī)生接診時間均值為 10 分鐘共有 2 名醫(yī)生。import simpy import random ARRIVAL_INTERVAL 6 TRIAGE_TIME 2 DOCTOR_TIME 10 NUM_DOCTORS 2 SIM_TIME 480 # 模擬一個 8 小時班次 def patient(env, name, triage_nurse, doctor): arrival_time env.now # 分診階段 with triage_nurse.request() as req: yield req yield env.timeout(random.expovariate(1 / TRIAGE_TIME)) # 候診階段等醫(yī)生 with doctor.request() as req: yield req yield env.timeout(random.expovariate(1 / DOCTOR_TIME)) total_time env.now - arrival_time print(f{name} 總耗時 {total_time:.1f}) def patient_arrivals(env, triage_nurse, doctor): i 0 while True: yield env.timeout(random.expovariate(1 / ARRIVAL_INTERVAL)) i 1 env.process(patient(env, f患者 {i}, triage_nurse, doctor)) env simpy.Environment() triage_nurse simpy.Resource(env, capacity1) doctor simpy.Resource(env, capacityNUM_DOCTORS) env.process(patient_arrivals(env, triage_nurse, doctor)) env.run(untilSIM_TIME)邏輯說明患者進程先請求分診護士資源完成后立刻請求醫(yī)生資源。兩級資源串行構(gòu)成了兩級隊列。capacityNUM_DOCTORS的調(diào)參意義在于把NUM_DOCTORS從 2 改成 3 再跑一遍對比總耗時的變化就能判斷新增醫(yī)生的邊際收益。常見做法是記錄每個患者的total_time到列表里最后畫直方圖看長尾在哪。這個模型里沒有區(qū)分患者危重程度實際項目建議把患者分類為輕癥和重癥用PriorityResource讓重癥優(yōu)先插隊。4.2 交通路口信號燈周期和車輛排隊等待交通路口仿真的核心是信號燈狀態(tài)切換對車輛到達/離開節(jié)奏的影響。用一個 Simulation 模擬主干道單路口車輛隨機到達停車線紅燈亮?xí)r排隊等待綠燈亮?xí)r依次通過。信號燈周期固定紅燈 30 秒、綠燈 40 秒循環(huán)切換。import simpy RED_TIME 30 GREEN_TIME 40 ARRIVE_INTERVAL 3 SIM_TIME 600 def traffic_light(env, green_event): while True: # 紅燈階段紅燈不單獨建模綠色事件未觸發(fā)即禁止通行 yield env.timeout(RED_TIME) # 綠燈亮觸發(fā)事件喚醒所有等待車輛 green_event.succeed() green_event env.event() yield env.timeout(GREEN_TIME) green_event env.event() def car(env, name, green_event): print(f{name} 在 {env.now} 到達路口) yield green_event print(f{name} 在 {env.now} 通過路口) def car_arrivals(env, green_event): i 0 while True: yield env.timeout(ARRIVE_INTERVAL) i 1 env.process(car(env, f車輛 {i}, green_event)) env simpy.Environment() green_event env.event() env.process(traffic_light(env, green_event)) env.process(car_arrivals(env, green_event)) env.run(untilSIM_TIME)邏輯說明green_event是手動管理的事件對象。紅燈期間到達的車輛執(zhí)行yield green_event掛起。綠燈亮?xí)rgreen_event.succeed()會喚醒所有等待車輛然后重建一個新事件對象供下一輪使用。注意succeed()只能觸發(fā)一次所以必須在每個周期都創(chuàng)建新的事件實例。參數(shù)ARRIVE_INTERVAL和RED_TIME、GREEN_TIME的比例直接決定擁堵程度可調(diào)的實驗杠桿是綠燈時長分配。這個模型做了簡化所有車輛到達即排隊不考慮通過時間也不區(qū)分左轉(zhuǎn)直行。如果要模擬多車道和多相位建議把信號燈狀態(tài)機拆成獨立的 Process每個相位用env.timeout推進相位切換時更新通行事件。邏輯雖然復(fù)雜但核心思路還是「掛起等待 事件喚醒」。4.3 供應(yīng)鏈系統(tǒng)庫存消耗、補貨觸發(fā)與安全庫存驗證供應(yīng)鏈仿真的典型問題是確定再訂貨點和訂貨量庫存太低容易缺貨太高占用資金。一個簡化的倉庫模型需求隨機到達每單消耗 1 件庫存庫存低于再訂貨點時觸發(fā)補貨補貨提前期固定 5 天記錄缺貨次數(shù)和平均庫存水位。import simpy import random INITIAL_STOCK 20 REORDER_POINT 10 ORDER_QTY 30 LEAD_TIME 5 DEMAND_INTERVAL 2 SIM_TIME 100 stock INITIAL_STOCK reorder_in_transit 0 stock_out_count 0 stock_levels [] def demand_process(env): global stock, reorder_in_transit, stock_out_count while True: yield env.timeout(random.expovariate(1 / DEMAND_INTERVAL)) stock_levels.append(stock) if stock 0: stock - 1 else: stock_out_count 1 # 達到再訂貨點且沒有在途訂單觸發(fā)補貨 if stock REORDER_POINT and reorder_in_transit 0: reorder_in_transit 1 env.process(replenishment(env)) def replenishment(env): global stock, reorder_in_transit yield env.timeout(LEAD_TIME) stock ORDER_QTY reorder_in_transit 0 env simpy.Environment() env.process(demand_process(env)) env.run(untilSIM_TIME) print(f缺貨次數(shù): {stock_out_count}) print(f平均庫存: {sum(stock_levels) / len(stock_levels):.1f})邏輯說明用全局變量stock維護庫存水位。reorder_in_transit是個布爾標(biāo)志防止重復(fù)下單。stock REORDER_POINT觸發(fā)補貨邏輯LEAD_TIME是下單到入庫的延遲用來模擬真實供應(yīng)鏈的滯后。每次需求到達后記錄庫存快照仿真結(jié)束計算平均庫存。調(diào)整REORDER_POINT和ORDER_QTY這兩個參數(shù)觀察缺貨率變化就是這個模型的核心實驗。這里有個容易被忽略的細節(jié)庫存統(tǒng)計的時間加權(quán)問題。每步固定間隔采樣的stock_levels是離散快照如果需求不是均勻到達均值會和真實時間加權(quán)平均有一些偏差。更精確的做法是在每次庫存變化時記錄變化時刻和變化值用時間加權(quán)公式計算平均庫存。小規(guī)模模型用快照法問題不大跑敏感性分析時建議換成事件驅(qū)動的統(tǒng)計方式。5. 仿真常見問題與避坑記錄五個翻車現(xiàn)場5.1 進程死循環(huán)env.run() 永遠跑不完現(xiàn)象設(shè)置了until100程序卻像卡死一樣一直不結(jié)束CPU 占用率拉滿。原因進程內(nèi)的while True循環(huán)里有yield env.timeout(0)或直接yield一個已觸發(fā)的事件事件循環(huán)不停地把零時延事件塞回隊列仿真時間始終不前進。這在場景里最常出現(xiàn)在那些忘了加超時時間的分支邏輯上。解決檢查所有yield語句確認每次循環(huán)至少有一個正時長的env.timeout()。排查時可以臨時在進程開頭加計數(shù)器超過某個閾值就拋異常退出。我一般會在每個while True循環(huán)的入口打一行日志跑到哪個分支立刻能看出來。5.2 隨機結(jié)果不可復(fù)現(xiàn)兩次跑同一模型結(jié)果差很大現(xiàn)象改了一個參數(shù)后重跑發(fā)現(xiàn)連改之前的結(jié)果都對不上了很難判斷效果到底是參數(shù)引起的還是隨機波動引起的。原因沒有固定隨機種子。random.expovariate每次調(diào)用都用系統(tǒng)熵源生成結(jié)果兩次運行從第一分鐘起就分道揚鑣了。解決在腳本開頭調(diào)用random.seed(42)。生產(chǎn)級實驗應(yīng)該把種子作為命令行參數(shù)每輪實驗記錄種子值以便回歸。這樣做敏感性分析時每個參數(shù)組合都用相同的隨機數(shù)序列對比才有意義。5.3 with 塊縮進錯誤統(tǒng)計數(shù)據(jù)被提前記錄了現(xiàn)象等待時間的列表里出現(xiàn)很多 0 值明顯不符合直覺。原因把統(tǒng)計等待時間的append語句寫在了with server.request()之前進程還在排隊呢就已經(jīng)把等待時間記錄成 0 了。解決記錄邏輯必須放在yield req之后——只有yield返回了資源才算真正拿到此刻env.now - arrival_time才是真實等待時長。拿不準(zhǔn)的時候把打印語句放在不同位置跑一次短仿真日志順序一目了然。5.4 事件對象復(fù)用導(dǎo)致狀態(tài)丟失現(xiàn)象手動管理的事件在第二個周期失效進程在應(yīng)該被喚醒時繼續(xù)掛起。原因event.succeed()調(diào)用后事件就進入觸發(fā)狀態(tài)不能用于下一輪。沒有為每個周期創(chuàng)建新的事件實例導(dǎo)致后續(xù)車輛永遠等一個已經(jīng)觸發(fā)過的事件。解決在觸發(fā)后立刻重新賦值一個新事件對象像交通燈示例里那樣green_event env.event()。凡是手動管理的周期事件都要養(yǎng)成「觸發(fā)即重建」的習(xí)慣。5.5 時間單位不統(tǒng)一參數(shù)看起來對但結(jié)果離譜現(xiàn)象改成真實數(shù)據(jù)后仿真結(jié)果比實際情況大幾百倍或者小幾百倍。原因有人把到達間隔寫成「5 分鐘」服務(wù)時間寫成「3 秒」直接混用沒換算仿真輸出完全不可解釋。解決在腳本頭部定義明確的單位常量并寫注釋例如TIME_UNIT 1 # 1 單位 1 分鐘所有參數(shù)都基于這個單位換算。如果項目需要跨系統(tǒng)對比建議用datetime.timedelta作為底層表示SimPy 官方支持只是速度慢一些。6. 最后一塊拼圖事件組合、優(yōu)先級資源與可視化驗證6.1 事件組合與條件等待從「等一個」到「等一組」SimPy 支持用和|組合事件。event1 event2表示兩個事件都觸發(fā)后才會喚醒等待進程event1 | event2表示任一事件觸發(fā)即可喚醒。這在建?!傅任锪系轿煌瑫r等設(shè)備空閑」這類并聯(lián)條件時非常實用。import simpy def machine_repair(env): # 物料到達和維修工空閑兩個條件同時滿足才開工 parts_ready env.timeout(4) worker_free env.timeout(6) yield parts_ready worker_free print(f維修開始當(dāng)前時間: {env.now}) env simpy.Environment() env.process(machine_repair(env)) env.run()邏輯說明parts_ready在時刻 4 觸發(fā)worker_free在時刻 6 觸發(fā)組合事件要等兩者都完成所以env.now輸出是 6。如果用|就會變成 4——誰先到都算滿足。注意組合事件的觸發(fā)時間只取決于較快或較慢的那個邊界不能用來表達「還需要再等一段固定時間」的語義那種情況還是得用env.timeout。6.2 優(yōu)先級資源與可視化驗證PriorityResource是Resource的子類request(priority1)可以指定優(yōu)先級。數(shù)字越小優(yōu)先級越高高優(yōu)先級請求會排在低優(yōu)先級前面搶占資源。醫(yī)院分診里給危重患者高優(yōu)先級、供應(yīng)鏈里給關(guān)鍵客戶訂單提前插隊都是典型用法。from simpy.resources.resource import PriorityResource resource PriorityResource(env, capacity1) # 低優(yōu)先級請求先到高優(yōu)先級后到但高優(yōu)先級先獲得資源 env.process(low_priority_process(env, resource)) env.process(high_priority_process(env, resource))可視化方面記住一個原則先看分布再看均值。plt.hist(waiting_times, bins20)看長尾plt.boxplot(waiting_times)看離群點plt.plot(env.now_list, stock_levels)看隨時間的變化趨勢。多組參數(shù)對比時把多條折線畫在同一張圖里一眼就能看出瓶頸在哪組參數(shù)下消失。從那以后我每次跑仿真都強制走一遍固定種子、時間單位注釋、事件實例重建檢查這三步再做任何參數(shù)實驗心里都有底。希望幫到你。本文還有配套的精品資源點擊獲取