刚刚在陈硕老师的blog看到他针对一个模拟银行叫号系统给出的答案和代码。陈老师代码文章都写得好,令人佩服。恰好我的很大一部分工作都是基于仿真来做的,这里按照我自己的理解,简要介绍一些主要的仿真技术,给出一个非常简单的事件驱动的仿真程序,并用一个排队系统初步演示一下仿真程序的使用。
说起为什么需要仿真,理由可以列举很多,简要来说,它是在纯数学建模和实际系统的优缺点之间的一个折中。相对数学模型来说,仿真不需要高深的数学技巧,也不需要作过多的简化和假设,并且在求解复杂系统时,不会面对状态和空间爆炸的问题。再者,仿真也可以用来验证数学模型的正确性。而相比实际系统,仿真的成本要小得多,并且容易取得更多的统计信息。有时候,实际系统甚至是不可得的,比如对于Peer-to-Peer (P2P)网络的研究者来说,大部分时候,大部分人都不可能获得一个拥有上百万用户的P2P网络的控制,如果没有仿真,只怕很多做研究的都要无事可做了。
要进行仿真,首先需要对被仿真的系统进行建模。系统的模型由若干实体以及实体间的关系组成。实体的属性构成了系统的状态,而事件是引起系统状态发生改变的行为。通常来说,系统的状态随着时间的不断发生而不断变化。因此,要模拟一个系统的行为,最重要的就是模拟系统中的事件。
一般说来,仿真模型分为两中,一种称为周期驱动模型,也称为时间驱动,系统以固定的时间间隔增量累积时间,每次时钟的走动要求某些动作必须发生,这种模型的好处是非常简单,但真实性比较差;另一种是事件驱动模型,在这种模型中,系统状态的改变仅仅由事件的发生引发,系统时间也由事件的发生时间来确定,由于事件发生时间的随机性,系统时间的推进步长也是随机的。由于两个相邻事件之间的时间内系统状态不会发生改变,所以系统时钟可以直接从一个事件发生的时间推进到下一个事件的发生时刻。事件驱动的仿真模型能够比较精确地模拟复杂的系统,因此应用范围更加广泛。本文接下来只讨论事件驱动的仿真模型。
事件驱动的仿真模型的核心构件是“事件队列”,事件队列保存系统中还未发生的事件,并且事件是以其发生的时间先后排序的,这样,队列头总是指向下一个将要发生的事件。需要注意的是,在某一时刻,事件队列中并不一定保存了所有将要发生的时间,因为有些事件是在其他事件的发生过程中产生的。
事件驱动的仿真模型的流程一般可以概括如下:
Initialize event_queue while not event_queue.empty() and current_time<end_time: event = event_queue.front() event_queue.pop_front() current_time = event.occur_time event.happen() post simulation process
其中event_queue代表事件队列,end_time是仿真结束时间,只要时间队列为空,或者系统时间到达结束时间,仿真就结束了。仿真结束后一般会做一些统计工作。
实现一个事件驱动仿真模型实际上是相当简单的,我这里给出一个C++的实现。这个实现仅仅用于展示仿真的基本思路,为了保持代码简单,不考虑效率和可扩展性。
此处仅列出部分代码,全部代码可访问
https:// github.com/beyondwdq/Recipes/blob/master/edsim_naive
该实现包括两个文件,edsim_naive.h 和edsim_naive.cc。 edsim_naive.h主要定义了Event以及TEventHandler,前者表示一个事件,后者是一个函数指针,指向事件发生时的处理函数。代码如下:
typedef void (*TEventHandler) (void); struct Event{ Event(double occur_time, TEventHandler handler) : m_occur_time(occur_time) , m_handler(handler) {} bool operator < (const Event& rhs) const{ return m_occur_time < rhs.m_occur_time; } double m_occur_time; TEventHandler m_handler; };
edsim_naive.cc用一个list来表示事件队列,并定义了函数enqueue()来插入一个事件到事件队列中,run_sim()函数实现了事件驱动模型的核心流程。代码如下:
typedef std::list<Event> TEventQueue; TEventQueue g_event_queue; double g_current_time; void enqueue(const Event& event) { TEventQueue::iterator itr = std::upper_bound(g_event_queue.begin(), g_event_queue.end(), event); g_event_queue.insert(itr, event); } double now(void) { return g_current_time; } void run_sim(double end_time) { while(!g_event_queue.empty() && g_current_time<end_time){ Event event = g_event_queue.front(); g_event_queue.pop_front(); g_current_time = event.m_occur_time; (*event.m_handler)(); } }
离散事件仿真模型被广泛应用在各种排队系统的研究中,这里给出一个简单排队系统的实现。假设一个银行,有一个服务员,客户以随机时间到达银行,如果服务员空闲,则对此客户进行服务,如果服务员忙碌,则客户排队等候。服务顺序是先到先服务,排队队列长度没有限制,也就是说用户在到达后不会在得到服务前离开一行。假设客户到达的时间间隔和服务的时间都服从指数分布,了解排队论的应该知道这是一个M/M/1 queue,不了解也没关系。
此处仅列出部分代码,全部代码可访问:
https:// github.com/beyondwdq/Recipes/blob/master/edsim_naive/mm1.cc
系统中只有两个事件,客户到达和客户离开,因此只要定义两个事件处理函数 userArrival和userDeparture, 如下:
void userDeparture(void) { --g_online_user_cnt; logOnlineUserCount(); } void userArrival(void) { ++g_online_user_cnt; logOnlineUserCount(); double interval = exponential(g_avg_arrv_intv); double serv_time = exponential(g_avg_serv_time); Event next_arrv(now()+interval, &userArrival); Event departure(now()+serv_time, &userDeparture); enqueue(next_arrv); enqueue(departure); }
运行仿真的代码也相当简单(部分代码省去),如下:
int main(int argc, const char *argv[]) { ... //first arrival event Event first_arrv(0.0, &userArrival); enqueue(first_arrv); run_sim(end_time); ... }
假设第一个客户在0时刻到达,只要生成第一个客户到达事件,然后调用run_sim()仿真就开始运行了。仿真生成一个文件 online_user_cnt.txt 记录系统客户数目随时间的变化,并提供一个gnuplot叫本 online_user_cnt.p 画图。图示如下。需要注意的是,如果需要得到平滑的曲线,可以设置不同的随机数产生种子多次运行仿真并求平均值。
本文给出的仿真实现非常简单,在效率和扩展性上都有很多缺点,比如,每插入一个Event对象都需要做一次拷贝,用函数指针来做事件处理的handler不够灵活等等,实际系统中,事件队列常常是保存指向Event对象的指针,而事件处理的handler通常通过派生类来实现。还有就是事件队列的排序也可以优化,比如ns2就提供了三种队列: list, heap 和 calendar queue。 这些优化实现起来不难,以后有机会再讨论。