news 2026/10/19 17:04:17

C# 十字路口红绿灯模拟:有限状态机与多线程并发实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
C# 十字路口红绿灯模拟:有限状态机与多线程并发实战

简介:这份C#十字路口红绿灯模拟资源面向具备C#基础、希望深入理解多线程与事件驱动编程的开发者,通过一个完整的交通信号灯项目,帮助解决状态管理、线程同步与UI联动等典型问题。压缩包共29个文件,约1.52MB,以cs源码、png信号灯图片、resx资源文件、exe可执行程序及sln解决方案为主,另含pdb调试符号、manifest清单与settings配置,结构完整,可直接运行或二次开发。项目围绕Timer定时切换、TrafficLightState枚举状态管理、多线程独立控制四个方向信号灯、Windows Forms界面实时刷新等核心知识点展开,并涉及Monitor、Mutex等线程同步原语与异常处理机制。代码采用面向对象封装TrafficLight类,兼顾可读性与拓展性,便于后续加入人行横道或自适应流量控制。目前已有1632人学习下载,适合作为课程设计、毕业设计或C#进阶练习的参考案例。

1. 十字路口红绿灯模拟:一个被低估的 C# 并发练手项目

很多人第一次看到「C# 十字路口红绿灯模拟」这个标题,第一反应是「这不就是个定时器加几个色块切换吗」。我最初也这么想,直到自己动手写第一版,才发现事情没那么简单——四个方向、左转直行右转、行人过街、黄灯缓冲、紧急车辆优先,这些状态一旦交叉起来,用几个Timer硬怼很快就会变成一团乱麻。这个项目真正值钱的地方,不是画几个红绿灯,而是它把「有限状态机 + 多线程同步 + 定时调度」这三件事塞进了一个足够小、又能跑起来的场景里。它适合刚学完 C# 基础语法、想找一个能看见效果的项目练手的开发者,也适合想复习lock、Monitor、Task这些并发原语的熟手。下面我按自己拆解和复现的顺序,把这份资源从头到尾讲清楚。

2. 状态机建模:把四个方向的红绿灯拆成可枚举的状态

2.1 为什么不能直接用 Timer 堆

新手最容易踩的坑,就是给每个方向的红绿灯各开一个System.Timers.Timer,到点就切颜色。单方向跑没问题,但十字路口的核心约束是「互斥」——南北直行绿灯时,东西直行必须是红灯,否则就是撞车逻辑。多个 Timer 各自为政,你没法保证它们之间的时序关系,最后只能靠一堆if-else去补,代码越写越像意大利面。

正确的做法是先定义状态,再让状态驱动灯色,而不是让定时器驱动灯色。常见做法是把一个完整的通行周期拆成若干个「相位」(Phase),每个相位里明确哪个方向亮什么灯、持续多久。这样互斥关系在定义阶段就固定下来了,运行时只需要按顺序推进相位。

2.2 用枚举定义灯色和相位

先定义最基础的灯色枚举,这是整个项目的原子单位:

// 灯色枚举:红、黄、绿三态 public enum LightColor { Red, // 红灯,禁止通行 Yellow, // 黄灯,缓冲过渡 Green // 绿灯,允许通行 } // 方向枚举:十字路口四个方向 public enum Direction { North, // 北 South, // 南 East, // 东 West // 西 }

灯色只有三种,但相位要多一些。一个典型的四相位十字路口,包含「南北直行」「南北左转」「东西直行」「东西左转」四个机动车相位,再加上行人过街相位。如果简化成两相位(南北放行、东西放行),代码会短很多,但真实感差。我一般建议先用两相位跑通,再扩展到四相位。

// 相位枚举:描述当前哪个方向组在放行 public enum Phase { NorthSouthStraight, // 南北直行放行 NorthSouthLeft, // 南北左转放行 EastWestStraight, // 东西直行放行 EastWestLeft, // 东西左转放行 AllRed // 全红,清空路口 }

这里有个参数要注意:AllRed相位不是可有可无的。真实路口在相位切换之间会插入 1~2 秒全红,目的是让已经进入路口的车辆驶出。模拟里如果省掉这一步,视觉上会看到灯色瞬间跳变,逻辑上也不严谨。

2.3 相位配置表与持续时间

把相位和持续时间做成配置,而不是硬编码在逻辑里,后面调参才不用改代码:

// 相位配置:每个相位的持续时间(毫秒) public static readonly Dictionary<Phase, int> PhaseDurations = new() { { Phase.NorthSouthStraight, 8000 }, // 南北直行 8 秒 { Phase.NorthSouthLeft, 4000 }, // 南北左转 4 秒 { Phase.EastWestStraight, 8000 }, // 东西直行 8 秒 { Phase.EastWestLeft, 4000 }, // 东西左转 4 秒 { Phase.AllRed, 1500 } // 全红清空 1.5 秒 };

参数说明:直行时间给 8 秒是因为直行车流量通常最大;左转给 4 秒,因为左转车少且需要让行对向直行;全红 1.5 秒是经验值,太短清不空,太长通行效率低。这些数字都可以按你的模拟场景调整,改配置表就行,不用动状态机代码。

2.4 状态推进的核心循环

状态机的推进逻辑,本质就是「当前相位 → 等待持续时间 → 切换到下一相位」。用一个循环加Task.Delay就能实现:

// 状态机主循环:按顺序推进相位 private async Task RunStateMachineAsync(CancellationToken token) { var phases = Enum.GetValues<Phase>(); // 获取所有相位 int index = 0; while (!token.IsCancellationRequested) { var current = phases[index]; int duration = PhaseDurations[current]; // 更新所有方向的灯色 ApplyLightColors(current); // 等待当前相位持续时间 await Task.Delay(duration, token); // 推进到下一相位,取模实现循环 index = (index + 1) % phases.Length; } }

逻辑说明:ApplyLightColors根据当前相位设置四个方向的灯色,这是纯映射关系,不涉及时间。Task.Delay负责等待,CancellationToken负责优雅退出——没有它,程序关不掉。index取模实现循环推进,不需要手动判断「到最后一个了怎么办」。

ApplyLightColors的实现就是一个switch,把相位翻译成四个方向的灯色组合:

// 根据相位设置四个方向的灯色 private void ApplyLightColors(Phase phase) { switch (phase) { case Phase.NorthSouthStraight: SetLight(Direction.North, LightColor.Green); SetLight(Direction.South, LightColor.Green); SetLight(Direction.East, LightColor.Red); SetLight(Direction.West, LightColor.Red); break; case Phase.EastWestStraight: SetLight(Direction.North, LightColor.Red); SetLight(Direction.South, LightColor.Red); SetLight(Direction.East, LightColor.Green); SetLight(Direction.West, LightColor.Green); break; // 其余相位同理,此处省略 case Phase.AllRed: SetLight(Direction.North, LightColor.Red); SetLight(Direction.South, LightColor.Red); SetLight(Direction.East, LightColor.Red); SetLight(Direction.West, LightColor.Red); break; } }

这段代码看起来笨,但它是整个项目最不容易出错的部分。每个相位的灯色组合是显式写死的,互斥关系一目了然。如果你用「南北绿灯时东西自动红灯」这种推导逻辑,反而容易在扩展相位时漏掉某个方向。

3. 多线程与 UI 更新:灯色变了,界面怎么跟着动

3.1 后台线程与 UI 线程的边界

状态机跑在后台线程(Task默认在线程池上执行),但灯色最终要反映到界面上。如果你用的是 WinForms 或 WPF,直接在后台线程里改控件属性会抛InvalidOperationException——这是新手最常见的翻车点之一。血泪经验是:所有 UI 更新必须回到 UI 线程。

WinForms 里用Control.Invoke或BeginInvoke:

// 线程安全的灯色设置方法 private void SetLight(Direction dir, LightColor color) { // 判断是否需要跨线程调用 if (this.InvokeRequired) { // 回到 UI 线程执行 this.BeginInvoke(new Action(() => SetLight(dir, color))); return; } // 此处已在 UI 线程,直接更新控件 var panel = GetPanelByDirection(dir); panel.BackColor = color switch { LightColor.Red => Color.Red, LightColor.Yellow => Color.Yellow, LightColor.Green => Color.Green, _ => Color.Gray }; }

逻辑说明:InvokeRequired判断当前线程是不是创建控件的线程。如果不是,就用BeginInvoke把调用排队到 UI 线程。BeginInvoke是异步的,不会阻塞后台线程;Invoke是同步的,会等 UI 线程执行完,在状态机循环里用Invoke可能导致卡顿,所以我一般用BeginInvoke。

参数说明:GetPanelByDirection是你自己写的辅助方法,根据方向返回对应的Panel控件。Color.Gray是兜底,防止枚举值异常时界面不更新。

3.2 用 lock 保护共享状态

如果多个线程都要读当前相位(比如状态机线程在写,UI 线程在显示倒计时),就需要保护共享变量。C# 里最直接的就是lock:

// 共享状态:当前相位 private Phase _currentPhase; private readonly object _phaseLock = new object(); // 写入当前相位(状态机线程调用) private void UpdatePhase(Phase phase) { lock (_phaseLock) { _currentPhase = phase; } } // 读取当前相位(UI 线程调用) private Phase GetCurrentPhase() { lock (_phaseLock) { return _currentPhase; } }

逻辑说明:lock保证同一时刻只有一个线程能进入临界区。_phaseLock是一个专用的锁对象,不要用this或typeof(...)当锁,避免外部代码意外锁同一个对象导致死锁。

参数说明:Phase是枚举,属于值类型,读写本身是原子的,但lock在这里更多是保证「读到的值是最新的」,以及为将来扩展成复杂状态对象留余地。如果你只读写一个枚举,不加锁在 x86/x64 上通常也不会出错,但这是玄学,不建议赌。

3.3 倒计时显示的实现

倒计时是红绿灯模拟的标配。实现方式是在状态机等待期间,每隔 100 毫秒更新一次剩余时间:

// 带倒计时的相位等待 private async Task WaitWithCountdownAsync(int totalMs, CancellationToken token) { int elapsed = 0; const int step = 100; // 每 100ms 更新一次 while (elapsed < totalMs) { int remaining = (totalMs - elapsed) / 1000; // 剩余秒数 UpdateCountdownLabel(remaining); // 更新 UI 标签 await Task.Delay(step, token); elapsed += step; } }

逻辑说明:把整段等待拆成 100 毫秒的小步,每步更新一次倒计时。step越小,倒计时越平滑,但 UI 更新越频繁。100 毫秒是折中值,肉眼看起来已经足够连续。

参数说明:totalMs是当前相位的总时长,从配置表里取。remaining用整数除法取秒,所以显示的是「还剩几秒」,不是精确到毫秒。如果你要显示毫秒,把除数改成 1 即可,但没必要。

4. 避坑与排查:那些让我重写三遍的细节

4.1 黄灯到底该放在哪

现象:第一版里我把黄灯当成一个独立相位,结果南北绿灯结束后先切南北黄灯,再切全红,再切东西绿灯。逻辑上没错,但代码里相位数量翻倍,配置表变得很长。

原因:黄灯本质是「绿灯到红灯的过渡」,它属于某个方向组,而不是一个全局相位。

解决:把黄灯合并进相位切换逻辑里。具体做法是,在绿灯相位结束前 3 秒,把该方向的绿灯切成黄灯,然后进入全红。这样相位枚举不用增加,只是ApplyLightColors里多一个「剩余时间小于 3 秒」的判断。我一般会在状态机里维护一个remainingMs,在ApplyLightColors时根据剩余时间决定是绿还是黄。

4.2 Task.Delay 的取消异常没处理

现象:程序关闭时抛TaskCanceledException,虽然不影响功能,但调试窗口一片红。

原因:Task.Delay(duration, token)在 token 被取消时会抛异常,如果你没 catch,它就会冒泡。

解决:在状态机循环外层包一层 try-catch,专门捕获OperationCanceledException:

try { await RunStateMachineAsync(token); } catch (OperationCanceledException) { // 正常取消,忽略即可 }

注意:不要 catch 所有Exception,那样会把真正的 bug 也吞掉。只捕获OperationCanceledException及其子类。

4.3 界面卡死的元凶是 Invoke 而不是 BeginInvoke

现象:状态机跑起来后,界面按钮点不动,窗口拖不动。

原因:在状态机循环里用了Control.Invoke(同步),而后台线程在等 UI 线程,UI 线程又在等后台线程释放锁,形成死锁。

解决:所有从后台线程发起的 UI 更新,一律用BeginInvoke。如果确实需要同步等待 UI 更新完成,用Invoke但确保不在锁内部调用。我一般会在SetLight里统一用BeginInvoke,从源头上避免这个问题。

4.4 相位切换时灯色跳变

现象:从南北绿直接跳到东西绿,中间没有全红,视觉上很突兀。

原因:相位枚举里漏了AllRed,或者配置表里AllRed的持续时间设成了 0。

解决:确保AllRed相位存在且持续时间大于 0。1.5 秒是经验值,如果你想让模拟更真实,可以设成 2 秒。另外,AllRed期间所有方向都是红灯,包括行人灯。

4.5 扩展四相位后左转和直行冲突

现象:南北直行绿灯时,南北左转也绿灯,左转车和直行车在路口内交叉。

原因:左转和直行不能同时放行,除非有专门的左转保护相位。

解决:把NorthSouthStraight和NorthSouthLeft拆成两个独立相位,中间插入全红。左转相位放在直行相位之后,这样左转车可以利用直行已经清空的路口。如果你要做「左转待转区」,那又是另一个复杂度了,建议先把基础四相位跑通。

5. 进阶技巧:把模拟器变成可配置的交通流实验台

5.1 用配置文件驱动相位

硬编码的配置表只适合演示。如果你想拿这个模拟器做交通流实验,把相位配置抽到 JSON 文件里:

{ "phases": [ { "name": "NorthSouthStraight", "durationMs": 8000 }, { "name": "AllRed", "durationMs": 1500 }, { "name": "NorthSouthLeft", "durationMs": 4000 }, { "name": "AllRed", "durationMs": 1500 }, { "name": "EastWestStraight", "durationMs": 8000 }, { "name": "AllRed", "durationMs": 1500 }, { "name": "EastWestLeft", "durationMs": 4000 }, { "name": "AllRed", "durationMs": 1500 } ] }

读取用System.Text.Json:

// 从 JSON 文件加载相位配置 private List<PhaseConfig> LoadPhaseConfig(string path) { string json = File.ReadAllText(path); var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true // 忽略大小写 }; return JsonSerializer.Deserialize<List<PhaseConfig>>(json, options); }

逻辑说明:PropertyNameCaseInsensitive让 JSON 里的durationMs能匹配 C# 的DurationMs属性,省得两边大小写不一致。PhaseConfig是一个简单的 DTO 类,包含Name和DurationMs两个属性。

参数说明:JSON 文件路径建议放在程序目录下的config/phases.json,用AppDomain.CurrentDomain.BaseDirectory拼接,避免工作目录变化导致找不到文件。

5.2 加入随机车流量

固定相位时长是理想情况。真实路口的配时应该根据车流量动态调整。一个简单的做法是:每个方向维护一个「排队车辆数」,绿灯期间每 500 毫秒减少一辆,红灯期间每 1 秒增加一辆。当某个方向的排队数超过阈值时,延长它的绿灯时间。

// 根据排队长度动态调整绿灯时长 private int CalculateGreenDuration(Direction dir, int baseDuration) { int queue = GetQueueLength(dir); // 获取排队车辆数 int extra = Math.Min(queue * 200, 4000); // 每辆车加 200ms,最多加 4 秒 return baseDuration + extra; }

逻辑说明:queue * 200是经验公式,每多一辆排队车,绿灯延长 200 毫秒。Math.Min限制最大延长量,防止某个方向一直绿灯导致其他方向饿死。

参数说明:baseDuration是配置表里的基础时长。4000是最大延长上限,你可以按路口规模调整。这个逻辑放在相位切换之前,动态计算下一个周期的时长。

5.3 验证状态机是否正确

写完状态机后,怎么确认它没漏相位、没冲突?我一般会加一个「日志模式」,把每次相位切换和灯色变化打到控制台:

// 相位切换日志 private void LogPhaseChange(Phase from, Phase to) { Console.WriteLine($"[{DateTime.Now:HH:mm:ss.fff}] {from} -> {to}"); } // 灯色变化日志 private void LogLightChange(Direction dir, LightColor color) { Console.WriteLine($" {dir}: {color}"); }

跑一遍完整周期,检查三件事:所有相位都出现了、每个相位持续时间符合配置、没有两个冲突方向同时绿灯。如果日志太长,可以只记录相位切换,灯色变化用断点看。

从那以后我每次写状态机,都强制先跑一遍日志模式,确认状态流转没问题再接 UI。这个习惯帮我省了很多来回调试的时间。希望帮到你。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/19 17:00:59

Claude Code CLI 安装配置与实战指南:Node.js 环境、BASE_URL 与 AUTH_TOKEN 详解

1. 环境准备与基础依赖梳理1.1 为什么 Node.js 是绕不开的第一道坎Claude Code 这个 CLI 工具本质上是跑在 Node.js 运行时上的一个命令行程序&#xff0c;所以不管你用 Windows、macOS 还是 Linux&#xff0c;第一步都得先把 Node.js 装好。很多人卡在第一步不是因为不会装&am…

作者头像 李华
网站建设 2026/10/19 16:58:43

LabVIEW视觉缺陷检测:模板匹配与边缘检测工程化实战

简介&#xff1a;面向机器视觉入门与工程应用开发的LABVIEW视觉缺陷检测完整案例&#xff0c;围绕源图与对比图的差异分析&#xff0c;覆盖图像预处理、特征提取、缺陷判定等典型环节&#xff0c;适合自动化质检、产线表面缺陷筛查等场景。压缩包共200个文件&#xff0c;包含10…

作者头像 李华
网站建设 2026/10/19 16:47:28

自建开源CRM系统实践:从数据模型到部署落地的完整指南

做销售管理这几年&#xff0c;我一直觉得团队最缺的不是努力&#xff0c;而是一套能把客户线索、跟进动作、成交结果串起来的东西。市面上的轻量工具试过不少&#xff0c;但要么收费随团队人数水涨船高&#xff0c;要么数据存在别人服务器上&#xff0c;总觉得不踏实。后来我们…

作者头像 李华
网站建设 2026/10/19 16:46:14

微服务架构下集成商品API:从设计到排障的完整实践

做微服务改造这几年&#xff0c;我经常遇到一个尴尬场景&#xff1a;商品数据散落在各个业务服务里&#xff0c;有人直接调第三方商品 API&#xff0c;有人把返回结果整段塞进 Redis&#xff0c;还有人每天夜里批处理同步一次。表面看都跑得通&#xff0c;可一旦大促流量上来&a…

作者头像 李华
网站建设 2026/10/19 16:46:14

嵌入式固件BIN与HEX互转合并:从格式解析到工程化脚本实践

简介&#xff1a;这是一款面向嵌入式开发者的BIN与HEX文件互转合并工具&#xff0c;基于对话框程序实现&#xff0c;适合需要处理单片机固件格式转换的工程师使用。工具支持BIN转HEX、HEX转BIN以及HEX文件合并三类操作&#xff0c;并针对实际使用中的常见问题持续修复&#xff…

作者头像 李华