首页 / 资讯中心 / 文章详情

C#程序退出事件捕获全攻略:跨平台钩子与收尾实践

C#程序退出事件捕获全攻略:跨平台钩子与收尾实践 ★ FEATURED ARTICLE
程序跑着跑着被系统关掉、被用户点了右上角、被运维一条命令终止结果日志没落盘、临时文件没清理、数据库连接池没释放——这类问题我见过太多次排查起来往往还特别费劲因为现场根本没留下。捕获程序退出事件这件事说小很小说大能直接决定一次线上事故能不能复盘。这篇就围绕 C# 里怎么在 Windows 和 Linux 两套环境下把退出事件接住把踩过的坑、不同方案的边界、以及那些文档里不会写的细节一次讲透。不管你是刚接触 .NET 的新手还是已经在做桌面端、后台服务、跨平台工具的老手下面这些内容都能直接拿去改改就用。1. 先搞清楚退出到底有几种死法很多人一上来就找ProcessExit事件粘上代码发现有时候触发有时候不触发然后就开始怀疑人生。问题不在代码在于你没分清进程退出的几种路径。C# 程序结束的方式远比想象中多不同路径能触发的钩子完全不一样先把这个分类搞清楚后面选方案才不会抓瞎。1.1 正常退出、异常退出与被强杀的区别从运行时角度看一个 .NET 进程的结束大致分三类。第一类是正常退出Main方法返回、调用Environment.Exit(0)、或者控制台程序里用户按了 CtrlC 后程序自己走完清理逻辑。这类退出运行时是有准备的托管堆还在事件能正常派发。第二类是异常退出未捕获异常导致进程崩溃运行时会尝试走一遍 AppDomain 卸载流程ProcessExit有机会触发但时间窗口很紧张。第三类是被强杀任务管理器结束进程、Linux 下kill -9、系统关机强制终止这类退出进程直接被内核干掉任何托管代码都不会执行。这个分类直接决定了你的期望值。我见过有人在ProcessExit里写数据库事务提交结果测试时用kill -9一杀数据全丢然后怪事件不靠谱。不是事件不靠谱是kill -9这种信号压根不给进程任何反应机会SIGKILL 不可捕获、不可忽略这是操作系统层面的规则跟 .NET 没关系。所以第一件要建立的心智模型是退出钩子只能覆盖进程还有机会执行代码的场景。真正重要的数据不能只依赖退出时落盘得有运行过程中的增量持久化兜底。退出钩子的定位是尽力而为的收尾不是数据安全的唯一保障。1.2 托管层钩子与操作系统信号的对应关系再往深一层看C# 能接到的退出通知本质上是运行时帮你把操作系统的信号或消息翻译成了托管事件。Windows 下主要是控制台控制事件CtrlC、CtrlBreak、关闭窗口、注销、关机和进程退出通知Linux 下则是 POSIX 信号比如 SIGINTCtrlC、SIGTERM常规终止、SIGHUP终端挂断。.NET 的Console.CancelKeyPress、AppDomain.ProcessExit、AssemblyLoadContext.Unloading这些托管入口底层都对应着这些系统级事件。理解这层映射的意义在于当你在 Linux 上发现某个钩子没触发你要问的不是事件是不是有 bug而是这个场景下系统到底发了什么信号运行时有没有把它翻译过来。比如SIGTERM默认会被运行时转成优雅关闭流程ProcessExit会触发但SIGKILL没有任何翻译进程直接消失。再比如容器环境里编排系统停止容器时默认发的是SIGTERM给一段宽限期后才SIGKILL这个宽限期就是你退出钩子的执行预算超时了照样被强杀。2. AppDomain.ProcessExit跨平台的主力钩子如果只能记一个钩子那必须是AppDomain.CurrentDomain.ProcessExit。它在 Windows 和 Linux 上都能用是 .NET Core / .NET 5 里最通用的进程退出通知入口。但能用和用对之间隔着不少细节尤其是执行时限和线程上下文这两块坑最多。2.1 事件注册的正确姿势与执行时机注册本身很简单一行代码的事AppDomain.CurrentDomain.ProcessExit (sender, args) { // 收尾逻辑 };但这里有个容易被忽略的点注册必须足够早。如果你把注册放在某个懒加载的模块里而进程在模块加载前就退出了钩子自然不生效。我的习惯是在Main方法的第一行就注册或者用ModuleInitializer保证程序集加载时立即挂上。对于 ASP.NET Core 这类有宿主Host的应用更推荐用IHostApplicationLifetime.ApplicationStopping它和宿主生命周期绑定得更紧密触发时机也更可控。执行时机方面ProcessExit是在运行时准备卸载 AppDomain 时触发的此时托管堆仍然有效你可以访问静态字段、调用大部分托管 API。但要注意此时其他线程可能已经被终止或正在终止如果你在钩子里依赖某个后台线程的状态很可能读到一半就没了。所以钩子里的逻辑要尽量自包含别去等别的线程。2.2 那个绕不开的执行时限问题这是ProcessExit最大的坑也是我踩得最狠的一次。.NET 运行时给ProcessExit处理器的执行时间是有限制的默认大约 2 秒不同版本略有差异且这个值不是公开稳定契约。超过这个时间运行时会直接结束进程你的收尾逻辑做到一半就被腰斩。我当时的场景是在钩子里做一次 HTTP 上报网络稍微抖一下2 秒就过去了日志显示上报请求发出去了但没等到响应进程就没了。后来改成异步上报加本地队列兜底才解决。这个教训是钩子里绝对不要做可能阻塞的同步 IO 或网络调用。如果非要等异步操作完成得用Task.Wait(timeout)显式设一个远小于运行时限制的超时比如 500 毫秒超了就放弃把数据写进本地文件等下次启动再补。AppDomain.CurrentDomain.ProcessExit (s, e) { var task FlushAsync(); // 显式限时别指望运行时给你无限时间 if (!task.Wait(TimeSpan.FromMilliseconds(500))) { // 超时降级到同步写本地文件 WriteFallbackLog(); } };注意这个 2 秒左右的限制在不同 .NET 版本和不同操作系统上表现不完全一致别把它当成精确契约来设计。正确做法是把钩子里的工作压缩到几百毫秒内剩下的交给持久化队列。2.3 多线程环境下的竞态与幂等设计ProcessExit可能被多个线程同时触发吗严格说事件本身派发是串行的但你的收尾逻辑如果涉及共享资源仍然要考虑并发。更现实的问题是幂等某些场景下退出流程可能被走两遍比如你先调了Environment.Exit运行时走一遍ProcessExit然后宿主又在别处触发一次关闭。如果你的收尾逻辑不幂等就会出现重复删除文件、重复提交事务的问题。我的做法是用一个Interlocked标志位守住收尾入口private static int _cleanupStarted 0; private static void OnProcessExit(object? sender, EventArgs e) { if (Interlocked.Exchange(ref _cleanupStarted, 1) ! 0) return; // 已经清理过直接返回 // 真正的收尾逻辑 }这个模式看起来土但在退出这种只该发生一次的场景里极其有效。别用lock因为退出时锁的持有者可能已经被终止lock会死等Interlocked是无锁的安全得多。3. Console.CancelKeyPress接住 CtrlC 的那一下桌面程序有窗口关闭消息后台服务有系统信号但控制台程序最常遇到的退出方式是用户按 CtrlC。这个场景ProcessExit也能覆盖但Console.CancelKeyPress给你更早的介入点和更明确的语义值得单独拿出来说。3.1 为什么它比 ProcessExit 更适合交互式中断CancelKeyPress的触发时机比ProcessExit早。用户按下 CtrlC运行时先派发CancelKeyPress你可以在事件参数里设置e.Cancel true来阻止进程立即退出然后从容地做清理清理完再自己调Environment.Exit。这个先拦下来再慢慢收尾的能力是ProcessExit没有的——后者一旦触发进程已经在退出路上了你只能抢时间。对于需要做较多收尾工作的控制台工具比如要合并临时分片文件、要回滚未完成的任务我强烈建议用CancelKeyPress而不是只靠ProcessExit。它给了你一个明确的暂停键收尾逻辑可以写得更从容不用时刻盯着那 2 秒的倒计时。Console.CancelKeyPress (sender, e) { e.Cancel true; // 先拦住别让进程马上死 Console.WriteLine(正在收尾请稍候...); DoCleanup(); // 这里可以稍微多花点时间 Environment.Exit(0); // 收尾完自己退出 };3.2 Cancel 设为 true 之后的收尾节奏控制e.Cancel true之后有个细节要注意进程不会自动退出你得自己负责结束它。如果清理逻辑抛了异常没走到Environment.Exit进程就会卡在那里用户再按一次 CtrlC 也没用因为事件又被拦了。所以清理逻辑必须包在try/finally里finally里保证调用Environment.Exit。另一个节奏问题是用户可能不耐烦连按好几次 CtrlC。第一次你拦下来做清理第二次用户其实是想别清理了赶紧退。这时候可以做个计数第二次按下时不再拦截直接放行private static int _cancelCount 0; Console.CancelKeyPress (sender, e) { if (Interlocked.Increment(ref _cancelCount) 1) { e.Cancel false; // 用户催了放行 return; } e.Cancel true; try { DoCleanup(); } finally { Environment.Exit(0); } };这个交互细节看起来小但实际用起来体验差别很大。工具类程序尤其要考虑用户等不及的心理给他们一个强制退出的出口。3.3 Linux 下信号映射的实测差异CancelKeyPress在 Linux 上对应的是 SIGINT行为基本一致但有几个实测差异值得记一下。第一在某些终端模拟器里CtrlC 的传递可能被终端本身拦截导致程序收不到 SIGINT这时候得检查终端的配置。第二通过kill -INT pid发信号和按 CtrlC 效果相同都会触发CancelKeyPress这在写自动化测试脚本时很有用。第三如果程序运行在后台比如nohup或CtrlC 根本不会传到它得用kill -TERM而 SIGTERM 不走CancelKeyPress走的是ProcessExit。这个差异意味着如果你的程序既可能在交互式终端跑也可能在后台跑两个钩子都得挂。CancelKeyPress管交互式中断ProcessExit管 SIGTERM 和正常退出两者互补不能只挂一个。4. Linux 专属PosixSignalRegistration 的精细控制.NET 6 引入了PosixSignalRegistration这是 Linux以及 macOS上处理信号的一等公民。它比ProcessExit更底层、更精细能让你针对具体信号做不同处理还能选择是否让运行时继续默认行为。如果你的程序要正经跑在 Linux 服务器上这个 API 值得花时间掌握。4.1 注册 SIGTERM 与 SIGINT 的标准写法PosixSignalRegistration.Create接收信号类型和一个回调返回一个可释放的注册对象。回调里通过context.Cancel决定是否阻止运行时的默认处理using System.Runtime.InteropServices; var sigterm PosixSignalRegistration.Create(PosixSignal.SIGTERM, context { context.Cancel true; // 阻止默认的立即终止 DoGracefulShutdown(); // 自己做优雅关闭 }); var sigint PosixSignalRegistration.Create(PosixSignal.SIGINT, context { context.Cancel true; DoGracefulShutdown(); }); // 程序结束时记得 Dispose否则注册会一直挂着这里的关键是context.Cancel true。设了它运行时就不会执行该信号的默认动作SIGTERM 默认是终止进程把控制权完全交给你。你可以慢慢做清理清理完自己调Environment.Exit。不设的话运行时处理完你的回调后仍会按默认行为终止进程你的清理可能来不及完成。4.2 Cancel 语义与默认行为的取舍context.Cancel这个命名有点反直觉它不是说取消这个信号而是取消运行时的默认响应。理解这一点很重要因为设了Cancel true之后进程不会自己退出你必须显式结束它。我见过有人设了 Cancel 却忘了调Environment.Exit结果进程收到 SIGTERM 后既不退出也不干活卡成了僵尸运维那边看着进程还在以为服务正常实际上已经半死不活了。取舍逻辑是这样的如果你需要做较重的收尾落盘、通知、等待任务完成设Cancel true自己掌控节奏如果收尾很轻或者你信任运行时的默认流程可以不设让运行时处理完回调后正常终止。我的默认选择是设Cancel true因为可控性更重要但一定配套Environment.Exit兜底。4.3 容器环境里 SIGTERM 宽限期的配合容器编排系统停止容器时标准流程是先发 SIGTERM等一段宽限期默认常见是 30 秒可配置宽限期内进程没退出就发 SIGKILL 强杀。这个宽限期就是你PosixSignalRegistration回调的执行预算。如果你的收尾逻辑要跑 40 秒那对不起第 30 秒就被 SIGKILL 了收尾白做。所以容器场景下收尾逻辑的耗时必须明显小于宽限期并且最好在配置里把宽限期和收尾超时对齐。我的做法是收尾逻辑内部设一个硬超时比如宽限期的 70%超时就放弃剩余工作、记录日志、立即退出把优雅退出降级为快速退出避免被 SIGKILL 打断留下半截状态。var sigterm PosixSignalRegistration.Create(PosixSignal.SIGTERM, context { context.Cancel true; using var cts new CancellationTokenSource(TimeSpan.FromSeconds(20)); // 宽限期 30s留余量 try { DoGracefulShutdown(cts.Token); } finally { Environment.Exit(0); } });注意Environment.Exit在信号回调里调用是安全的但如果你的收尾逻辑里还有未完成的异步任务Environment.Exit会直接切断它们。所以要么在退出前await完要么接受这些任务被丢弃。5. Windows 专属窗口关闭与控制台事件的差异化处理Windows 这边的退出路径比 Linux 更杂因为图形界面程序和控制台程序走的是两套消息机制。桌面应用点右上角关闭、控制台程序按 CtrlC、系统注销关机触发的钩子各不相同得分开处理。5.1 WinForms 与 WPF 的关闭事件链路WinForms 里窗体关闭走FormClosing和FormClosed两个事件。FormClosing在关闭之前触发事件参数里可以设e.Cancel true阻止关闭适合做确认退出或保存提示。FormClosed在关闭之后触发此时窗体已经销毁适合做资源释放。应用程序级别的退出则看Application.ApplicationExit它在消息循环结束时触发是 WinForms 程序做全局收尾的推荐位置。WPF 的链路不太一样主窗口关闭默认会结束整个应用ShutdownMode为OnMainWindowClose时收尾逻辑挂在Application.Exit事件或者主窗口的Closing事件上。WPF 的Closing同样支持e.Cancel但要注意如果应用是通过Application.Current.Shutdown()主动关闭的Closing也会触发别在里面做重复清理。这里有个共性坑窗体关闭事件只在 UI 线程触发且只在正常关闭流程里触发。如果进程是被任务管理器结束的这些事件一个都不会走。所以桌面程序的收尾同样不能只依赖窗体事件得配合ProcessExit做兜底。5.2 SetConsoleCtrlHandler 覆盖的边界场景控制台程序在 Windows 上除了CancelKeyPress能接住的 CtrlC还有 CtrlBreak、关闭控制台窗口、用户注销、系统关机这几种控制事件。CancelKeyPress只覆盖 CtrlC其余几种要靠 P/Invoke 调用SetConsoleCtrlHandler注册处理函数。[DllImport(kernel32.dll, SetLastError true)] static extern bool SetConsoleCtrlHandler(ConsoleCtrlDelegate handler, bool add); delegate bool ConsoleCtrlDelegate(uint ctrlType); // ctrlType: 0CtrlC, 1CtrlBreak, 2关闭窗口, 5注销, 6关机这个方案属于底层手段一般业务代码用不到但如果你在做需要感知系统关机的工具比如关机前保存状态就得用它。要注意的是处理函数里能做的事情很有限系统关机时留给你的时间窗口很短别指望在里面做复杂操作。而且这个 API 是 Windows 专属跨平台代码里得用#if WINDOWS包起来或者用OperatingSystem.IsWindows()做运行时判断。5.3 服务场景下 SCM 停止通知的对接如果程序是以 Windows 服务形式运行的退出路径又不一样了。服务控制管理器SCM停止服务时会调用服务的OnStop方法你得在ServiceBase派生类里重写它来做收尾。用Microsoft.Extensions.Hosting的UseWindowsService时宿主会把 SCM 的停止通知转成IHostApplicationLifetime.ApplicationStopping用这个更现代、更统一。服务场景的坑在于停止超时。SCM 对服务停止有时间限制超时了会认为服务无响应可能强制结束。所以OnStop或ApplicationStopping里的收尾同样要限时逻辑跟前面容器宽限期那套思路一致设硬超时超时降级保证在 SCM 耐心耗尽前退出。6. 把收尾逻辑写扎实顺序、超时与降级钩子挂对了只是第一步收尾逻辑本身写不好照样出问题。这一节讲的是钩子触发之后的事也是实际项目里最容易翻车的地方。收尾不是把所有清理代码堆在一起就完事顺序、超时、失败降级都得设计。6.1 收尾动作的依赖顺序与失败隔离收尾动作之间有依赖关系顺序错了会出问题。举个典型例子你先关了数据库连接池再去写关闭成功的日志结果日志写不进去因为日志组件可能依赖某个后台线程或连接。正确的顺序应该是先做对外通知和日志落盘再释放内部资源最后关连接。原则是越靠近外部可见的动作越早做越底层的资源越晚释放。失败隔离同样重要。收尾逻辑里如果有五个步骤第三步抛异常了第四第五步还做不做答案是要做。每个步骤都应该独立 try-catch一个失败不影响其他void SafeRun(string name, Action action) { try { action(); } catch (Exception ex) { /* 记录但不中断 */ } } SafeRun(flush-log, () logger.Flush()); SafeRun(notify, () notifier.SendShutdown()); SafeRun(release-db, () dbPool.Dispose());这个模式看起来啰嗦但退出场景下尽力而为比要么全做要么全不做更实际。一个步骤失败就中断整个收尾只会让情况更糟。6.2 用 CancellationToken 给收尾设硬超时前面反复提到超时具体怎么实现最干净的方式是用CancellationTokenSource设一个总超时把 token 传进各个收尾步骤每个步骤自己检查 token 决定是否继续。这样即使某个步骤卡住整体也能在超时后跳出。using var cts new CancellationTokenSource(TimeSpan.FromSeconds(3)); try { await FlushLogsAsync(cts.Token); await NotifyAsync(cts.Token); } catch (OperationCanceledException) { // 超时了记录并放弃剩余步骤 } finally { Environment.Exit(0); }注意CancellationTokenSource的超时是软超时它只是让 token 变成已取消状态具体步骤得配合检查 token 才会真正停下。如果某个步骤是阻塞的同步调用token 救不了它所以收尾步骤本身也要尽量用支持取消的异步 API。6.3 收尾失败时的降级与现场保留收尾失败不可怕可怕的是失败了还没留下任何线索。我的习惯是在收尾逻辑的最外层包一层任何未处理异常都写到一个独立的、不依赖主日志系统的应急文件里。因为主日志系统可能正是收尾失败的原因比如它的缓冲区满了这时候再往里写就是雪上加霜。应急文件用最朴素的File.AppendAllText写路径选在程序目录或系统临时目录内容包含时间戳、异常类型、堆栈。这个文件平时用不上出问题时就是救命稻草。另外如果收尾涉及删除临时文件建议先重命名再删除或者干脆保留到下次启动时清理避免删到一半进程没了留下残缺文件。7. 跨平台统一封装的实战写法讲了这么多平台差异实际项目里不可能到处写#if。合理的做法是封装一个统一的退出管理器内部按平台分派对外暴露一致的注册接口。这一节给出一个可以直接抄的骨架以及封装时要注意的取舍。7.1 一个可复用的 ShutdownManager 骨架核心思路是对外提供Register(ActionCancellationToken)方法内部把ProcessExit、CancelKeyPress、PosixSignalRegistration都挂上统一调度注册的回调统一做超时和幂等控制。public sealed class ShutdownManager { private readonly ListFuncCancellationToken, Task _handlers new(); private int _started 0; public void Register(FuncCancellationToken, Task handler) _handlers.Add(handler); public void Attach() { AppDomain.CurrentDomain.ProcessExit (_, _) RunAll(TimeSpan.FromSeconds(2)); Console.CancelKeyPress (_, e) { e.Cancel true; RunAll(TimeSpan.FromSeconds(5)); Environment.Exit(0); }; if (!OperatingSystem.IsWindows()) { PosixSignalRegistration.Create(PosixSignal.SIGTERM, ctx { ctx.Cancel true; RunAll(TimeSpan.FromSeconds(20)); Environment.Exit(0); }); } } private void RunAll(TimeSpan timeout) { if (Interlocked.Exchange(ref _started, 1) ! 0) return; using var cts new CancellationTokenSource(timeout); foreach (var h in _handlers) { try { h(cts.Token).Wait(timeout); } catch { /* 隔离失败 */ } } } }这个骨架的关键设计点_started保证只跑一次每个 handler 独立 try-catch 隔离失败不同触发源给不同超时ProcessExit给最短因为运行时限制最紧SIGTERM 给最长因为容器宽限期通常较宽。实际用的时候按需调整超时值。7.2 不同触发源超时预算的分配逻辑超时预算不能拍脑袋定得根据触发源的耐心来分配。ProcessExit背后是运行时的约 2 秒限制所以给它的预算要压到 1.5 秒以内留余量。CancelKeyPress是用户交互用户能等几秒给 5 秒合理。SIGTERM 背后是容器宽限期常见 30 秒给 20 秒留足余量。Windows 服务的 SCM 停止超时通常也是几十秒量级可以给 15 到 20 秒。这个分配逻辑要写进代码注释里不然过半年你自己都忘了为什么是这些数字。我见过太多项目里一堆魔法数字没人敢改就是因为当初没写清楚依据。7.3 注册时机与 Dispose 生命周期的管理PosixSignalRegistration返回的对象需要Dispose否则注册会一直挂着。在长期运行的服务里这通常不是问题服务活到进程结束但在单元测试或频繁创建销毁的场景里不 Dispose 会导致注册泄漏。封装时应该把注册对象存起来提供Detach方法统一释放。注册时机上Attach要在程序启动早期调用最好在Main的第一行或宿主构建完成后立即调用。晚于第一次可能的退出信号就等于没挂。这个早字怎么强调都不过分我踩过的坑里有一半是注册太晚导致的。8. 那些只有踩过才知道的坑前面讲的都是应该怎么做这一节专门讲实际会怎么翻车。这些经验在官方文档里基本找不到都是实打实调试出来的。8.1 钩子里访问已释放对象的空引用退出时最典型的异常是NullReferenceException或ObjectDisposedException原因是你访问的对象已经被别的清理流程释放了。比如主流程在关闭时先释放了日志组件然后ProcessExit钩子又去调logger.Info直接炸。这类问题的根源是清理流程没有统一的编排各处各清各的。解决办法是引入一个关闭状态标志所有清理入口先检查这个标志已关闭就跳过。或者更彻底一点用前面说的ShutdownManager统一编排所有清理都走它避免多处并发清理。核心原则退出相关的清理只能有一个入口多入口必然打架。8.2 异步方法在退出钩子里假死在ProcessExit里调async方法然后不await是另一个高频坑。你以为它在后台跑实际上进程退出时线程池线程被终止异步续体永远不执行方法假死。表现就是日志显示方法进去了但后半段逻辑从来没跑过。正确做法是同步等待且带超时task.Wait(timeout)或task.GetAwaiter().GetResult()配合外层超时控制。别用async void别 fire-and-forget。退出场景下没有后台慢慢跑这回事进程一没什么都没了。8.3 日志组件自身成为收尾瓶颈日志组件在收尾时经常掉链子。异步日志框架比如带缓冲队列的在进程退出时队列里还有没落盘的消息如果不在钩子里显式Flush这些日志就丢了。但Flush本身可能阻塞又撞上超时限制。我的处理是收尾第一步就是Flush日志给它一个短超时比如 500 毫秒超时就放弃剩余缓冲转而写应急文件。同时关键日志不要只走异步队列重要节点用同步写或者双写保证即使异步队列丢了关键信息还在。这个取舍有点反性能优先的直觉但退出场景下可靠性优先。8.4 测试退出逻辑时怎么模拟真实信号退出逻辑最难的是测试因为你不能真的把测试进程杀了。我的做法是分层测试单元测试层面把收尾逻辑抽成纯函数直接调用验证集成测试层面起一个子进程用kill -TERMLinux或发送 CtrlC 事件Windows触发然后检查子进程的退出码和落盘文件。Linux 下用kill命令模拟信号很方便Windows 下可以用GenerateConsoleCtrlEvent这个 API 给指定进程发 CtrlC。测试时特别要覆盖超时路径故意让某个收尾步骤睡超过预算的时间验证整体能在超时后正常退出、降级逻辑生效。这条路径平时不走到但真出问题时全靠它兜底不测等于没有。9. 选型速查什么场景挂什么钩子最后给一张对照表把前面散落的结论收拢一下。实际项目里对着这张表选基本不会漏。场景推荐钩子超时预算备注通用跨平台收尾AppDomain.ProcessExit1.5 秒内必挂兜底用控制台交互式中断Console.CancelKeyPress5 秒配合e.Cancel拦截Linux 服务优雅关闭PosixSignalRegistration(SIGTERM)宽限期的 70%设context.Cancel trueWinForms 桌面程序FormClosingApplicationExit视交互而定配合ProcessExit兜底WPF 桌面程序Application.Exit 主窗口Closing视交互而定注意ShutdownModeWindows 服务IHostApplicationLifetime.ApplicationStopping15 到 20 秒对齐 SCM 停止超时需要感知系统关机SetConsoleCtrlHandler极短Windows 专属P/Invoke这张表不是让你全挂上而是让你知道每个场景该用哪个。挂多了反而容易互相干扰比如CancelKeyPress和PosixSignalRegistration(SIGINT)同时挂可能重复触发收尾。选型原则是按程序的实际运行形态选最贴切的一到两个再用ProcessExit做统一兜底。我个人在实际项目里的固定搭配是所有程序都挂ProcessExit做兜底控制台工具额外挂CancelKeyPressLinux 服务额外挂SIGTERM注册桌面程序额外挂窗体关闭事件。这套组合覆盖了绝大多数退出路径剩下的kill -9和强制关机属于物理不可抗靠运行中的增量持久化来保不指望退出钩子。把这条边界记牢退出事件这块基本就不会再出大问题了。
阅读完成 · 觉得有帮助?
咨询建站