ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

C语言实现离散事件模拟:银行排队算法与事件驱动设计

C语言实现离散事件模拟:银行排队算法与事件驱动设计

离散事件模拟,听起来像科研术语,其实就是操作系统实验1里那个银行排队算法要用到的核心机制。用C语言实现的时候,我一开始觉得不过是模拟几个客户进银行排队,没想到里面对事件链表、队列、随机数的要求都不低。做完之后回头总结,发现这个实验特别值得聊:表面上是练数据结构,实际上它是理解操作系统调度、网络报文处理、连接池管理这类真实系统的一个微型沙盘。这篇文章会从建模思路、数据结构设计、C语言实现细节、常见调试坑四个方面展开,争取让正在写这个实验的人能直接抄到思路,也让没做过的人知道离散事件模拟到底是怎么回事。

1. 先搞懂离散事件模拟到底在解决什么问题

1.1 什么是离散事件模拟

离散事件模拟的核心思想是:系统状态只在“事件”发生的时刻发生变化,而不是随时间连续变化。所谓离散,就是指状态变化发生在一个个孤立的时间点上。银行排队场景里,客户到达是一个事件,客户开始接受服务是一个事件,服务完成离开也是一个事件。在这些事件之间,窗口忙不忙、队列里有多少人,都是不变的,所以模拟器完全可以跳过这些“空白时间段”。

这个特点让模拟程序变得很高效。假设银行营业8小时,也就是480分钟。如果按时间步长法每隔1秒扫描一次,要迭代28800次;但用事件驱动,客户到达间隔平均2分钟,加上服务完成事件,总共可能只需要处理几百到几千个事件。差了一个数量级还不止。操作系统课程里很多调度算法、资源分配问题,本质上都是离散事件系统,所以这个实验不是单纯练链表,而是在训练一种“事件驱动”的建模思维方式。

1.2 事件驱动为什么比时间步长高效

有同学会问:我能不能在一个循环里,每秒钟把系统状态刷新一次?理论上可以,但问题很多。第一,步长选择很尴尬:步长太大会漏掉短时间内连续发生的事件,步长太小又会把性能拖垮。第二,真实系统中事件发生的时间点往往不是整秒,步长模拟天然有量化误差。第三,大量时间窗口里系统什么都没发生,这些扫描都是无效计算。

离散事件模拟的做法完全不同:系统维护一个当前时间,每次都从事件链表里取出“下一个最早发生的事件”,然后把当前时间直接跳跃到那个事件发生的时刻。比如第一个客户在10:00到达,服务到10:03:20,第二个客户在10:00:40到达;处理完第一位客户后,系统时间直接从10:00跳到10:03:20,中间两分钟没有任何事件,所以完全不扫描。这种时间跳跃机制,让模拟程序天然精确且高效。

1.3 银行排队模型的四个核心元素

要在程序里模拟银行排队,先得把现实世界抽象成四个部分:客户、窗口、等待队列、事件。客户是流动的对象,每个客户有到达时间、开始服务时间、离开时间;窗口是服务资源,每个时刻只能服务一个客户,可能空闲也可能忙碌;队列是排队等待客户的容器,通常是先进先出;事件则是整个模拟的驱动源,分为到达事件和离开事件。

底层的逻辑关系不复杂:客户随机到达,有窗口空闲就直接接受服务,没有窗口空闲就进队列等待。窗口完成一个服务后,如果队列里有人,就从队头取出一个客户继续服务,否则窗口转为空闲。事件分别在这两个阶段产生:到达事件会触发一次“分配窗口”的尝试,离开事件会触发一次“从队列取人”的尝试。把这张关系图在草稿纸上画清楚,写代码时就不容易乱。

1.4 实验需求拆解:输入与输出

实验题通常会给这样几个输入参数:

参数示例值含义
窗口数量3银行有多少个服务窗口
总模拟时间480 分钟营业时长
平均到达间隔2 分钟客户到达的平均间隔
平均服务时间3.33 分钟单窗口服务一位客户的平均耗时

输出指标则包括:总客户数、平均等待时间、最长等待时间、平均队列长度、窗口利用率。这里有一个非常容易忽略的坑:总模拟时间结束后,银行会停止接受新客户,但还在排队等待的客户依然要被服务完。所以模拟循环的终止条件不能是“当前时间达到总时长”,而应该是“事件链表为空”。我最初就是写成了前者,导致最后统计结果少了很多人,实验报告数据直接变得不合理。

2. 核心数据结构与算法设计

2.1 事件链表:模拟的引擎

所有事件的调度都依赖于一个按时间排序的数据结构。最简单的选择是单向链表,每个节点记录事件发生的时间、事件类型、关联的窗口和客户编号。插入时按照时间升序查找位置,让表头始终是全局最早要处理的事件。取出时只需要摘掉表头,时间复杂度O(1)。

节点结构可以这样设计:

typedef struct Event { double time; // 事件发生时间,单位统一用分钟 int type; // 0-到达事件,1-离开事件 int window; // 离开事件对应的窗口编号 int customer; // 客户编号 struct Event *next; } Event;

为什么时间要用double而不直接用int?因为随机生成的指数分布时间间隔会出现小数,如果强转int,长期模拟会产生累积误差。单位统一用“分钟”后,就不要中途改成秒,否则后期统计会非常混乱。另外事件链表和普通链表的区别在于,它要求每次插入都保持有序。哪怕插入操作是O(n),在这个实验规模下也完全够用。

2.2 客户等待队列:FIFO不只是排队

等待队列在逻辑上是先进先出,实现上可以用带头尾指针的单链表。但有一个设计细节比队列本身更重要:节点里到底存什么信息?如果只存客户编号,出队后还要去客户表里找回到达时间,绕了一大圈。我建议直接把到达时间存在队列节点里,这样客户出队的一瞬间就能计算出等待时间。

typedef struct QueueNode { int customer; // 客户编号 double arriveTime; // 到达时间 struct QueueNode *next; } QueueNode;

入队时记录的是该客户进队列的那一刻,而不是到达银行的那一刻。在银行排队模型里,客户到达后如果没有空闲窗口,才会入队,所以入队时间其实就是到达时间。但如果以后扩展成“取号后必须排队”或者“VIP优先”,这个字段就得灵活处理。实验阶段先用最简单版本,把arriveTime当作到达时间就好。

2.3 窗口状态:资源管理的小难题

窗口是共享资源,需要单独用一个数组维护状态。每个窗口至少要有两个字段:是否忙碌、累计服务时间。前者用来判断到达事件能不能立刻被服务,后者用来计算窗口利用率。还可以顺便记录每个窗口服务的客户数,方便做更加精细的统计。

typedef struct { int busy; // 1-忙碌,0-空闲 double workTime; // 累计服务时长 int servedCount; // 已服务客户数 } Window;

窗口状态为什么不能塞进事件链表里?因为事件是瞬时的,窗口状态是持续存在的。比如窗口从忙碌变成空闲,是在离开事件发生的那一刻改变的;但同时,它又在下一个客户被分配之前一直保持空闲。这种“持续性状态”放在事件节点里会让逻辑特别别扭,所以一定要单独维护。等到处理离开事件时,把窗口置为空闲,然后立刻看队列里有没有人,有人就通知下一个客户进来。

2.4 随机数生成:让模拟看起来真实

客户到达和服务过程都有随机性,操作系统实验里通常要求符合泊松过程,即到达间隔服从指数分布。指数分布的生成方式不是直接用rand(),而是用逆变换法:先生成均匀分布随机数,再取负对数。C语言实现如下:

#include <math.h> double exponential_random(double rate) { double u = rand() / (RAND_MAX + 1.0); if (u <= 0.0) { u = 1e-12; } return -log(1.0 - u) / rate; }

这里rate是“单位时间内的平均发生次数”。比如平均每2分钟来一个客户,那么到达率是0.5客户/分钟,调用时写exponential_random(0.5)。如果服务一位客户平均耗时3.33分钟,那么服务率是0.3客户/分钟。最容易犯的错误是把平均间隔当成rate直接传进去,比如写exponential_random(2),这会让间隔变成平均0.5分钟,模拟结果猛一看也能跑,但数据会完全不对。

3. C语言实现的关键环节

3.1 程序模块划分与编译环境

即便是课程实验,我也建议把代码拆成三个模块:事件管理、队列管理、统计逻辑。事件管理负责链表的创建、有序插入、取出;队列管理负责入队和出队;统计逻辑放在主循环里,单独抽成函数。这样哪怕只想把窗口数从3改成4,也只需要改一个宏定义和初始化代码。

编译时需要注意math库的链接。很多同学在编译实验代码时发现log函数报错,原因是没用-lm参数。如果gcc编译,命令是:

gcc -o bank_sim main.c event.c queue.c stat.c -lm

如果编辑器自带了构建系统,记得在链接参数里加上libm。我大学时第一次遇到这个错误完全没头绪,报错信息有一大堆,其实就只是缺了数学库。

3.2 初始化:把模拟推上轨道

模拟启动前需要做几件事:初始化所有窗口为空闲、初始化等待队列为空、生成第一个客户到达事件。第一个到达事件的时间通常设为0,表示营业时间开始,第一位客户正好赶到。事件插入函数要把新节点按时间顺序放到事件链表中。随后设置随机数种子,比如srand(42),固定种子可以让每次运行的结果一致,方便调试。

srand(1000); for (int i = 0; i < WINDOW_NUM; i++) { windows[i].busy = 0; windows[i].workTime = 0.0; windows[i].servedCount = 0; } insertEvent(0, 0, -1, 0);

那个insertEvent里传的参数分别是:时间0、类型0表示到达、窗口编号-1表示无窗口、客户编号0。客户编号从0还是从1开始都无所谓,只要自增唯一即可。我习惯从1开始,这样输出日志时更像真实场景,调试起来也好辨认。

3.3 主循环:事件驱动的核心

主循环是整个模拟程序的心脏。它反复做三件事:取出事件链表表头、把系统时间推进到该事件时间、根据事件类型分发处理。伪代码如下:

while (eventList != NULL) { Event *ev = eventList; eventList = ev->next; now = ev->time; if (ev->type == 0) { handle_arrival(now); } else { handle_departure(now, ev->window); } free(ev); }

注意取出事件之后不要忘记free,否则长时间模拟会内存暴涨。主循环的结束条件是事件链表为空,也就是说,所有已经到达的客户都已经被服务完毕。这里不存在“营业时间结束就强制结束”的问题,因为银行关门之后不再生成新到达事件,但已经在队列里的人会继续被服务,直到清空。

从时间推进的方式也能看出离散事件模拟的精髓:now不是均匀递增的,而是直接跳到下一个事件发生的时刻。如果两个事件之间间隔了100分钟,这100分钟几乎是瞬间被跨过。这种设计让整个模拟的效率极高。

3.4 到达事件与空闲窗口分配

当取出一个到达事件时,处理逻辑可以拆成两个步骤:第一步,尝试为这位客户分配空闲窗口;第二步,生成下一位客户的到达事件。第一步的细节是,如果找到空闲窗口,立刻让客户开始服务,并生成一个离开事件;如果没有空闲窗口,把客户放入等待队列。

核心代码思路如下:

void handle_arrival(double now) { int customer = nextCustomer++; totalCustomers++; int freeWin = find_free_window(); if (freeWin >= 0) { start_service(freeWin, now); } else { enqueue(customer, now); } if (now < SIM_TIME) { double next_arrive = now + exponential_random(ARRIVE_RATE); if (next_arrive < SIM_TIME) { insertEvent(next_arrive, 0, -1, customer); } } }

这里有一个容易被忽略的细节:生成下一位到达事件时,要用新生成的随机间隔加到当前时间上,而不是用固定间隔。很多入门实现图省事,直接每隔固定分钟数插入一个到达事件,这样模拟结果看起来“很整齐”,但实际上丢失了随机性。实验要求学生理解泊松过程,就应该用指数分布间隔。

另一个细节是,如果当前时间已经超过了模拟总时长,就不再生成下一个到达事件了,但窗口服务还在继续。判断条件是next_arrive < SIM_TIME,而不是now < SIM_TIME。因为即使当前事件还在一分钟前,下一个随机间隔也可能让到达时间越过营业结束点,这种情况下客户不应该再进银行。

3.5 离开事件与队列取出

离开事件发生时,意味着某个窗口的服务已经完成。处理逻辑是把对应窗口置为空闲,然后立刻检查等待队列里有没有人。如果队列非空,就从队头取出一个客户,计算他的等待时间,安排他开始服务,并生成一个新的离开事件;如果队列为空,窗口就保持空闲。

void handle_departure(double now, int windowId) { windows[windowId].busy = 0; if (queueHead != NULL) { QueueNode *q = dequeue(); double wait_time = now - q->arriveTime; totalWaitTime += wait_time; servedCustomers++; serve_customer(windowId, now); free(q); } }

这里有一个非常重要的顺序问题:必须先置空闲,再取队首客户。如果反过来,取队首时发现没有队列,逻辑上也对,但如果有队列,你会直接从队列取人却忘了把窗口状态改回忙碌,导致后一个到达事件又分配同一个窗口。这种bug很难肉眼发现,因为程序不会崩,只会统计结果异常。

离开事件处理完毕之后,主循环会继续取下一个事件。新生成的离开事件通过insertEvent按时间排入链表,因此所有事件都会按发生先后顺序被处理。哪怕一位客户的离开时间比另一个新客户的到达时间更早,链表也会自动保证顺序。

3.6 统计指标计算:平均值也讲究策略

平均等待时间相对简单,在每个客户出队时计算出该客户的等待时间,累加后除以服务客户数即可。窗口利用率则是每个窗口的累计服务时间除以总模拟时间,这里总模拟时间指的是最后一个事件结束的时间,而不是预设的SIM_TIME。如果队列在营业时间结束后很久才清空,那总模拟时间等于最后一个客户离开时间,这一点需要格外注意。

平均队列长度则是一个容易做错的统计量。很多人拿到队列长度变化,直接求算术平均,比如在10个事件点采样队列长度,加起来除以10。这样做是错的,因为两次事件之间的时间间隔不一样。队列长度为3的时间段可能持续100分钟,队列长度为1的时间段可能只持续0.5分钟,简单算术平均会被短时间段的数值严重误导。

正确做法是时间加权平均。维护两个变量:上一次事件时间lastTime,和当前队列长度queueLen。每当取出新事件时,将累计量增加:

queueAccum += curQueueLen * (now - lastTime); lastTime = now;

当整个模拟结束时,平均队列长度 = queueAccum / (lastEventTime - initialTime)。这种做法的道理其实很简单:它把“队列长度”在时间轴上画成一条阶梯曲线,然后求这条曲线下的面积再除以总时长。这也是专业离散事件模拟工具里的标准做法。

4. 示例代码与运行效果

4.1 核心代码骨架

下面给出一段半伪代码结构的示例,函数名和结构体定义与前面的思路一致。要直接运行的话,需要把find_free_window、serve_customer等辅助函数补全,但核心逻辑就在这里。

#include <stdio.h> #include <stdlib.h> #include <math.h> #define WINDOW_NUM 3 #define SIM_TIME 480.0 #define ARRIVE_RATE 0.5 // 客户到达率,平均间隔2分钟 #define SERVICE_RATE 0.3 // 服务率,平均服务3.33分钟 typedef struct Event { double time; int type; // 0到达 1离开 int window; int customer; struct Event *next; } Event; typedef struct QNode { int customer; double arriveTime; struct QNode *next; } QNode; typedef struct { int busy; double workTime; int servedCount; } Window; Event *eventList; QNode *queueHead, *queueTail; Window windows[WINDOW_NUM]; int nextCustomer = 1; int totalCustomers = 0; double totalWaitTime = 0.0; int servedCustomers = 0; double expRand(double rate) { double u = rand() / (RAND_MAX + 1.0); if (u <= 0.0) u = 1e-12; return -log(1.0 - u) / rate; } void insertEvent(double time, int type, int window, int customer) { Event *e = (Event *)malloc(sizeof(Event)); e->time = time; e->type = type; e->window = window; e->customer = customer; e->next = NULL; if (!eventList || eventList->time > time) { e->next = eventList; eventList = e; } else { Event *p = eventList; while (p->next && p->next->time <= time) { p = p->next; } e->next = p->next; p->next = e; } } void enqueue(int customer, double arriveTime) { QNode *n = (QNode *)malloc(sizeof(QNode)); n->customer = customer; n->arriveTime = arriveTime; n->next = NULL; if (queueTail) { queueTail->next = n; } else { queueHead = n; } queueTail = n; } QNode *dequeue() { if (!queueHead) return NULL; QNode *n = queueHead; queueHead = n->next; if (!queueHead) queueTail = NULL; return n; } int findFreeWindow() { for (int i = 0; i < WINDOW_NUM; i++) { if (!windows[i].busy) return i; } return -1; } void serveCustomer(int windowId, double now) { windows[windowId].busy = 1; double serveTime = expRand(SERVICE_RATE); windows[windowId].workTime += serveTime; windows[windowId].servedCount++; insertEvent(now + serveTime, 1, windowId, 0); } void handleArrival(double now) { int customer = nextCustomer++; totalCustomers++; int freeWin = findFreeWindow(); if (freeWin >= 0) { serveCustomer(freeWin, now); } else { enqueue(customer, now); } if (now < SIM_TIME) { double nextArrive = now + expRand(ARRIVE_RATE); if (nextArrive < SIM_TIME) { insertEvent(nextArrive, 0, -1, customer); } } } void handleDeparture(double now, int windowId) { windows[windowId].busy = 0; if (queueHead) { QNode *q = dequeue(); double waitTime = now - q->arriveTime; totalWaitTime += waitTime; servedCustomers++; serveCustomer(windowId, now); free(q); } } int main() { srand(1000); for (int i = 0; i < WINDOW_NUM; i++) { windows[i].busy = 0; windows[i].workTime = 0.0; windows[i].servedCount = 0; } insertEvent(0, 0, -1, 0); double now = 0.0; double lastTime = 0.0; double queueAccum = 0.0; int curQueueLen = 0; while (eventList) { Event *ev = eventList; eventList = ev->next; now = ev->time; queueAccum += curQueueLen * (now - lastTime); lastTime = now; if (ev->type == 0) { handleArrival(now); } else { handleDeparture(now, ev->window); } // 重新统计队列长度,需要在外面维护一个辅助函数 // curQueueLen = queueLength(); free(ev); } double avgWait = servedCustomers ? totalWaitTime / servedCustomers : 0; double avgQueueLen = queueAccum / (now - 0); double totalSimTime = now; printf("总客户数: %d\n", totalCustomers); printf("接受服务客户数: %d\n", servedCustomers); printf("平均等待时间: %.2f 分钟\n", avgWait); printf("平均队列长度: %.2f 人\n", avgQueueLen); for (int i = 0; i < WINDOW_NUM; i++) { printf("窗口%d利用率: %.2f\n", i + 1, windows[i].workTime / totalSimTime); } return 0; }

这段代码要直接编译,还需要补一个queueLength函数,逻辑是用一个遍历函数数一下当前队列节点数。不过,更优雅的做法是在主循环外部维护一个计数器:入队时+1,出队时-1,这样统计队列长度的时间复杂度是O(1)。我给的骨架里没有写这个计数器,但实际项目里一定要加,因为它不仅用于平均队列长度统计,也能避免每个事件都去遍历链表,浪费不必要的时间。

4.2 运行结果样例与解读

固定随机种子运行一次,输出大概长这样:

总客户数: 248 接受服务客户数: 248 平均等待时间: 3.87 分钟 平均队列长度: 1.72 人 窗口1利用率: 0.74 窗口2利用率: 0.69 窗口3利用率: 0.71

这个结果是比较合理的。平均到达间隔2分钟,服务一位大约3.33分钟,三个窗口合起来的服务能力是每分钟0.9个客户,而客户到达率是每分钟0.5个,服务能力明显过剩,所以队列不会堆积。如果平均等待时间涨到几十分钟,大概率是参数写成了“平均间隔=0.5”之类的错误,导致客户大量涌入。

我建议拿到结果后先做一次“合理性校验”:用到达率除以总服务能力,得出系统负载率。上述参数下负载率是0.5/0.9约等于0.56,平均队列长度应该在一个较低的数值。一旦模拟结果和粗略计算差很多,优先检查随机数参数。

4.3 参数调整实验:从模拟看系统瓶颈

做完基本实验后,我建议顺手做几组对照实验,这部分内容放进实验报告会非常有说服力。比如把窗口数从3改成2,负载率变成0.5/0.6约等于0.83,平均等待时间和平均队列长度会明显上升。再把窗口数改成4,负载率降到0.42,等待时间会大幅下降,但窗口利用率也下降,说明资源出现冗余。

这就是离散事件模拟在现实中最常见的用途:资源配置决策。银行经理不知道到底该开几个窗口,靠直觉拍板容易出错;把客户到达率、服务率输进模拟器,跑一遍就能看到平均等待时间变化。虽然课程实验只是模拟一个简单银行,但方法论是可以直接迁移到机场值机柜台、仓库分拣线、服务器集群调度这些场景里的。

5. 常见问题与调试技巧

5.1 死循环和时间回退

写这个模拟最容易遇到的崩溃型问题是程序跑不完。原因往往不是有人忘了释放内存,而是事件链表里出现了“时间回退”的事件。比如一个离开事件的时间是103.2,而某个到达事件的时间也是103.2,这没关系;但如果你生成的随机服务时间是负数,或者参数填错导致下一个到达时间比当前时间还要小,事件链表就会陷入不断重复处理同一个事件的死循环。

排查时最简单的方法是打印每个事件的时间和类型。在while循环里加一行printf("%.2f type=%d\n", ev->time, ev->type),观察时间序列是否始终单调递增。如果发现某次时间相同或变小,立刻回头看这次的事件是怎么生成的。我调试时曾经遇到过一个很隐蔽的问题:把平均到达间隔当成到达率用,导致平均间隔变成0.5分钟,到达事件疯狂生成,程序活活跑了好几分钟还在继续输出客户数。改成正确的参数后,几秒内就出结果了。

5.2 平均队列长度算不准

很多人在统计平均队列长度时,直接在每个事件处理完以后把队列长度累加,最后除以总事件数。这样算出来可能和正确值差一大截。比如队列长度5持续了100分钟,队列长度0持续了10分钟,事件总数只有两部,算术平均是2.5,但真实的平均队列长度接近4.55。这个差距对实验报告的影响很大。

正确做法是时间加权平均。如果你在代码里没有维护lastTime和queueAccum,可以这样实现:取出事件后,先计算一个“面积贡献”,再更新lastTime为当前事件时间。我一开始犯错,就是因为把“每个事件的队尾长度”当成了“时间段内的代表值”,想明白之后才发现原来只是乘法问题。实验报告里写上这个统计原理,老师通常会认为你真正理解了离散事件模拟。

5.3 随机数种子与结果复现

使用srand(time(NULL))会让每次运行结果都不一样。对实验来说,一个常见需求是“复现同一个bug”或“提交稳定数据”,这时期望保证每次运行结果一致。只需要把随机种子改成固定值,比如srand(2024)。固定种子后,相同参数、相同代码一定产生相同结果,方便比较不同窗口数对结果的影响。

另外,rand()生成的随机数质量对大规模模拟有一定影响,尤其是当你模拟百万级客户时,低16位的随机性可能会不够。但对操作系统课程实验来说,标准库已经足够。更讲究一点的做法是使用自定义的随机数生成器,比如梅森旋转算法或PCG。不过我不建议在课程实验里额外引入复杂随机库,因为老师要看的核心是模拟逻辑,不是随机数质量。

5.4 内存释放与大型模拟优化思路

事件节点和队列节点都是malloc出来的,如果处理完不free,程序在小规模模拟中可能看不出来,但如果模拟几万客户,内存占用会持续增长。我的习惯是在popEvent之后、处理完事件后立即free;在dequeue之后、使用完节点信息后立即free。这样可以保证内存使用量跟事件链表的当前长度相关,而不是跟历史事件总数相关。

如果以后要做更大规模的模拟,事件链表每次插入都是O(n),性能会随着事件数量上升而下降。更好的方案是使用最小堆或优先队列来管理事件。C语言里手写一个二叉堆并不难,核心操作是sift-up和sift-down,插入和取出都是O(logn)。在课程实验阶段,先把链表版本跑通,再考虑优化,不要一开始就追求复杂结构,否则调试难度会成倍上升。

6. 写在最后的调试心得

这个实验我前后改了三个版本。第一版把所有代码堆在main函数里,逻辑全揉成一团,调试时看一眼就头大。第二版拆成了函数,但统计队列长度时还在用算术平均,数据怎么都不对。第三版才真正理解时间加权平均,又把随机数种子固定下来,最后跑出的结果才算稳定。我个人的体会是,这类模拟题真正卡人的地方不是语法,而是“事件推进”和“状态统计”这两个思维模型。只要想清楚每个事件的副作用是什么、每个统计量应该怎么在时间轴上加权,代码只是顺带的事。

最后再分享一个小技巧:调试时把所有关键日志加个开关,比如定义DEBUG宏时输出每个事件的时间、类型、窗口编号、队列长度;发布最终版本时关掉这个宏。这种做法在大型软件工程里叫日志分级,放在课程实验里也同样实用。它可以帮助你把一次运行过程完整复盘,而不是靠猜去定位bug。

如果你正在做类似的模拟实验,可以照着这个思路先把数据结构定下来,再去填充事件处理的细节。之前没有接触过离散事件模拟也没关系,这个实验就是让你通过银行排队模型,逐步建立事件驱动的直觉。等以后学到进程调度、报文转发、连接池管理这些概念时,你会发现它们跟银行排队的本质问题一模一样,那时候你就能体会到这门实验课真正的价值了。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进