程序跑着跑着被用户点了右上角的叉,或者被系统服务管理器发了个终止信号,结果临时文件没清、数据库连接没关、日志缓冲区里的最后几条记录直接蒸发——这种事我遇到过不止一次。C#捕获程序退出事件看起来是个小得不能再小的知识点,但真到跨平台部署的时候,Windows上跑得好好的代码搬到Linux上就哑火了,原因就在于两个系统对“进程退出”这件事的处理模型压根不一样。这篇内容我打算把Windows和Linux下捕获退出事件的几种方式掰开揉碎讲清楚,包括控制台应用、桌面应用、后台服务三种形态各自的坑,以及怎么用一套代码同时覆盖两个平台。适合已经能写C#基础代码、正在做跨平台部署或者写守护型程序的读者,也适合那些被“程序关了但资源没释放”折腾过的朋友。
1. 先搞清楚“退出”到底有几种死法
在动手写代码之前,得先把“程序退出”这件事分类。很多人一上来就找AppDomain.ProcessExit,结果发现某些场景下它根本不触发,然后就开始怀疑人生。问题不在代码,在于退出的路径不同,能捕获到的事件也不同。
1.1 正常退出、异常退出与被杀死的区别
从进程的视角看,退出大致分三类。第一类是正常退出,比如控制台程序执行完Main自然返回,或者代码里主动调用了Environment.Exit(0),这类退出会走完整的清理流程,托管环境有机会触发退出事件。第二类是异常退出,比如未捕获的异常导致运行时终止进程,这类情况部分退出事件仍会触发,但时机很微妙,不能指望它做复杂清理。第三类是被外部强制终止,Windows上的任务管理器结束进程、Linux上的SIGKILL,这类退出进程没有任何机会执行任何代码,任何事件捕获都是徒劳。
理解这个分类非常关键,因为它直接决定了你的清理逻辑该放在哪里。我见过有同事把数据库事务的提交写在ProcessExit里,结果进程被kill -9之后数据一致性直接崩了。正确的思路是:退出事件只用来做“尽力而为”的收尾,真正重要的状态持久化必须靠定期落盘或者事务机制,不能押宝在退出回调上。
1.2 托管层事件与操作系统信号的对应关系
C#运行在托管环境里,我们接触到的是AppDomain.ProcessExit、Console.CancelKeyPress这类托管事件,而操作系统层面发的是信号或者消息。两者之间有一层翻译,这层翻译在不同平台上行为不一致,这就是跨平台坑的根源。
Windows上,控制台程序的关闭走的是控制台控制事件(Ctrl+C、Ctrl+Break、关闭窗口、注销、关机),桌面程序走的是窗口消息WM_CLOSE和会话结束消息。Linux上则统一为POSIX信号:SIGINT对应Ctrl+C,SIGTERM是标准的终止请求,SIGHUP是终端断开,SIGQUIT是带核心转储的退出。托管层把其中一部分包装成了事件,但不是全部。下面这张表是我实际测试整理的对应关系,建议收藏。
| 触发方式 | Windows 信号/消息 | Linux 信号 | 托管层可捕获事件 |
|---|---|---|---|
| Ctrl+C | CTRL_C_EVENT | SIGINT | Console.CancelKeyPress |
| Ctrl+Break | CTRL_BREAK_EVENT | SIGQUIT | Console.CancelKeyPress(部分) |
| 关闭控制台窗口 | CTRL_CLOSE_EVENT | SIGHUP | ProcessExit(有限时间) |
| 系统关机 | CTRL_SHUTDOWN_EVENT | SIGTERM | ProcessExit(有限时间) |
| 任务管理器结束 | 无 | SIGKILL | 无法捕获 |
| 正常返回 | 无 | 无 | ProcessExit |
注意:表格里“有限时间”的意思是,操作系统给进程的清理窗口很短,Windows上关闭控制台窗口大约只有几秒,超时直接强杀。所以清理逻辑必须快,别在里面做网络请求。
2. 托管层的第一道防线:ProcessExit与CancelKeyPress
先从最通用、跨平台支持最好的托管事件说起。这两个事件是绝大多数场景下的首选,代码量小,两个平台都能跑,但各自的适用边界要拎清楚。
2.1 AppDomain.ProcessExit的注册时机与执行窗口
AppDomain.CurrentDomain.ProcessExit这个事件在进程即将终止时触发,Windows和Linux都支持。注册方式很直接:
using System; class Program { static void Main() { AppDomain.CurrentDomain.ProcessExit += (sender, e) => { Console.WriteLine("ProcessExit 触发,开始清理"); // 清理逻辑写这里 }; Console.WriteLine("程序运行中,按 Ctrl+C 或等待自然结束"); System.Threading.Thread.Sleep(Timeout.Infinite); } }这段代码在Windows上按Ctrl+C、在Linux上收到SIGTERM时都会触发。但有几个细节必须注意。第一,注册必须早,最好在Main的第一行就挂上,因为如果注册之前进程就收到终止信号,回调不会执行。第二,回调里不能做耗时操作,前面说过系统给的窗口很短,我实测Windows关闭控制台窗口时大约有5秒,Linux的SIGTERM默认没有硬性超时但如果父进程是服务管理器可能会等不及。第三,回调里抛异常会被吞掉,不会导致进程崩溃,但也意味着你的清理失败不会有任何提示,建议在回调内部自己try-catch并写日志。
还有一个容易忽略的点:ProcessExit在Environment.Exit()被调用时也会触发,但如果是在ProcessExit回调内部再调用Environment.Exit(),会造成递归或者直接被忽略,别这么干。
2.2 Console.CancelKeyPress拦截Ctrl+C的正确姿势
Console.CancelKeyPress专门处理Ctrl+C和Ctrl+Break,它的一个关键能力是可以取消默认的终止行为。事件参数里有Cancel属性,设为true之后进程不会退出,你可以从容地做清理再自己决定何时退出。
using System; class Program { static void Main() { Console.CancelKeyPress += (sender, e) => { e.Cancel = true; // 阻止默认终止 Console.WriteLine("收到 Ctrl+C,执行优雅关闭"); // 做清理 Environment.Exit(0); // 清理完主动退出 }; System.Threading.Thread.Sleep(Timeout.Infinite); } }这里有个实操心得:e.Cancel = true之后如果不主动调用Environment.Exit,进程会一直挂着,用户会以为程序卡死了。所以拦截之后一定要有明确的退出路径。另外,这个事件在Linux上对SIGINT有效,但对SIGTERM无效——SIGTERM不会触发CancelKeyPress,只会走ProcessExit。这意味着如果你的程序作为服务运行、由服务管理器发SIGTERM停止,CancelKeyPress帮不上忙,得靠ProcessExit或者后面要讲的PosixSignalRegistration。
2.3 两个事件配合使用的推荐模板
实际项目里我一般两个都注册,分工明确:CancelKeyPress负责交互式场景下的优雅关闭(可以取消、可以做稍长的清理),ProcessExit作为兜底(不可取消、清理要快)。下面这个模板我在多个项目里用过,比较稳:
using System; using System.Threading; class Program { private static readonly ManualResetEventSlim ExitEvent = new(false); static void Main() { AppDomain.CurrentDomain.ProcessExit += (_, _) => Shutdown("ProcessExit"); Console.CancelKeyPress += (_, e) => { e.Cancel = true; Shutdown("CancelKeyPress"); }; Console.WriteLine("运行中..."); ExitEvent.Wait(); } private static void Shutdown(string source) { Console.WriteLine($"清理触发源:{source}"); try { // 快速清理:刷日志、关连接 } catch (Exception ex) { Console.Error.WriteLine($"清理异常:{ex.Message}"); } ExitEvent.Set(); } }用ManualResetEventSlim让主线程阻塞等待,而不是Thread.Sleep(Timeout.Infinite),好处是清理完成后能干净地走完Main返回,比直接Environment.Exit更可控。
3. Linux专属利器:PosixSignalRegistration
.NET 6之后引入的PosixSignalRegistration是我认为跨平台信号处理里最值得掌握的一个API。它让你能在托管代码里直接注册POSIX信号处理器,粒度比ProcessExit细得多,而且能区分SIGTERM、SIGHUP、SIGINT等不同信号。
3.1 为什么ProcessExit在Linux服务场景下不够用
前面提过,服务管理器停止服务时发的是SIGTERM,ProcessExit虽然会触发,但它是个“最后时刻”的事件,你没法区分这次退出是正常结束还是被要求终止,也没法在清理前做“拒绝退出、先完成手头任务”的处理。更麻烦的是,某些运行环境下ProcessExit的触发时机和信号处理是异步的,如果清理逻辑依赖顺序,容易出问题。
PosixSignalRegistration解决的就是这个:你可以针对SIGTERM单独注册回调,在回调里决定是取消终止(context.Cancel = true)还是继续。这给了服务型程序“收到停止请求后先把当前任务做完再退出”的能力,这在批处理、消息消费类程序里非常实用。
3.2 注册SIGTERM与SIGINT的完整示例
using System; using System.Runtime.InteropServices; using System.Threading; class Program { static void Main() { using var sigterm = PosixSignalRegistration.Create( PosixSignal.SIGTERM, context => { context.Cancel = true; // 阻止默认终止 Console.WriteLine("收到 SIGTERM,开始优雅关闭"); // 通知主循环停止 ShutdownRequested = true; }); using var sigint = PosixSignalRegistration.Create( PosixSignal.SIGINT, context => { context.Cancel = true; Console.WriteLine("收到 SIGINT"); ShutdownRequested = true; }); while (!ShutdownRequested) { // 主循环干活 Thread.Sleep(100); } Console.WriteLine("主循环退出,执行清理"); // 清理逻辑 } private static volatile bool ShutdownRequested; }这段代码在Linux上跑,kill -TERM <pid>会触发第一个回调,Ctrl+C触发第二个。context.Cancel = true是关键,它告诉运行时不要执行默认的信号终止行为,把控制权交给你。注意PosixSignalRegistration实现了IDisposable,用using确保释放,否则信号处理器会一直挂着。
注意:
PosixSignalRegistration在Windows上也能用,但只支持SIGINT、SIGQUIT、SIGTERM这几个,且行为映射到控制台事件,跨平台代码里可以用它统一处理Ctrl+C,但别指望它在Windows上处理窗口关闭。
3.3 信号处理回调里的线程安全红线
信号回调是在一个特殊的线程上下文里执行的,这里有几条红线不能碰。第一,不要在回调里做阻塞操作,比如等待锁、同步IO,因为信号处理可能打断主线程持有的锁,造成死锁。第二,回调里只做标志位设置和轻量通知,真正的清理放到主循环退出后做。第三,context.Cancel的设置要谨慎,设了true就意味着你承诺会自己退出,如果忘了退出进程就挂住了。
我踩过的一个坑:在SIGTERM回调里直接调用了Console.WriteLine写日志,结果在高并发场景下偶发卡死,原因是控制台输出内部有锁,而信号恰好打断了持有该锁的线程。后来改成只设置volatile标志位,日志放到主循环里写,问题消失。这个经验值得记一下。
4. Windows桌面程序的退出捕获:窗口消息与SessionEnding
如果你的程序是WinForms或WPF桌面应用,前面那些控制台事件基本用不上,得走窗口消息这条路。桌面程序的退出路径和控制台程序完全不同,关闭主窗口、用户注销、系统关机是三条不同的路。
4.1 FormClosing与ApplicationExit的分工
WinForms里捕获退出主要靠FormClosing和Application.ApplicationExit。FormClosing在主窗体关闭时触发,事件参数FormClosingEventArgs里的CloseReason能告诉你关闭原因:UserClosing是用户点叉,WindowsShutDown是系统关机,TaskManagerClosing是任务管理器结束(这个其实拦不住)。ApplicationExit在应用程序消息循环结束时触发,适合做全局清理。
using System; using System.Windows.Forms; class MainForm : Form { protected override void OnFormClosing(FormClosingEventArgs e) { if (e.CloseReason == CloseReason.UserClosing) { var result = MessageBox.Show("确定退出?", "确认", MessageBoxButtons.YesNo); if (result == DialogResult.No) { e.Cancel = true; // 取消关闭 return; } } // 清理逻辑 base.OnFormClosing(e); } }e.Cancel = true能阻止用户关闭窗口,这在“有未保存数据”的场景下很有用。但要注意,CloseReason.WindowsShutDown的情况下取消关闭是无效的,系统不会等你,所以关机场景下的清理必须极快。
4.2 系统关机与注销的SessionEnding处理
用户注销或系统关机时,WinForms会触发SystemEvents.SessionEnding事件,这个事件比FormClosing更早,而且能拿到SessionEndingReason。注册方式:
using Microsoft.Win32; SystemEvents.SessionEnding += (sender, e) => { Console.WriteLine($"会话结束原因:{e.Reason}"); // 快速保存状态 };SessionEnding的回调同样有时间限制,Windows给的时间窗口很短,做不了重活。我的做法是在这个回调里只做“标记需要恢复”的落盘,真正的数据保存靠程序运行期间的定期持久化。指望关机时保存大量数据是不现实的,这是桌面程序开发的一个基本认知。
4.3 WPF下的等效方案
WPF没有FormClosing,对应的是Window.Closing事件和Application.Exit事件,逻辑类似。Window.Closing的CancelEventArgs同样支持取消,Application.Current.Shutdown()触发的是正常退出流程。WPF里还有一个Application.SessionEnding事件,对应WinForms的SystemEvents.SessionEnding,用法一致。跨框架迁移时注意事件名对应关系即可,核心思路不变。
5. 一套代码覆盖双平台的工程化封装
讲了这么多分散的API,实际项目里肯定不希望每个入口都写一遍。我的做法是封装一个GracefulShutdown类,把平台差异吃掉,对外只暴露“注册清理回调”和“触发关闭”两个方法。
5.1 封装类的接口设计与平台分支策略
设计上遵循几个原则:注册要幂等(重复注册不重复执行)、清理回调按注册逆序执行(后注册的先清理,符合资源依赖关系)、单个回调异常不影响其他回调。平台分支用RuntimeInformation.IsOSPlatform判断,Linux下额外挂PosixSignalRegistration,Windows下挂控制台事件和ProcessExit。
using System; using System.Collections.Generic; using System.Runtime.InteropServices; using System.Threading; public static class GracefulShutdown { private static readonly List<Action> Callbacks = new(); private static readonly object Lock = new(); private static int _triggered; public static void Register(Action callback) { lock (Lock) Callbacks.Add(callback); } public static void Setup() { AppDomain.CurrentDomain.ProcessExit += (_, _) => Trigger("ProcessExit"); Console.CancelKeyPress += (_, e) => { e.Cancel = true; Trigger("CancelKeyPress"); }; if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) { PosixSignalRegistration.Create(PosixSignal.SIGTERM, ctx => { ctx.Cancel = true; Trigger("SIGTERM"); }); } } private static void Trigger(string source) { if (Interlocked.Exchange(ref _triggered, 1) == 1) return; Console.WriteLine($"关闭触发:{source}"); List<Action> snapshot; lock (Lock) snapshot = new List<Action>(Callbacks); for (int i = snapshot.Count - 1; i >= 0; i--) { try { snapshot[i](); } catch (Exception ex) { Console.Error.WriteLine($"清理失败:{ex.Message}"); } } } }Interlocked.Exchange保证只触发一次,避免ProcessExit和CancelKeyPress同时到达时重复清理。逆序执行是因为通常先注册的是底层资源(如数据库连接),后注册的是上层资源(如业务缓存),关闭时应该先关上层再关底层。
5.2 清理回调的执行顺序与超时保护
清理回调可能卡住,比如某个网络连接关闭时一直等超时。所以Trigger里最好加个总超时。简单做法是用Task.Run跑清理,主线程Wait一个固定时间:
var task = Task.Run(() => ExecuteCallbacks()); if (!task.Wait(TimeSpan.FromSeconds(3))) { Console.Error.WriteLine("清理超时,强制退出"); }3秒这个值是我根据Windows关闭控制台窗口的窗口期定的,留了余量。Linux下可以放宽到5秒,但别更长,服务管理器通常等不了太久。超时后直接让进程退出,未完成的清理只能靠下次启动时的恢复逻辑兜底,这也是为什么我一直强调“退出清理只是尽力而为”。
5.3 在控制台、桌面、服务三种形态下的接入差异
控制台程序直接在Main开头调GracefulShutdown.Setup()即可。桌面程序要注意Console.CancelKeyPress在无控制台时无效,得换成SystemEvents.SessionEnding和窗体的Closing,封装类里可以加个SetupDesktop()方法单独处理。服务形态(用BackgroundService或Windows服务)则主要依赖SIGTERM和ProcessExit,IHostApplicationLifetime的ApplicationStopping也是个好入口,可以和封装类配合,在ApplicationStopping里调Trigger。
三种形态的接入点不同,但清理回调的注册方式统一,这就是封装的价值——业务代码只关心“我要清理什么”,不关心“什么时候被调用”。
6. 踩坑实录与排查速查表
这一节是我这些年攒下来的真实问题记录,按现象、原因、解决整理成表,遇到类似情况可以直接对照。
6.1 常见失效场景与根因分析
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| Linux下kill后清理没执行 | 用了SIGKILL(kill -9) | 确认信号类型,SIGKILL无法捕获 |
| Windows关闭窗口清理没跑完 | 清理超时被强杀 | 缩短清理逻辑,加超时保护 |
| ProcessExit偶尔不触发 | 注册太晚或进程被强杀 | 提前注册,检查退出路径 |
| CancelKeyPress在服务里无效 | 服务不发Ctrl+C | 改用SIGTERM或ApplicationStopping |
| 清理回调死锁 | 回调里等锁或同步IO | 回调只设标志位,清理放主线程 |
| 重复清理导致异常 | 多个事件同时触发 | 用Interlocked保证只执行一次 |
6.2 几个我亲自踩过的坑
第一个坑是在ProcessExit里写日志文件。有次线上程序退出时日志没写进去,查了半天发现是日志框架的缓冲区在ProcessExit之后才flush,顺序反了。解决办法是在清理回调里显式调用日志框架的Flush(),或者干脆用同步写文件的方式记录关键退出信息。
第二个坑是Linux容器里SIGTERM被吞。容器编排系统停止容器时发SIGTERM,但如果程序的入口脚本是shell且没正确转发信号,信号到不了.NET进程。表现就是清理不执行、等超时后被强杀。排查方法是看进程树,确认信号接收者是不是你的程序本身。这个坑在容器化部署时特别常见,值得单独警惕。
第三个坑是桌面程序关机时弹确认框。FormClosing里弹MessageBox询问是否退出,结果系统关机时这个框弹出来没人点,系统等超时直接强杀,数据丢了。后来改成关机场景(CloseReason.WindowsShutDown)不弹框直接保存退出,问题解决。
6.3 验证清理逻辑是否真的执行的测试方法
清理逻辑最怕“以为执行了其实没有”。我的验证方法是:在清理回调里写一个带时间戳的标记文件,然后分别用Ctrl+C、kill -TERM、关闭窗口、kill -9四种方式终止程序,检查标记文件是否生成、内容是否正确。kill -9那次不生成是预期内的,其余三次都必须生成。这个测试我建议每个跨平台项目都做一遍,成本很低但能提前暴露大部分问题。
另外可以用strace(Linux)或Process Monitor(Windows)观察进程退出时的系统调用,确认信号是否送达、回调是否执行。这属于进阶排查手段,常规验证用标记文件就够了。
7. 关于退出清理的一点个人看法
写了这么多API和封装,最后想说个观念上的事。我早期做项目时总想把所有清理都塞进退出事件里,觉得这样“优雅”。后来发现这个思路本身就是脆弱的——退出事件能给你的保证太少了,强杀、断电、崩溃,任何一种都能让你的清理逻辑变成摆设。真正可靠的做法是把状态持久化做成常态,退出清理只负责“关掉那些不关也无所谓、但关了更干净”的资源,比如释放文件句柄、注销临时注册。数据库事务、关键业务状态这些,必须在业务操作发生时就已经落盘,不能等退出。
这个认知转变之后,我的退出清理代码反而变短了,从几百行的“大扫除”变成几十行的“顺手关灯”,稳定性却上去了。如果你正在设计一个需要长期运行的程序,建议也按这个思路来:退出事件是锦上添花,不是雪中送炭。跨平台部署时尤其如此,两个系统的信号模型差异摆在那,指望一套清理逻辑在两个平台上都完美执行,不如把功夫下在运行时的持续持久化上。