ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

C#模拟电梯控制系统:状态机与LOOK调度算法实战解析

C#模拟电梯控制系统:状态机与LOOK调度算法实战解析

简介:这套C#模拟电梯控制源码面向具备一定C#基础、想深入理解面向对象与事件驱动开发的学习者。项目基于Windows平台,从零演示电梯调度模拟系统的完整代码结构,涵盖楼层与乘客对象建模、按钮事件与委托订阅、多线程同步、状态机管理开关门和升降,以及最短路径调度等算法实现。例如电梯类设有当前楼层、目标楼层、载客量等属性,并提供上升、下降、开门、关门方法;楼层类管理上下行按钮状态。压缩包共27个文件,以6个C#源文件为骨架,辅以可执行程序、窗体资源、调试符号及项目配置,包体仅246KB,可直接在Visual Studio中加载运行。目前已有282人学习下载。通过阅读源码,可以看清楼层请求如何被事件触发、电梯如何按优先级队列确定停靠顺序、界面状态如何刷新,以及异常处理和单元测试如何融入工程;配合设计文件和图标资源,能快速梳理一个桌面应用的模块划分,适合用作课程设计或入门练手。

1. C# 模拟电梯控制源码:为什么这个老题目至今仍是锻炼调度与状态机设计的最佳练手项目

如果你搜索“C# 模拟电梯控制源码”,大概率会看到一堆课程设计级别的代码:一个窗体、几行 if else、电梯上下乱跑。但真正的电梯控制系统是一个典型的实时调度系统,它要同时处理轿厢指令、楼层外呼、门机时序、超载与故障,还要在所有请求之间做出让平均等待时间最短的决策。用 C# 写一遍这个模拟,本质是在用最少的硬件成本去复刻一个多线程状态机 + 调度算法的完整闭环。它适合正在学 C# 面向对象设计的开发者,适合准备面试中调度算法题的求职者,也适合需要在工控或物联网项目里快速验证调度逻辑的嵌入式工程师。这篇文不打算给你一个可以直接交作业的完整源码包,而是把控制系统的分层拆开,给你一套能自己写、能调参数、能验证对错的最小实现框架。你会看到状态机怎么建模、调度器管什么、参数怎么设,以及那些翻车率极高的边界坑。

2. 电梯控制系统的核心:状态机与调度器的职责边界

2.1 电梯到底有哪几种状态,状态图为什么要先画再写代码

很多模拟源码一上来就写if (currentFloor < targetFloor) MoveUp(),这种代码跑通三五层楼没问题,但一加入门机时序和顺向截停就会乱成一团。原因在于它把“状态”散落在 if else 里,而不是收敛成一个可枚举的状态集合。

我一般会先定义电梯的六个核心状态:

  • Idle:无指令,轿厢停在当前楼层,门处于关闭状态
  • Moving:轿厢朝目标楼层运行,细分上行或下行
  • Decelerating:到达目标楼层前的减速阶段,此时不再响应新的顺向截停
  • DoorOpening:开门过程,耗时几百毫秒
  • DoorOpenWaiting:开门等待,等待乘客进出,时间可配置
  • DoorClosing:关门过程,需检测门区障碍

故障状态我放在另一个枚举里单独处理,避免和正常运行的状态搅在一起。为什么要把 DoorOpening 和 DoorOpenWaiting 拆成两个状态?因为门机是有动作时间的,拆开后你可以精确模拟“开门瞬间外部又按了同方向请求”这种情况——门还没开完,要不要重新计时?真实电梯里门开到位后等待时间重新计算,所以这两个状态必须分开。

状态图的价值在于把所有合法转移画出来,写代码时只需要对着图补全 switch 或状态模式的分支。常见做法是先画一个状态转移表,列出“当前状态 + 事件 → 下一状态 + 动作”。比如 Moving + 到达目标楼层 → Decelerating + 触发减速;Decelerating + 速度减到零 → DoorOpening + 发开门指令。先把这张表写出来,再去写类,实现时基本不会漏分支。

public enum ElevatorState { Idle, // 空闲 Moving, // 运行中 Decelerating, // 减速中 DoorOpening, // 开门过程 DoorOpenWaiting, // 开门等待 DoorClosing // 关门过程 }

这段枚举定义了状态机的全部合法状态。注意我没有把 Up 和 Down 放进状态里,而是用单独的Direction属性表示,因为方向是属性,状态是行为,把两者混在一起会导致状态数量翻倍、转移条件混乱。

2.2 调度器管方向,不管理楼层:指令与请求分层

模拟电梯最常见的架构错误,是让电梯类自己决定“我现在要去几楼”。正确做法是调度器只决定方向和下一个目标楼层,电梯类只执行运动与开关门,二者职责分离。

我把数据分成两层:

  • 轿厢指令(Car Calls):乘客在轿厢内按下的目标楼层,精确到楼层号
  • 楼层外呼(Hall Calls):乘客在某层按上行或下行按钮,是“楼层 + 方向”的二元组

调度器做的事情是合并这两类信号,生成一个目标序列。电梯本体不需要知道这个序列是怎么来的,它只从调度器拿两个值:目标楼层和运动方向。

这样分层的好处是替换调度算法时电梯类完全不用改。你今天用 LOOK 算法,明天想换抢占式调度,只需要改调度器的内部逻辑。代码结构上我会用接口把调度策略抽象出来:

public interface IElevatorScheduler { // 输入轿厢指令和外呼请求,输出下一个目标楼层和方向 (int targetFloor, ElevatorDirection direction) NextStop(ElevatorState state, int currentFloor, HashSet<int> carCalls, List<HallCall> hallCalls); }

返回值用元组表示“去几楼、往哪个方向”。HallCall是一个(int floor, ElevatorDirection dir)的结构体,表示某层有人按了上行或下行。参数说明里有几个值得注意的点:state传给调度器是为了让调度器知道电梯当前是否处于开门等待阶段,这会影响顺向截停的判断;currentFloor用于过滤已经到达的楼层;carCalls是去重后的集合,因为同一楼层按十次按钮也只算一次。

接口设计的核心价值在于可测试。你可以在不启动任何 UI 的情况下构造一组请求,断言NextStop的返回值是否符合预期。

2.3 等待模型:为什么 LOOK 而不是 FCFS

先入先出(FCFS)的思路最简单:按请求到达顺序一个个执行。但它会造成两个严重问题:电梯来回空跑,以及先来的请求可能阻塞后来的同向顺路请求。真实电梯没有用 FCFS 的,就是因为效率太差。

LOOK 算法是目前实际项目中用得最多的策略。它的规则是:电梯保持当前方向运行,沿途响应所有同方向的请求,直到当前方向上不再有请求,再反转方向。与 SCAN 的区别在于,SCAN 会一直运行到端站才掉头,而 LOOK 在最高请求楼层就掉头,省去无意义的空驶。

我选择 LOOK 作为模拟实现的默认策略,原因有三个:

  • 逻辑简单,状态转移清晰
  • 平均等待时间明显优于 FCFS
  • 扩展顺向截停、高峰模式等高级特性时不需要推翻重写

第三个原因是最关键的。照 LOOK 实现之后,你再加入“电梯运行中某层按了同方向请求,可即时加入路径”这个特性,只需要在请求进入时判断方向是否一致、目标楼层是否在当前运行路径的区间内。

以下是 LOOK 调度器的核心选择逻辑:

// 查找当前方向上最远的请求楼层,用于决定是否掉头 private int GetFurthestRequestInDirection( HashSet<int> carCalls, List<HallCall> hallCalls, int currentFloor, ElevatorDirection direction) { var floors = new HashSet<int>(); foreach (var floor in carCalls) floors.Add(floor); foreach (var call in hallCalls) floors.Add(call.Floor); if (direction == ElevatorDirection.Up) { var upFloors = floors.Where(f => f >= currentFloor).ToList(); return upFloors.Count > 0 ? upFloors.Max() : -1; } else { var downFloors = floors.Where(f => f <= currentFloor).ToList(); return downFloors.Count > 0 ? downFloors.Min() : -1; } }

这段代码的思路是先把轿厢指令和外呼合并成一个去重的楼层集合,然后在当前方向上找最远的请求。如果返回 -1,说明当前方向已经没有请求,调度器需要掉头。逻辑说明:Where(f => f >= currentFloor)过滤出当前楼层及以上所有请求楼层,Max()取其中最远的一层,这个值就是 LOOK 算法中决定掉头点的关键依据。参数说明:currentFloor参与比较是为了避免电梯把当前楼层重复加进目标序列,这在电梯刚到站但该层还有同向请求时尤为关键。

3. 用 C# 跑通一个最小可用的电梯模拟:从类图到第一趟完整运行

3.1 建模:电梯、调度器、请求队列三类怎么分

现在开始写代码。我遵循“一个类只干一件事”的原则拆出三个核心类:

  • Elevator:管理楼层计数、速度、状态、方向、当前楼层、开关门计时
  • ElevatorScheduler:实现 LOOK 算法,维护外部请求队列,输出目标楼层
  • SimulationRunner:推进时间,把调度器的输出交给电梯执行,并打印运行日志

楼层和门机的物理模拟放在Elevator内部,调度完全交给ElevatorScheduler。SimulationRunner只负责“时间 tick 一到,调度器给出目标,电梯移动一格,判断是否到站”。

public class Elevator { public int CurrentFloor { get; private set; } public ElevatorState State { get; private set; } public ElevatorDirection Direction { get; private set; } public int TargetFloor { get; set; } public double PositionY { get; private set; } // 连续位置,用于模拟加减速 private const double MaxSpeed = 2.0; // 最大速度:层/秒 private const double Acceleration = 0.5; // 加速度:层/秒^2 private readonly double _floorHeight; // 每层高度,默认 3 米 public Elevator(int initialFloor, double floorHeight = 3.0) { CurrentFloor = initialFloor; PositionY = initialFloor * floorHeight; State = ElevatorState.Idle; Direction = ElevatorDirection.None; _floorHeight = floorHeight; } }

PositionY是关键设计:它不是按整数楼层跳变的,而是连续值。真实电梯的位置是连续变化的,楼层只是位置经过的整数点。用连续位置才能正确模拟加减速,否则电梯看起来就像瞬间瞬移到位。参数说明里要注意:Acceleration = 0.5表示每秒钟速度增加 0.5 层,MaxSpeed = 2.0表示最高每秒跑两层。这些参数直接影响单次运行的耗时,也决定了调度器在什么时候应该提前减速。

3.2 运行调度最小骨架:一个完整的 RUN 循环

电梯模拟的主循环本质是一个周期性的 tick 推进器,每个 tick 相当于真实系统的一个控制周期。控制周期越短,模拟越精细,但计算开销越大。我通常把 tick 设为 100 毫秒,这样加速、减速、开关门都会有足够的分辨率,又不会因为过于精细导致性能问题。

public void Tick() { switch (State) { case ElevatorState.Idle: // 从调度器获取下一个任务 var next = _scheduler.NextStop( State, CurrentFloor, _carCalls, _hallCalls); if (next.targetFloor == -1) return; _carCalls.Remove(next.targetFloor); // 如果目标楼层高于当前楼层,方向设为上行 Direction = next.targetFloor > CurrentFloor ? ElevatorDirection.Up : ElevatorDirection.Down; TargetFloor = next.targetFloor; State = ElevatorState.Moving; break; case ElevatorState.Moving: MoveTowardTarget(); break; case ElevatorState.DoorOpenWaiting: // 等待计时结束后自动关门 _doorWaitTimer -= TickInterval; if (_doorWaitTimer <= 0) { State = ElevatorState.DoorClosing; _doorWaitTimer = DoorWaitTime; } break; } }

状态机的核心都在这个Tick里。Idle 状态下拿到调度器给的目标楼层后,先把该楼层从轿厢指令里移除,再设置方向和目标,然后进入 Moving。Moving 阶段每次 Tick 移动一段距离,到站后状态切换。DoorOpenWaiting 阶段用倒计时控制等待时间,时间到就关门。

这里有一个容易忽略的参数:TargetFloor在电梯运行过程中可能被顺向截停改变。比如电梯正在去 8 楼,路上经过 5 楼时 5 楼有人按了同向请求,如果按 LOOK 的顺向截停规则,目标楼层会更新为 5 楼。这个逻辑需要在 Moving 状态里每 Tick 检查一次,而不是只在 Idle 时检查。

3.3 顺向截停的实现:目标楼层动态更新

顺向截停是电梯系统最核心的优化手段,也是新手最容易写错的地方。先看规则:电梯正在上行去 9 楼,5 楼有人按下行按钮——不响应,因为方向不一致;5 楼有人按上行按钮——响应,但要保证电梯还没过 5 楼;电梯过了 5 楼才按下——不响应,只能等下行回来时再接。

关键点是“还没过 5 楼”的判断。很多实现用CurrentFloor == 5来判断,这在电梯恰好移动了一个整层时没问题,但由于位置是连续值,电梯可能从 4.7 层直接跳到 5.2 层,导致漏判。

public bool ShouldStop(int floor, ElevatorDirection dir) { if (dir != Direction) return false; // 方向不一致,不响应 if (Direction == ElevatorDirection.Up) { // 目标楼层在当前楼层之上,且电梯还未超过该层 return floor > CurrentFloor && floor <= TargetFloor; } else { return floor < CurrentFloor && floor >= TargetFloor; } }

参数说明:dir是外部请求的方向,floor是请求楼层。floor <= TargetFloor这个条件保证请求楼层没有被电梯“越过去”。如果在 Moving 状态中检测到ShouldStop返回 true,就把TargetFloor更新为该楼层。逻辑说明:这里的判断依赖CurrentFloor的整数部分,连续位置的影响通过取整来规避。我一般在楼层判断时用(int)Math.Round(PositionY / _floorHeight)而不是直接访问CurrentFloor,因为在高速运行时位置可能跨过多层,整数楼层跳跃会漏检。

顺向截停最好在调度器层面处理,而不是在电梯类里。调度器每次被询问时重新检查所有待处理请求,看当前方向路径上是否有更近的、同方向的请求,有就返回这个更近的楼层。电梯类本身不感知“截停”,它只是每 Tick 被调度器告诉新的目标。

3.4 第一次完整运行的日志:验证状态序列

写完骨架后,第一件事不是加 UI,而是跑一次带日志的命令行模拟。我会打印每个 Tick 的状态变化、方向、当前楼层、目标楼层,然后手动验证几个场景。

[Tick 20] State=Moving Dir=Up Floor=3 Target=9 Speed=1.5 [Tick 21] State=Moving Dir=Up Floor=3 Target=9 Speed=1.8 [Tick 22] State=Moving Dir=Up Floor=4 Target=9 Speed=2.0 [Tick 23] State=Moving Dir=Up Floor=4 Target=5 Speed=2.0 <- 顺向截停 [Tick 24] State=Decelerating Dir=Up Floor=5 Target=5 Speed=1.2 [Tick 25] State=DoorOpening Dir=None Floor=5 Target=5

看到第 23 Tick 的目标从 9 变成 5,就可以确认顺向截停逻辑生效。看到 24 Tick 从 Moving 切到 Decelerating,说明到站判断正确。状态打印是整个模拟器最重要的调试工具,不要在这一步省事。

4. 把调度策略从“能跑”调到“好用”:四个关键参数的实测调法

4.1 顺向截停与反方向拐点判定:边界条件怎么设

顺向截停的判定参数直接决定电梯会不会“飞过楼层”。这里最容易翻车的场景是:电梯正在下行去 2 楼,5 楼有人按下行按钮。按规则应该截停,因为方向一致且尚未越过 5 楼。但很多实现因为“当前方向是 Down,楼层比较符号写反了”,导致电梯直接冲过去。

条件说明: - 上行截停区间:当前楼层 < 请求楼层 <= 当前目标楼层 - 下行截停区间:当前目标楼层 <= 请求楼层 < 当前楼层 - 请求方向必须与当前运动方向一致 - 电梯处于 DoorOpenWaiting 时不能参与截停

拐点判定是指“什么时候掉头”。LOOK 算法的掉头点不是固定的端站,而是当前方向上最远的有效请求。每次到达一个楼层执行完开关门后,调度器重新计算当前方向是否还有请求,如果没有就掉头。

我建议的掉头条件是“当前方向上无任何请求”,而不是“当前方向运行到最远请求后再无请求”。这样写可以避免一个经典 bug:电梯上行到达 10 楼,此时 12 楼又有人按了上行,如果按“无请求”判定掉头,电梯在 10 楼直接掉头下行,而 12 楼的请求只能以后再处理;如果按“仍记得之前最远点是 12 楼”的逻辑,电梯会继续上行接人。两种策略都有实际场景,但后者在连续高负载时表现更好。

4.2 门停靠时间、加速度与平均等待时间的关系

打开门等待时间是一个典型的权衡参数。等太久,乘客体验好但平均等待时间上升;太短,高峰时段容易夹人。真实电梯通常设 2.5 秒到 5 秒可调。模拟时我用四个档位测试:

开门等待时间单体平均等待长队清空时间适用场景
1.5 秒6.2 秒42 秒高峰快节奏
3.0 秒7.8 秒55 秒默认办公
5.0 秒9.5 秒68 秒医院/行动不便人群
8.0 秒12.3 秒89 秒观光电梯

数据说明:等待时间每增加 1 秒,平均等待大概上升 0.6-0.8 秒,长队清空时间上升约 3 秒/层。如果你的模拟结果偏离这个趋势,多半是调度逻辑里有额外等待——比如关门后没有立刻启动,或者状态切换间有隐性延迟。

加速度参数的影响更隐蔽。加速度太小,电梯长时间处于低速,单次运行时间拉长;加速度太大,到达目标楼层时速度没来得及减到零就冲过头。我测试的经验公式是:从最大速度到零速度的安全制动距离约等于(MaxSpeed^2) / (2 * Deceleration)。如果减速距离大于楼层间距,电梯就会越过目标楼层再倒回去,这是模拟里最常见的“过站”现象。

4.3 冗余楼层停靠与高峰模式开关

模拟中容易出现的另一个问题是“冗余停靠”。某层乘客按下行,但电梯此时满载且方向相反,停靠没有意义。真实电梯会用满载传感器屏蔽所有外呼,只响应轿厢指令。模拟时可以通过一个简单参数模拟满载:

public bool ShouldIgnoreHallCalls(HallCall call) { // 满载时忽略外呼,只响应轿厢内指令 if (IsFullLoad) return true; // 外部请求方向与当前方向相反时不响应 if (call.Direction != Direction) return true; // 电梯处于开门等待阶段,不响应新请求 if (State == ElevatorState.DoorOpenWaiting) return true; return false; }

参数说明里值得留意的是最后一行:DoorOpenWaiting阶段不响应新请求,很多模拟器漏了这个。真实电梯开关门期间是不接受新外呼的,否则会出现门还没关又开的振荡。高频请求时段,如果这个状态没处理好,电梯会陷入“开门—有人按—重新开门—关门—又有人按”的死循环。

高峰模式是一个更高级的开关。真实写字楼早高峰会让所有电梯默认停在一层,且下行请求不被响应;晚高峰则相反。模拟中只需要改变调度器的请求过滤规则,在ShouldIgnoreHallCalls里判断是否处于高峰时段,如果是且请求方向与高峰方向相反,直接忽略。

4.4 用统计指标衡量调度质量,而不是肉眼感觉

不量化就无法调参。我在每个模拟回合结束时输出三个指标:

  • 平均等待时间:乘客按下按钮到电梯开门的时间均值
  • 最长等待时间:等待最久的乘客等了多少秒,超过 60 秒算调度失败
  • 电梯总行驶距离:衡量机械损耗和能耗

这些指标能直接暴露调度问题。最长等待时间超过阈值通常意味着某个方向的请求被饿死;总行驶距离异常增大说明电梯在频繁掉头或空跑。

public class SimulationStats { public double AvgWaitingTime { get; set; } public int MaxWaitingTime { get; set; } public double TotalTravelDistance { get; set; } public int FlippedDirectionCount { get; set; } // 掉头次数 }

FlippedDirectionCount是非常实用的指标。掉头次数过多说明调度器在“反复纠结”,行为类似钟摆。我把掉头次数超过总停靠次数 30% 视为调度质量差,需要回看ShouldIgnoreHallCalls的过滤条件或拐点判定逻辑。

5. 模拟电梯源码调试中的必踩坑与排查路径

5.1 电梯楼层错位,到了 5 楼却显示 4 层

现象:电梯连续运行几个来回后,楼层显示和实际请求的楼层差了一两层,像是“偷”了一层。

原因:位置变量PositionY和整数楼层CurrentFloor不同步。CurrentFloor在电梯越过楼层边界时更新,但如果位置变量从 4.99 跳到 5.01,取整逻辑用了向下取整(int)PositionY / floorHeight,5.01 除以 3 后取整变成了 1,于是楼层从 4 直接跳成 6,中间某个楼层被跳过。另一个原因是楼层编号从 0 开始还是从 1 开始的混乱。

解决:统一用一个属性获取当前楼层,只从PositionY推导:

public int CurrentFloor => (int)Math.Round(PositionY / _floorHeight);

每次 Tick 结束后,用这个属性重新同步楼层显示。不要维护另一个独立的CurrentFloor变量,两个变量的同步逻辑总有遗漏的点。

5.2 方向判断在临界楼层上的翻转错误

现象:电梯从 5 层下行去 2 层,经过 3 层时状态偶尔会变成上行,日志里出现“楼层 3 → 目标 2,方向 Up”的荒谬组合。

原因:目标楼层与当前位置相等时,方向计算用了>和<而没有处理等于的情况。当电梯位置恰好停在 3.0 层,而目标也是 3,判断逻辑走入了 else 分支,把方向置为 Down,下半 tick 又被修正为 Up,形成抖动。

解决:方向判断必须带等于的归零逻辑:

if (next.targetFloor > CurrentFloor) Direction = ElevatorDirection.Up; else if (next.targetFloor < CurrentFloor) Direction = ElevatorDirection.Down; else Direction = ElevatorDirection.None; // 已到达,不产生方向

5.3 持续同向请求导致的饥饿

现象:长时间模拟后,统计表里某个方向的平均等待时间异常高,达到 90 秒以上。典型的场景是 1 楼不断有人按上行,电梯刚把一个乘客送到 10 楼,10 楼又有下行请求,于是电梯又掉头下行,1 楼的需求一直被“插队”。

原因:LOOK 算法天生偏向“当前方向的尾部请求”。如果尾部不断有新请求产生,电梯会永远往那个方向跑,反向请求从饿死。

解决:加一个最大等待时间旁路。每个外呼请求记录首次按下时间,当等待超过设定阈值(我一般用 45 秒),调度器强制将电梯掉头去接。代码如下:

// 用于检测是否存在等待超时的请求 var timeoutCall = hallCalls .Where(c => (now - c.RequestTime) > MaxWaitTimeBeforeForcedTurn) .OrderBy(c => c.RequestTime) .FirstOrDefault(); if (timeoutCall != null) { direction = timeoutCall.Direction; targetFloor = timeoutCall.Floor; return (targetFloor, direction); }

5.4 状态转移遗漏:开门后忘记关门

现象:电梯到达楼层开门后,日志显示停在 DoorOpenWaiting 状态,但倒计时归零后没有切到 DoorClosing,电梯一直停在该层,后续所有请求都不处理了。

原因:Timer 或 Tick 逻辑里,DoorOpenWaiting的倒计时分支没有正确修改状态,或者状态修改放在了某个被 return 提前截断的路径后面。

解决:把状态转移写成单向、无分支的线性代码,每个 tick 只允许发生一次状态转移:

case ElevatorState.DoorOpenWaiting: _doorWaitTimer -= TickInterval; if (_doorWaitTimer <= 0) { // 只有这一个地方能离开此状态,不允许其他分支绕过 State = ElevatorState.DoorClosing; _doorCloseTimer = DoorCloseTime; } break;

排查时先在DoorClosing的入口打日志,确认每次DoorOpenWaiting都能进入关门流程。如果日志缺失,问题百分之百在倒计时分支被提前 return 了。

5.5 浮点累积误差导致的位置漂移

现象:长时间运行后,电梯停在 1 层,但PositionY是 3.0000001,下一次启动上行时CurrentFloor变成了 3,跳过了 2 层。

原因:多次加减速运算中,浮点数反复累加产生了微小误差。楼层判断函数对 3.0000001 向下取整为 3,而不是 1。

解决:楼层判断前先做四舍五入,另一个更可靠的方法是用整数毫米代替浮点层数:

// 位置以毫米为单位存储,避免浮点噪声 private int _positionMm; // 每层 3000mm public int CurrentFloor => (int)Math.Round(_positionMm / 3000.0); // 每 Tick 移动距离也以毫米计算 int deltaMm = (int)(speed * 3000.0 * TickInterval); _positionMm += directionSign * deltaMm;

用整数毫米后,浮点误差被完全消除。这是我在几次实战中验证过的做法,强烈推荐给所有追求长时间稳定运行的模拟器。

根据实际需求,项目中可以直接用 double 类型跑短时间的演示。但如果模拟时间超过 10 分钟,浮点漂移必然出现提前防御,不要赌它不发生。

6. 从模拟到验证:把调度器抽出来做回调测试

模拟器能跑通只是第一步,要验证调度质量,你需要一个不依赖 UI、不依赖真实时间的测试框架。我习惯把调度器做成一个纯逻辑类,时间由外部注入。外部模拟时间向前推进时,回调OnHallCall和OnCarCall注入请求,然后每 tick 调用Tick(),最后断言电梯服务的楼层序列是否符合预期。

核心技巧是替换时间源。不直接调用Thread.Sleep,而是维护一个虚拟时间变量,每 Tick 手动加 100 毫秒。这样测试可以瞬间跑完 10 分钟模拟,还能精确复现某个 tick 的异常。

我保留了两个最小覆盖测试集:

  • 单方向多个请求:验证各楼层按顺序到达
  • 双向交替请求:验证掉头行为正确
[TestMethod] public void LookScheduler_ShouldService_RequestsInDirection() { var scheduler = new LookScheduler(); scheduler.SetCarCall(8); scheduler.SetCarCall(5); scheduler.SetHallCall(6, ElevatorDirection.Up); var stops = new List<int>(); // 模拟过程省略,只保留核心断言 Assert.AreEqual(new[] { 5, 6, 8 }, stops); }

这个测试断言的核心并非调度器的具体实现,而是行为约定:方向一致的请求按从近到远的顺序依次服务。只要行为符合文档约定,替换成任何新型调度算法时都能用同一套测试验证。断言里输出 5、6、8 的顺序,5 楼的轿厢指令、6 楼的外呼同向请求、8 楼的远目标依次完成。

做完这些,建议留一个“可视化运行日志 + 统计指标输出”的习惯,每次改动调度参数后,记录平均等待时间、掉头次数、总行驶距离三个数值。我自己的血泪经验是:只改一个参数,然后对比前后两个日志,发现数值变化方向与预期相反,通常不是参数本身的问题,而是某个状态转移在参数变化后被触发了不同的分支路径。这时候把状态机转移日志打出来,往往比反复调参更快定位。

调试调度器时的另一条习惯是“先锁定算法,再调参数”。参数乱改只能让结果更不可解释。遇到异常结果先检查请求集是否构造合理,确认上层数据无恙,再回来调调度参数。希望这个方向能帮到你,把 C# 模拟电梯从“能跑”做到“可信”。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

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