ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

C#模拟电梯控制源码:从状态机到调度算法全解析

C#模拟电梯控制源码:从状态机到调度算法全解析

简介:C# 模拟电梯控制是一套基于 Windows Forms 的完整 Visual Studio 工程,面向初学 C# 的桌面应用开发者,也适合准备课程设计或希望理解对象建模、事件驱动与多线程调度等机制的读者。源码通过电梯、楼层、乘客对象抽象还原核心逻辑,借助委托与事件响应楼层呼叫,以状态机管理开关门与上下行状态,并结合线程同步与调度算法模拟电梯运行。压缩包共 27 个文件,以 cs 源码为主体,配合 sln/csproj 工程配置、resx/resources 界面资源、ico/bmp 图标素材、pdb 调试符号及 exe 可执行程序,整体约 246KB,可直接在 Visual Studio 中打开运行。工程内含入口与界面代码,启动后通过 GUI 实时观察电梯响应与停靠过程,对照源码可层层拆解设计模式、异常处理和测试思路。已有 282 人学习使用,适合借助完整可运行示例快速掌握 C# 控制类项目的编写与调试技巧。

1. 先想明白:这源码到底在模拟什么

如果你以为“C# 模拟电梯控制源码”就是把电梯在界面上上下移动,那大概率会翻车。真正让它值得做的不是动画,而是背后那套调度逻辑:外呼怎么分配、内呼怎么排序、电梯什么时候换向、多台电梯会不会抢同一层楼。很多拿着别人源码来改的开发者,最后都卡在“电梯动起来了,但乱跑”这个环节上。

这个方向适合三类人:一是做课程设计或毕设,需要一套能演示、能答辩的完整系统;二是想学 C# 异步编程和状态机设计的开发者,电梯恰好是两者结合得最好的练手场景;三是准备面试作品,想展示自己写过“带并发控制的项目”。这篇内容把 C# 模拟电梯控制源码从模型拆分、代码落地到踩坑排查完整梳理一遍,你可以边看边照着你手上的版本来改。

2. 把电梯问题拆成三个模型:楼层、按钮、状态机

2.1 电梯控制到底在控制什么:先看运行闭环

一台电梯在真实世界里同时处理两类请求:一类是轿厢内的楼层按钮,乘客已经进电梯,目标明确;另一类是每个楼层上下行按钮,乘客还没进电梯,只知道要去“上”或“下”。这两类请求汇聚到电梯控制器里,控制器再输出两个结果:当前位置和当前方向。

整个控制闭环拆开看就三层:运动控制负责“电梯走不走、走到哪停”,逻辑控制负责“方向怎么定、目标怎么排序”,调度控制负责“多台电梯谁来接这个外呼”。多数模拟源码翻车,都是在逻辑控制这层偷了懒——把所有按钮塞进一个集合,然后每次只挑最近的楼层,完全不管方向一致性。结果就是电梯在 5 楼和 6 楼之间来回折腾,乘客按钮按了个寂寞。

所以写模拟电梯之前,先把这三个问题分清楚。运动控制推进展,逻辑控制定目标,调度控制做分配。三个模块之间只通过电梯对象的状态字段通信,别互相直接访问按钮队列。

2.2 状态机设计:Idle / Moving / DoorOpen 的流转条件

电梯对象最适合用一个状态机来描述,这也是绝大多数模拟源码的通用做法。状态枚举不需要太多,五个就够用:Idle、Moving、DoorOpening、DoorOpen、DoorClosing。这里不把 DoorClosed 单独列出来,因为它可以合并进 Idle 或 Moving 之间,否则状态太多反而难维护。

public enum ElevatorStatus { Idle, // 停着,门已关,无目标 Moving, // 正在运行 DoorOpening, // 开门动画/计时 DoorOpen, // 门完全打开,等待乘客 DoorClosing // 关门动画/计时 } public enum Direction { Up = 1, Down = -1, None = 0 }

状态流转的关键规则用文字描述更清楚:Idle 状态下收到任意目标就变 Moving;Moving 状态下到达某个目标楼层就变 DoorOpening;DoorOpening 计时结束变 DoorOpen;DoorOpen 计时结束变 DoorClosing;DoorClosing 结束,如果还有目标就继续 Moving,否则回 Idle。这套流转在任何一门语言里都成立,但 C# 里最舒服的做法是用枚举做 switch,而不是搞一堆布尔标志位。

布尔标志位的典型反例是isMoving、doorOpen、isUp三个字段互相组合,到后面你会发现“门开着但还在 Moving”这种非法状态根本拦不住。状态机的核心价值就是让非法状态在编译层面就不存在。

2.3 用一张参数表定死行为:速度、开门延时、超时

模拟电梯不是越快越好。很多源码把移动延迟写成 50 毫秒一层,电梯像子弹一样飞,调度问题根本看不出来。我的建议是把参数统一收敛到一个配置类里,不要在代码各处散落魔法数字。

参数推荐初值说明
楼层移动耗时400ms/层太快看不出调度冲突,太慢演示煎熬
开门耗时800ms模拟门完全打开的时间
开门等待2500ms模拟乘客进出,实际是调度节奏的节拍器
关门耗时800ms关门后到启动的缓冲
帧刷新周期100msUI 状态刷新频率,和移动计时分开
目标楼层误差0.1 层到达判断的容差,防止浮点楼层永远对不齐

楼层数建议用常量而不是写死在循环里,比如TotalFloors = 12。楼层数设定会影响调度测试的价值:5 层以下任何算法都显得“合理”,12 层以上才能暴露方向排序和换向的缺陷。

3. 在 C# 里让电梯跑起来:项目结构与异步主循环

3.1 一个最小可运行的项目怎么组织

拿到“C# 模拟电梯控制源码”第一件事不是读代码,而是看项目结构。一个结构正常的模拟电梯项目,文件夹长这样:

ElevatorSim/ ├─ ElevatorSim.sln ├─ ElevatorSim/ │ ├─ Models/ │ │ ├─ Elevator.cs │ │ ├─ Building.cs │ │ ├─ FloorPanel.cs │ │ └─ Enums.cs │ ├─ Core/ │ │ ├─ ElevatorController.cs │ │ └─ DispatchStrategy.cs │ ├─ UI/ │ │ ├─ MainForm.cs │ │ └─ ElevatorControl.cs │ └─ Program.cs

Models 放电梯和楼栋的数据结构,Core 放调度算法,UI 只负责渲染和接收按钮点击。这个分离做不好,后面每一步都是坑。常见的问题是 UI 代码里直接改电梯目标集合,调度逻辑散在按钮事件里,结果就是一旦增加多电梯支持,代码彻底重构。

拿到源码先看 Models 和 Core 是否独立。如果所有逻辑都堆在 MainForm.cs 里,那这个源码的学习价值就打折了,建议自己按这个结构重写一遍。

3.2 电梯实体类:楼层、方向、目标集合的封装

电梯实体类是所有逻辑的中枢,设计上要做到“调度器只能修改目标集合,不能直接拨动状态”。内呼按钮按下后,其实是往目标集合里插入楼层,同时电梯自己会在合适的时机取下一个目标。

public sealed class Elevator { public int Id { get; } public int CurrentFloor { get; private set; } public Direction Direction { get; private set; } public ElevatorStatus Status { get; private set; } private readonly SortedSet<int> _targets = new(); private readonly object _lock = new(); public void AddTarget(int floor) { lock (_lock) { _targets.Add(floor); } } public bool HasTarget => _targets.Count > 0; public int NextTarget => PickNextFloor(); }

这里用SortedSet<int>保存目标楼层,原因有二:一是天然去重,同一层内呼按一百次只会留一条;二是默认有序,调度器只需要做方向过滤,不需要自己排序。_lock对象的作用后面避坑章节会详细讲,这里的核心原则是:按钮事件线程、调度线程、UI 定时器线程都可能同时访问目标集合,不加锁后面必然出并发问题。

方向字段不要用 bool 表示,Direction.Up / Down / None三态才是真实场景,因为电梯空闲时就是“无方向”状态。很多源码用isGoingUp布尔值,然后到处判断“如果 isGoingUp 为 true 且当前在上行”,读得人脑壳疼。

3.3 异步移动循环:为什么用 Timer 而不是 while(true)+Thread.Sleep

模拟电梯的主循环有两种主流写法:System.Windows.Forms.Timer驱动,还是async/await + Task.Delay。我实测下来的结论是:如果你做的是 WinForms 或 WPF 界面,用 Timer 最简单;如果做控制台版本的纯逻辑模拟,用异步循环更干净。

WinForms 里用 Timer 的最大好处是它在 UI 线程触发 Tick,改界面控件不需要跨越线程。移动逻辑写成每 Tick 检查一次当前状态:

private async void MoveTimer_Tick(object sender, EventArgs e) { if (elevator.Status != ElevatorStatus.Moving) return; int moveDelayMs = 400; await Task.Delay(moveDelayMs); elevator.MoveOneFloor(); RefreshElevatorUI(); if (elevator.IsAtTarget()) { BeginDoorSequence(); } }

这个 tick 函数里体现了两个关键参数:moveDelayMs代表每层楼的移动耗时,BeginDoorSequence()负责把状态机推向开门流程。注意这里的async void用在事件处理器里是安全的,但不要在任何同步方法里调用它,否则异常会直接炸线程。

控制台版本更纯粹,一个 while 循环一直跑,直到电梯没目标才休眠,但要注意必须让出控制权,不能死循环空转。无论哪种写法,核心思想都一样:用时间驱动的状态推进,而不是“按下按钮立即变楼层”。电梯移动是异步过程,源码里如果看到Thread.Sleep(5000)直接阻塞 UI,那一定不是合格做法。

4. 调度算法落地:优先方向、顺路捎带和换向

4.1 单电梯调度:SCAN 思路和它的前提

多数单电梯源码用的都是 SCAN 算法的变体:电梯保持当前方向,把该方向上所有目标楼层走完,再反方向接剩余请求。这个算法也叫“电梯算法”,在真实电梯里广泛使用,因为它能兼顾效率和公平。

实现 SCAN 有个前提:目标集合必须分成上行和下行两批。按下行外呼的乘客和按上行外呼的乘客,他们的等待逻辑完全不同——一个在 8 楼按“下”,说明他的目标楼层要低于 8 楼;一个在 3 楼按“上”,目标要高于 3 楼。正确做法是:方向保持期间,只处理“去往当前方向”的请求。

具体说,电梯在 5 楼向上运行,此时目标集合里有 7、10 和 2 楼。SCAN 会先处理 7 和 10,到了 10 楼没人了才换向下行,然后停 2 楼。直接按“楼层差值排序”的做法就是“最近优先”,会让电梯在上下两个方向反复横跳,乘客体验极差。

4.2 多电梯怎么分配外呼:先到先得还是就近

单电梯的逻辑搞清楚后,多电梯分配的难度不在电梯本身,而在于“谁来响应某个外呼”。常见做法有两种:先到先得式,谁先到达那个楼层谁接;就近分配式,调度器提前判断哪台电梯最合适,直接指派。

先到先得实现简单,但会出现两台电梯同时开门抢人——乘客上哪台都不对。我更推荐做指派式:外呼按钮按下时,遍历所有电梯,计算每台的优先级得分,得分最高者负责此次外呼,其他电梯保持不动。优先级得分一般看两个维度:

  • 方向匹配:电梯当前方向与外呼需求方向一致,加分;
  • 距离:电梯当前楼层与外呼楼层差值,越小越好;
  • 负载状态:如果电梯已经有很多目标,减分,避免忙的忙死闲的闲死。

这三个因素按权重相加,权重参数应该能在界面上调,这样答辩时有演示材料可讲。

4.3 调度核心代码:PickNextFloor 的实现

调度器最核心的方法是PickNextFloor(),它从目标集合里挑出下一个停靠楼层。这里的逻辑能否写对,直接决定了源码的可用性。

private int PickNextFloor() { lock (_lock) { if (_targets.Count == 0) return -1; var available = new List<int>(); if (Direction != Direction.None) { // 按当前方向筛选:上行时只找更高的楼层,下行时只找更低的 foreach (var floor in _targets) { if (Direction == Direction.Up && floor > CurrentFloor) available.Add(floor); if (Direction == Direction.Down && floor < CurrentFloor) available.Add(floor); } } if (available.Count == 0) { // 当前方向没有目标,则允许反向 available.AddRange(_targets); } if (available.Count == 0) return -1; // 同方向取最近一站 return Direction == Direction.Up || Direction == Direction.None ? available.Min() : available.Max(); } }

这段代码的关键在三个地方:一是方向过滤,上行只挑高于当前楼层的目标;二是反向兜底,同方向没目标才允许反向;三是取最近站,上行取最小楼层,下行取最大楼层。参数方面其实没有显式调参,但你可以把“方向偏好”理解成一个隐含权重——只要同方向还有目标,反向楼层永远不被选择,这正是 SCAN 的行为特征。

调用时机也很重要:PickNextFloor()不应该在按钮点击时调用,而应该在电梯完成一次停靠、即将进入移动状态的瞬间调用。这样每次取到的都是“当前位置+当前方向”下的最优目标,不会出现按下按钮后电梯目标集合更新不及时的情况。

5. 模拟电梯源码最容易翻车的 5 个坑

5.1 坑一:两台电梯同时响应同一个外呼

现象:乘客在 8 楼按了下行按钮,两台电梯几乎同时开门到达 8 楼,乘客不知所措,调度记录里出现两台电梯同层停靠的事件。 原因:外呼按钮没有绑定“分配状态”。按钮被按下后,调度器广播给所有电梯,每个电梯都把这个楼层当成自己的目标。 解决:外呼按钮需要增加一个AssignedElevatorId字段。按下后提交给调度器,调度器选出最优电梯后写入该字段,其他电梯检查到该外呼已被分配就直接忽略。

public class FloorPanel { public int Floor { get; init; } public bool UpRequested { get; private set; } public bool DownRequested { get; private set; } public int? AssignedElevatorId { get; private set; } public void AssignTo(int elevatorId) { AssignedElevatorId = elevatorId; } }

这里的关键设计是:外呼和“已分配的电梯”绑定,而不是和“按钮状态”绑定。哪怕电梯 A 到达前按钮已经被电梯 B 分配,A 也只会路过不会停靠。分配后要记得在电梯开门的同时清除外呼状态的 “指派人”,否则下一轮外呼会有残留。

5.2 坑二:UI 卡死或按钮按下不响应

现象:电梯跑着跑着窗口像休眠了一样,拖动窗口极其卡顿,有时按钮点了没反应。这是初学异步最容易遇到的“玄学”问题。 原因:代码里某处用了Thread.Sleep模拟开门延时或移动延时,而那段代码跑在 UI 线程里。UI 线程被阻塞,整个窗口自然假死。 解决:把一切耗时操作替换为异步等待。凡是“等一会儿再继续”的业务逻辑,统一用await Task.Delay(...),不要用Thread.Sleep。这两者的区别在于 Task.Delay 会释放当前线程继续响应界面,而 Thread.Sleep 真的把线程挂起。

5.3 坑三:开门状态把整个调度卡住

现象:电梯到了某一层后,门一直开着不关,其他所有目标全部排队等待,调度日志停在 “DoorOpen”。 原因:状态机里DoorOpen没有任何超时退出条件。进入开门状态后直接卡死,没人去推动它进入DoorClosing。 解决:DoorOpen 状态必须引入一个计时器,通常可以用DateTime doorOpenSince,在 PickNextFloor 之前检查一次:

if (elevator.Status == ElevatorStatus.DoorOpen) { if ((DateTime.Now - doorOpenSince).TotalMilliseconds < doorOpenWaitMs) return; // 还在等乘客,不做任何调度 elevator.StartClosingDoor(); }

额外注意:不要在 DoorOpen 状态下还接受新的内呼,因为门开着时乘客可以按按钮,但这只是把楼层加入目标集合,电梯不应该立刻关门出发。等关门完成后,调度器再取下一个目标。

5.4 坑四:遍历目标集合时抛 InvalidOperationException

现象:电梯运行中,偶尔在拾取下一个目标时报System.InvalidOperationException: Collection was modified。 原因:一个线程正在 foreach 遍历目标集合,另一个线程同时往里添加了新楼层。这是经典的并发集合修改问题。 解决:所有对目标集合的读写操作都放在同一个锁对象里,foreach 时也要在锁内完成遍历。上面 PickNextFloor 的代码已经演示了lock (_lock),但要注意别只锁写不锁读。

血泪经验就是:C# 的SortedSet不是线程安全的集合,任何遍历都可能在修改时爆炸。如果不想每个循环都加锁,也可以换成ConcurrentDictionary<int, byte>这类并发容器,但语义没有 SortedSet 直观,新手不建议。

5.5 坑五:外呼在高频运行时丢按钮

现象:连点几次同一个楼层的外呼按钮,调度日志只显示第一次;乘客明明按了两次,内呼集合没有更新。 原因:按钮点击事件直接操作 UI 状态,而 UI 状态没有同步到控制器。界面亮了、亮了又灭,内部控制器的目标集合没有收到指令。 解决:按钮点击事件必须只做一件事——调用调度器接口提交请求。所有请求直接写入电梯的目标集合,并且用时间戳记录请求顺序:

private void FloorButton_Click(object sender, EventArgs e) { int floor = (int)((Button)sender).Tag; elevator.AddTarget(floor); // 只通过接口操作,UI 只做反馈 UpdateButtonLights(); }

记住,界面状态是“显示层”,控制器集合才是“事实层”。模拟源码里这种 UI 按钮反馈和实际数据不同步的错误相当隐蔽,因为单线程运行时根本看不出问题,只有加了异步移动后才会暴露。

6. 再进一步:群控、换乘和调度日志怎么验证

单电梯跑通后,真正的加分项是多电梯群控。把外呼按钮从“直接改某台电梯的目标”改成“提交到调度器统一分配”,是这套源码里最值得扩展的架构升级。建议把调度器里的AssignBestElevator(int floor, Direction direction)单独提出来,用独立的策略类封装。

验证调度效果别靠肉眼看动画,接一个 CSV 日志输出,记录电梯号、请求楼层、分配时间、到达时间、托盘状态。字段少、比对直观:

电梯ID, 请求楼层, 请求方向, 分配耗时ms, 到达耗时ms, 换向次数 1, 8, Down, 12, 2300, 2

对比不同分配策略的同一组请求序列,比任何答辩截图都有说服力。换乘模型不建议新手直接做——那是在群控之上再叠加乘客路径规划,复杂度翻倍,一个外呼对应两台以上的电梯,状态机和日志都得重构。

我自己的习惯是先跑 30% 随机外呼、10% 高峰外呼的混合压力测试,再把参数表中的开门等待从 2500ms 调到 500ms,观察电梯是否进入“疲于奔命”状态。这种调参实验做完,你对调度特征的理解会比看十份源码都深。希望帮到你。

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

RELATED READING

延伸阅读

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