退出防呆:用"总闸"收口所有退出入口,以及三个并发语法坑
一、引子
工业软件最怕一种操作:机器正在跑活,有人顺手点了"退出",程序二话不说直接关了——PLC 还在动、轴还在走、下料还没完,现场一下失控。
所以得有"退出防呆":退出前先问一句"活干完没",没干完就拦住、先把尾收干净再关。
但"防呆"自己也有坑。本文讲两件事:怎么把所有退出入口收口到一处(架构),以及收口时踩到的三个并发语法坑(重点)。
二、架构:一栋楼十几个门,全接同一个总闸
程序里能退出的入口不止一个:主窗口右上角的 X、键盘 Alt+F4、还有就绪/编程/标定/设置四个页面各自带的"退出"按钮。
主窗口 X / Alt+F4 ─→ 窗口 Closing 事件 ─┐ ├─→ 退出守卫服务(全局单例) 四个页面的「退出」按钮 ──────────────────┘生活类比:一栋楼有十几个门,每个门都能走出去。与其给每个门单独装锁,不如把所有门都接到同一个总闸——拉闸才真断电,比逐个门管稳得多。这个"总闸"就是一个全局单例的服务,所有退出请求先过它。
2.1 为什么必须收口(核心机制)
关键在于:框架的"整程序关闭"会无视窗口 Closing 事件里的取消标记。
| 退出入口 | 底层调用 | 取消标记生效? |
|---|---|---|
| 点窗口 X / Alt+F4 | 关单个窗口 | ✔ 能拦住 |
| 程序里的退出按钮 | 直接关整个应用 | ✘ 被忽略 |
也就是说,只靠主窗口的 Closing 事件,只能拦住"点 X",拦不住"程序里的退出按钮"。这就是为什么四个页面的按钮也必须改走同一个"退出守卫"——否则防呆在这几个入口上是空的。
2.2 两条流程
无在途作业 → 不弹框、不转圈 → 关光 → 关闭 有在途作业 → 二次确认框 →「是」→ 全屏转圈 → 收尾(停 PLC + 异常恢复下料,有超时上限) → 收尾完 → 关闭生活类比:有人在屋里干活时你想锁门走人,得先问一句"活干完没";没人就直接走,有人就等他收完。
2.3 判据的边界(提醒)
"有没有在途作业"的判据,只认生产运行/单次运行,不认实时预览忙不忙、也不认标定进行中。这是个有意无意的盲区——判据越窄,漏拦的风险越大,设计时要心里有数。
三、语法坑一:Task.WhenAny只报"谁先到",不报"谁摔了"
收尾时要在"收尾任务"和"超时"之间二选一,很自然写成:
Task 收尾 = 取消收尾流程(); if (await Task.WhenAny(收尾, Task.Delay(超时阈值)) != 收尾) { 记"超时"; }坑:WhenAny只告诉你"谁先完成",不告诉你"是不是成功"。如果"收尾"自己抛异常挂了,它会被当成"已完成"——于是不打任何日志就走下去。结果:收尾失败和收尾成功,在日志里长得一模一样。
生活类比:运动会裁判只拿喇叭喊"第一名到了!“,不喊"那个人中途摔了”。你以为人都顺利到了,其实有人栽在半路,系统却当没事发生。
正确写法——显式看故障状态:
Task 完成 = await Task.WhenAny(收尾, Task.Delay(超时阈值)); if (完成 != 收尾) { 记"超时"; } else if (收尾.IsFaulted) { 记"收尾异常"; } // ← 不能漏一句话:WhenAny是"竞速",不是"体检"。要判成败,自己看IsFaulted。
四、语法坑二:??=缓存 Task,会把"闸门"永久焊死
想让"同一操作并发进来时共享同一个任务",容易顺手写:
// ❌ 有坑 public Task 执行() => _任务 ??= 核心异步();坑:??=是"先算右边、再赋值"。如果核心异步()一路同步跑完(中间没有真正挂起的等待),它会执行到末尾(可能顺手把自己清理掉),然后??=才把一个"已完成的任务"写回字段——之后每次调用都拿到这个已完成的任务,闸门被永久占住,方法再也不真正执行。
生活类比:你想在"第一次有人按门铃时"才装个闸门,结果门铃一响,装闸门的人发现门早就开着、装完直接把"门已开"的牌子焊死在杆上。以后所有人看牌子都以为门开着,再也没人真去关门。
什么时候会"一路同步跑完"?等待一个已经完成的任务不会切换线程(走同步快路径)。比如无在途作业时,整条收尾几乎全是同步路径——恰恰是最高频的常态。
修法——闸门同时判"是否已完成":
// ✔ public Task 执行() { if (_任务 is null || _任务.IsCompleted) // 已完成的旧任务不算"进行中" _任务 = 核心异步(); return _任务; }五、语法坑三(轻):async void事件里await的顺序
窗口 Closing 是个async void的事件处理器。要在这里await收尾,顺序有讲究:
Closing += async (sender, e) => { e.Cancel = true; // ← 必须在第一个 await 之前同步设好 if (!确认退出()) return; await Task.Yield(); // ← 让框架先把本次关闭处理完 await 收尾并关闭Async(); };三个要点:
e.Cancel要在第一个await之前设好——否则框架已经认为本次关闭处理完了,设了也没用;await Task.Yield()是必需的——先让框架把本次关闭流程处理干净,否则紧接着若走到关闭,会撞上"窗口正在关闭";async void里抛异常会落到全局未处理异常通道——所以这条链上要自己catch,别让它漏到顶层。
(这点和await的基础有关,但陷阱在"事件处理器里的顺序",与"挂起/恢复"是两码事。)
六、方法论:并发偶发失败,怎么下结论
单测里偶发多了一条失败,要判是不是自己改出来的,别急着认:
- 先隔离——单跑那条,通过就不像代码缺陷;
- 加大样本——同一份代码连跑多次;
- 做对照——把自己的改动暂存掉,连跑同样多次;
- 关变量——关掉测试的并行执行,排除"测试间互相干扰"。
经验:只跑两三次对照容易得出错误归因;能用"关变量"排除干扰,比堆样本更有力。本场数据就显示:关掉并行后偶发消失,说明是测试间并行干扰,而非代码缺陷。
七、小结
- 退出要收口到总闸——框架的"整程序关闭"会无视窗口取消标记,只拦主窗口拦不住页面按钮,必须把所有退出入口汇到同一个守卫服务。
WhenAny是竞速不是体检——必须显式判IsFaulted,否则收尾失败和成功在日志里一样,成了静默失败。??=缓存 Task 要防"焊死"——同步快路径会把已完成任务写回字段,闸门判"是否已完成"才稳。async void事件里await讲顺序——e.Cancel先设、Task.Yield垫步、自己catch。