退出防呆:用“总闸“收口所有退出入口,以及三个并发语法坑

退出防呆:用"总闸"收口所有退出入口,以及三个并发语法坑

一、引子

工业软件最怕一种操作:机器正在跑活,有人顺手点了"退出",程序二话不说直接关了------PLC 还在动、轴还在走、下料还没完,现场一下失控。

所以得有"退出防呆":退出前先问一句"活干完没",没干完就拦住、先把尾收干净再关。

但"防呆"自己也有坑。本文讲两件事:怎么把所有退出入口收口到一处 (架构),以及收口时踩到的三个并发语法坑(重点)。

二、架构:一栋楼十几个门,全接同一个总闸

程序里能退出的入口不止一个:主窗口右上角的 X、键盘 Alt+F4、还有就绪/编程/标定/设置四个页面各自带的"退出"按钮。

复制代码
   主窗口 X / Alt+F4 ─→ 窗口 Closing 事件 ─┐
                                           ├─→ 退出守卫服务(全局单例)
   四个页面的「退出」按钮 ──────────────────┘

生活类比 :一栋楼有十几个门,每个门都能走出去。与其给每个门单独装锁,不如把所有门都接到同一个总闸------拉闸才真断电,比逐个门管稳得多。这个"总闸"就是一个全局单例的服务,所有退出请求先过它。

2.1 为什么必须收口(核心机制)

关键在于:框架的"整程序关闭"会无视窗口 Closing 事件里的取消标记。

退出入口 底层调用 取消标记生效?
点窗口 X / Alt+F4 关单个窗口 ✔ 能拦住
程序里的退出按钮 直接关整个应用 ✘ 被忽略

也就是说,只靠主窗口的 Closing 事件,只能拦住"点 X",拦不住"程序里的退出按钮"。这就是为什么四个页面的按钮也必须改走同一个"退出守卫"------否则防呆在这几个入口上是空的。

2.2 两条流程

复制代码
无在途作业  →  不弹框、不转圈  →  关光  →  关闭
有在途作业  →  二次确认框  →「是」→ 全屏转圈  →  收尾(停 PLC + 异常恢复下料,有超时上限)
                                                  →  收尾完  →  关闭

生活类比:有人在屋里干活时你想锁门走人,得先问一句"活干完没";没人就直接走,有人就等他收完。

2.3 判据的边界(提醒)

"有没有在途作业"的判据,只认生产运行/单次运行,不认实时预览忙不忙、也不认标定进行中。这是个有意无意的盲区------判据越窄,漏拦的风险越大,设计时要心里有数。

三、语法坑一:Task.WhenAny 只报"谁先到",不报"谁摔了"

收尾时要在"收尾任务"和"超时"之间二选一,很自然写成:

text 复制代码
Task 收尾 = 取消收尾流程();
if (await Task.WhenAny(收尾, Task.Delay(超时阈值)) != 收尾)
{
    记"超时";
}

坑 :WhenAny 只告诉你"谁先完成",不告诉你"是不是成功" 。如果"收尾"自己抛异常挂了,它会被当成"已完成"------于是不打任何日志就走下去。结果:收尾失败和收尾成功,在日志里长得一模一样。

生活类比:运动会裁判只拿喇叭喊"第一名到了!",不喊"那个人中途摔了"。你以为人都顺利到了,其实有人栽在半路,系统却当没事发生。

正确写法------显式看故障状态:

text 复制代码
Task 完成 = await Task.WhenAny(收尾, Task.Delay(超时阈值));
if (完成 != 收尾)        { 记"超时"; }
else if (收尾.IsFaulted) { 记"收尾异常"; }   // ← 不能漏

一句话 :WhenAny 是"竞速",不是"体检"。要判成败,自己看 IsFaulted。

四、语法坑二:??= 缓存 Task,会把"闸门"永久焊死

想让"同一操作并发进来时共享同一个任务",容易顺手写:

text 复制代码
// ❌ 有坑
public Task 执行() => _任务 ??= 核心异步();

坑 :??= 是"先算右边、再赋值"。如果 核心异步() 一路同步跑完 (中间没有真正挂起的等待),它会执行到末尾(可能顺手把自己清理掉),然后 ??= 才把一个"已完成的任务"写回字段 ------之后每次调用都拿到这个已完成的任务,闸门被永久占住,方法再也不真正执行。

生活类比:你想在"第一次有人按门铃时"才装个闸门,结果门铃一响,装闸门的人发现门早就开着、装完直接把"门已开"的牌子焊死在杆上。以后所有人看牌子都以为门开着,再也没人真去关门。

什么时候会"一路同步跑完" ?等待一个已经完成的任务不会切换线程(走同步快路径)。比如无在途作业时,整条收尾几乎全是同步路径------恰恰是最高频的常态。

修法------闸门同时判"是否已完成":

text 复制代码
// ✔
public Task 执行()
{
    if (_任务 is null || _任务.IsCompleted)   // 已完成的旧任务不算"进行中"
        _任务 = 核心异步();
    return _任务;
}

五、语法坑三(轻):async void 事件里 await 的顺序

窗口 Closing 是个 async void 的事件处理器。要在这里 await 收尾,顺序有讲究:

text 复制代码
Closing += async (sender, e) =>
{
    e.Cancel = true;              // ← 必须在第一个 await 之前同步设好
    if (!确认退出()) return;
    await Task.Yield();           // ← 让框架先把本次关闭处理完
    await 收尾并关闭Async();
};

三个要点:

  1. e.Cancel 要在第一个 await 之前设好------否则框架已经认为本次关闭处理完了,设了也没用;
  2. await Task.Yield() 是必需的------先让框架把本次关闭流程处理干净,否则紧接着若走到关闭,会撞上"窗口正在关闭";
  3. async void 里抛异常 会落到全局未处理异常通道------所以这条链上要自己 catch,别让它漏到顶层。

(这点和 await 的基础有关,但陷阱在"事件处理器里的顺序",与"挂起/恢复"是两码事。)

六、方法论:并发偶发失败,怎么下结论

单测里偶发多了一条失败,要判是不是自己改出来的,别急着认:

  1. 先隔离------单跑那条,通过就不像代码缺陷;
  2. 加大样本------同一份代码连跑多次;
  3. 做对照------把自己的改动暂存掉,连跑同样多次;
  4. 关变量------关掉测试的并行执行,排除"测试间互相干扰"。

经验:只跑两三次对照容易得出错误归因;能用"关变量"排除干扰,比堆样本更有力。本场数据就显示:关掉并行后偶发消失,说明是测试间并行干扰,而非代码缺陷。

七、小结

  1. 退出要收口到总闸------框架的"整程序关闭"会无视窗口取消标记,只拦主窗口拦不住页面按钮,必须把所有退出入口汇到同一个守卫服务。
  2. WhenAny 是竞速不是体检 ------必须显式判 IsFaulted,否则收尾失败和成功在日志里一样,成了静默失败。
  3. ??= 缓存 Task 要防"焊死"------同步快路径会把已完成任务写回字段,闸门判"是否已完成"才稳。
  4. async void 事件里 await 讲顺序 ------e.Cancel 先设、Task.Yield 垫步、自己 catch。
相关推荐
悠仁さん2 小时前
【C++】vector 类
开发语言·c++
_upupup3 小时前
智能指针的原理及简单模拟实现(C++)
开发语言·c++
en.en..3 小时前
C语言---宏
c语言·开发语言
郝学胜-神的一滴3 小时前
C++ Templates 01:拨开迷雾,读懂C++模板经典著作
开发语言·c++·程序人生·游戏·游戏引擎·软件构建
言乐63 小时前
Python实现关税自动计算模型
开发语言·python·django·virtualenv·pygame
朝朝辞暮i3 小时前
C++ 第 14 课:引用 &
开发语言·c++·算法
梅雅达编程笔记4 小时前
04-Python CSV数据保存与翻页抓取
开发语言·爬虫·python·pandas·数据采集·csv
C++ 老炮儿的技术栈4 小时前
有符号变量与无符号变量的区别
服务器·开发语言·前端·数据结构·c++·算法·c
估值探索者4 小时前
【Python量化策略实战 #04】Donchian 通道假突破太多?用 ATR 阈值过滤跑通真实突破信号
开发语言·python·接口·api接口·数据api接口·股票数据api接口