从应用角度看 Windows 关机——正确扛过退出通知、重启与断电

· 更新日期: · · Windows, 关机, Windows 开发, Windows 服务, 设备 PC, 数据完整性, 长时间运行, UPS

更新记录(仅首版,2026年08月21日 发布)
首次发布
引用本文(DOI(已登记存档): 10.5281/zenodo.22176547)

以下 DOI 指向先前登记的存档,内容可能与当前正文不同。引用当前正文时,请使用本页网址。

Go Komura(2026)。《从应用角度看 Windows 关机——正确扛过退出通知、重启与断电》。小村软件有限公司。 https://comcomponent.com/zh-CN/blog/windows-shutdown-handling-for-apps/

DOI(已登记存档)
10.5281/zenodo.22176547
DOI(上次登记版本)
10.5281/zenodo.22176548

“Windows Update 的夜间重启把正在测量的文件弄坏了。”“从共用 PC 注销之后,正在编辑的内容就没了。”要避免这类问题,就不能把关机当成例外,而必须把“被外部要求退出”设计成应用的正常行为。

不过,只写接收退出通知的代码还不够。收到通知后可用的时间很短,而突然断电根本没有通知。本文的主线,是把日常保存、简短收尾、下次启动时的恢复串成一条链。

本文面向中小企业的信息系统负责人和 Windows 应用开发者,尤其是接触设备 PC 与长时间运行应用的读者,梳理从通知路径的选择、实现,到重启后的恢复、断电的准备和验证。技术依据是原文所引用的、截至 2026 年 8 月的 Microsoft Learn 一手资料。

1. 先说结论——在等通知之前,先减少需要保存的量

“保持随时都能在几秒内退出的状态;即使通知没有到来,也能在下次启动时恢复。”这就是应对关机的基本方针。

不要等收到退出通知之后才保存大量数据,而要在处理的每个节点上保存,把退出时剩下的差量压小。对 GUI 应用和关闭控制台来说,大约 5 秒是重要的参考值。服务另有一套宽限期,但它们都不是“一定会等你做到最后的时间”。123

1.1. 为自己的应用选择通知路径

应用形态 接收退出请求的位置 首先要掌握的要点 参阅章节
Win32 GUI 应用 WM_QUERYENDSESSION 与 WM_ENDSESSION 对查询原则上立即返回 TRUE;退出确定之后的收尾放在 WM_ENDSESSION 中做 第 3 章
WinForms / WPF FormClosing / SessionEnding,必要时挂钩消息 这两个事件属于查询阶段;不可撤销的收尾要分到确定通知里 第 3 章
纯控制台应用 SetConsoleCtrlHandler 等 关闭控制台与注销、关机,收到通知的条件各不相同 第 5 章
Windows 服务 来自 SCM 的 SHUTDOWN / PRESHUTDOWN 声明接受标志,并让控制处理例程立即返回 第 6 章
Generic Host / Worker Service IHostApplicationLifetime 与 StopAsync 把收尾集中到 Host 的停止路径,并显式设置 ShutdownTimeout 第 5、6 章

在 .NET 中,请不要只依赖 AppDomain.ProcessExit。 从 .NET 10 起,运行时不再为 CTRL_CLOSE_EVENT / CTRL_SHUTDOWN_EVENT 提供默认的处理例程。这条外部信号路径,与从 Main 返回之类的正常退出是两回事。详见 5.2 节。4

1.2. 在处理通知之外,还需要两项准备

收到通知后,不要去问用户“要保存吗?”,而应以简短的收尾结束。对实在无法中断的处理做临时阻止,见第 4 章;重启后的自动恢复,见第 7 章。

另一项是,即使遇到没有通知的断电,也要留下能读出来的数据。写入临时文件、刷新、替换、备份以及启动时验证,都在第 8 章汇总。实现完通知路径之后,请一直读到第 8 章的保存与恢复设计和第 9 章的验证。

退出通知与断电共用的保存设计靠日常保存把未保存的差量压小,有通知时做简短收尾,没有通知时经过对已保存数据的验证与恢复再重新开始业务有没有在每个节点上保存退出时是否有通知只保存剩余差量并退出当场无法收尾下次启动时验证与恢复恢复业务

图1:不要只在收到通知时才发力,而要把日常保存到下次启动串成一条链。

图中实线表示始终成立的关系,虚线表示带条件的关系(成立条件写在详情页各关系的说明中)。关系的完整列表(共 14 条,附依据与确信度)以及主要概念的定义,汇总在知识地图详情页(日文)。数据:JSON-LD / Turtle

2. 先做区分——注销、关机、重启与断电

2.1. 用户会话与内核要分开看

把“什么东西会结束”分成两类,处理思路就清楚了。用户会话是该用户的应用运行的范围。而内核和驱动程序属于操作系统一侧,在用户注销之后仍继续运行。

操作 用户会话 内核与驱动程序 通知情况
注销 结束 继续运行 GUI 会收到退出查询和确定通知;服务不会因注销而停止
启用快速启动时的关机 结束 把休眠状态保存到 hiberfil.sys GUI 的会话结束通知,以及发给服务的关机通知
重启 结束 完全结束,下次执行完整引导 同上
突然断电 立即丢失 立即丢失 没有通知

在 GUI 的 WM_QUERYENDSESSION 中,lParam 的 ENDSESSION_LOGOFF 位表示注销。如果 lParam 为 0,就是关机或重启,二者无法区分。lParam 要当作位掩码来处理。1

对应用中未保存的数据来说,注销和关机需要同样的准备。不要按“注销就不用保存”来区别对待,而要接到同一套保存处理上。 不过控制台和服务的通知条件并不因此相同,还要分别确认第 5、6 章的路径。

把注销与系统终止分开考虑注销会结束用户的应用但服务继续运行,系统关机或重启时服务也在停止之列,而断电对两者都没有通知注销用户的应用结束服务继续运行关机与重启服务也在停止之列断电对两者都没有通知

图2:用注销可以试 GUI 的退出处理,但不等于连服务的停止处理也试过了。

2.2. “关机不管用、重启才管用”的原因

在 Windows 8 之后的客户端操作系统上,支持休眠的多数 PC 默认启用快速启动。在这种配置下关机时,用户会被注销,但内核和设备驱动程序的状态会保存到休眠文件,并在下次启动时还原。切断电源,并不等于操作系统的状态全部被重置。5

这是有条件的行为。在禁用了休眠的环境(powercfg /hibernate off)、通过策略或电源选项关闭了快速启动的环境,以及 Windows Server 上,关机仍是原来的完整关机。请检查电源选项的配置,并用 powercfg /a 确认快速启动是否可用。

另一方面,“重启”总是执行完整的引导周期。在排查驱动程序异常的步骤里,请明确写“重启”,而不是“关掉电源再重新打开”。5

快速启动与完整引导启用快速启动的关机会保存并还原内核与驱动程序的状态,而完整关机或重启会通过完整引导重新初始化是否关机是否使用快速启动让内核与驱动程序休眠下次启动时还原状态完整关机下次完整引导时初始化重启

图3:切断电源与重置内核、驱动程序不是一回事。

要用命令显式执行完整关机,就用 shutdown /s;要执行混合关机,就用 shutdown /s /hybrid。Shutdown.exe 的默认行为是完整关机,因此不要想当然地认为它和界面上的“关机”一样。5

不建议靠禁用快速启动来解决问题。应用应当在启用和禁用两种情况下都能正常工作。例如,不要只凭操作系统的启动时刻推算设备的“累计运行时间”,而要把内核状态可能被延续这一点纳入设计。

3. GUI 应用——把查询与退出确定分开

3.1. WM_QUERYENDSESSION 是询问,WM_ENDSESSION 是结果

对于拥有窗口和消息队列的应用,会话结束会分两个阶段通知。1

消息 含义 要做的事
WM_QUERYENDSESSION 询问“是否可以退出” 原则上立即返回 TRUE;DefWindowProc 的默认行为也是 TRUE
WM_ENDSESSION,wParam=TRUE 会话结束已经确定 执行简短的保存、断开连接等收尾
WM_ENDSESSION,wParam=FALSE 会话结束被取消 应用继续运行;不要执行只有在确定之后才能做的收尾

即使自己对查询返回了 TRUE,也不一定真的会退出。 别的应用可能拒绝,导致整个过程被取消。如果此时断开连接或交出必需的资源,没有退出的应用就动不了了。把收尾分到确定通知里的理由就在这里。1

GUI 的查询与退出结果即使对 WM_QUERYENDSESSION 返回 TRUE,退出也可能被其他应用取消,因此只在 WM_ENDSESSION 的 wParam 为 TRUE 时才做确定之后的收尾是否WM_QUERYENDSESSION原则上立即返回 TRUEWM_ENDSESSIONwParam 是否为 TRUE确定之后的收尾退出被取消并继续运行

图4:不要把“允许退出”的答复与“退出已确定”的通知混为一谈。

有时也可以返回 FALSE 拒绝退出,但原则是尊重用户的退出意图。拒绝的应用会被显示为“正在阻止关机的应用”。控制台应用和没有可见窗口的应用同样受限:在常规配置下,如果 5 秒内不响应,就可能被自动强制结束。阻止关机只能作为第 4 章的例外处理,不要用于普通的保存。6

3.2. 约 5 秒不是“一定能保存完”的保证

在 WM_QUERYENDSESSION 和 WM_ENDSESSION 的各个阶段,如果把响应拖延约 5 秒,系统就会显示正在阻止关机的应用的界面,用户可以选择强制继续。被强制结束之后,没有机会再接着保存。6

对策是平时就保存,减少退出时的差量。未保存的工作状态先转存到临时位置,下次启动时再还原。不要设计成在关机过程中弹出确认对话框等待用户。 普通的退出确认,与来自操作系统的退出请求要分开处理。1

把退出时的工作量压小平时就保存的设计在退出时剩下的差量很小,而把数据攒在内存里直到退出的设计装不进短暂的宽限期,有被强制结束而丢失的风险平时就保存退出时剩下的差量很小用简短的收尾退出攒在内存里直到退出退出时一次性保存宽限期不足与被强制结束的风险

图5:要在几秒内退出,准备工作必须在收到退出通知之前完成。

3.3. 在 WinForms 与 WPF 中,把保存和不可撤销的收尾分开

在 WinForms 中,对应的是 FormClosing 的 CloseReason.WindowsShutDown;在 WPF 中,对应的是 Application.SessionEnding。WPF 也可以通过 XAML 的 SessionEnding 属性注册,还可以重写 OnSessionEnding 来处理。

不过,这两个都是查询阶段的事件。在这里能做的,只有即使被取消也无害、执行多少次结果都相同的“幂等快照保存”。断开连接这类只有在退出确定之后才能做的处理,要通过 WinForms 的 WndProc 或 WPF 的挂钩接收 WM_ENDSESSION(wParam=TRUE) 后再执行。

WinForms 与 WPF 中退出处理的分工在 FormClosing 与 SessionEnding 中保存即使被取消也安全的快照,再通过挂钩 WM_ENDSESSION 的 TRUE 执行不可撤销的收尾查询阶段FormClosingSessionEnding幂等的快照保存WM_ENDSESSION 的 TRUE用 WndProc 或挂钩接收断开连接等确定之后的处理

图6:不要把退出确定之后的工作也塞进框架的事件里。

下面两个例子,是在查询阶段只保存工作状态的部分。代码示例只是实现的节选,保存函数的内容、失败时的处理、事件注册等都需要由应用自己准备。

// WinForms: 关机或注销时也会调用 FormClosing
private void MainForm_FormClosing(object sender, FormClosingEventArgs e)
{
    if (e.CloseReason == CloseReason.WindowsShutDown)
    {
        // 只做幂等的快照保存,不弹出对话框。
        // 也不要设置 e.Cancel = true(拒绝)。
        SaveWorkingStateToTempFile();
        return;
    }

    // 用户点击×按钮关闭之类的普通情况,可以在这里做确认
}
// WPF: App.xaml.cs
protected override void OnSessionEnding(SessionEndingCancelEventArgs e)
{
    base.OnSessionEnding(e);

    // 虽然可以区分 ReasonSessionEnding.Logoff / Shutdown,
    // 但基本做法是两种情况都执行同样的快照保存
    SaveWorkingStateToTempFile();

    // 没有十分充分的理由,就不要设置 e.Cancel = true
}

两者都应把正常退出、关机以及尽可能连崩溃时都要用到的数据,统一到同一个保存函数和同一种格式上。只要不给每条保存路径各造一种格式,下次启动时的还原逻辑也能保持一条。崩溃时保留信息的设计,在 Windows 应用崩溃时保留日志与转储的设计中有说明。

4. 只对不可中断的处理做临时阻止

4.1. 注册原因与拒绝退出是两件事

像刻录 CD 或写入固件这样、一旦中断就会造成物理损坏的处理属于例外。在处理开始时用 ShutdownBlockReasonCreate 注册原因,完成时用 ShutdownBlockReasonDestroy 解除。 注册的原因会显示在正在阻止关机的应用的界面上。7

不过,只注册原因字符串并不能挡住关机。还要配合一个“保护中”标志,仅在该期间对 WM_QUERYENDSESSION 返回 FALSE。处理结束后,把原因和保护中标志都解除。

临时阻止退出的职责分工只在不可中断的处理进行期间把原因注册与拒绝查询组合起来,完成后两者都解除,但仍无法阻止用户强制继续取消强制继续开始不可中断的处理注册原因并设置保护标志在工作线程中处理完成后解除原因与标志此期间收到退出查询返回 FALSE 并显示原因用户的判断继续运行可能被结束

图7:只注册原因并不等于拒绝,而即使实现了拒绝,也挡不住强制继续。

4.2. 让 UI 线程始终处于能接收退出请求的状态

原因的注册与解除,要从创建目标窗口的那个线程调用,从其他线程调用会失败。7 另一方面,不可中断的长时间处理本身要移到工作线程。因为一旦用同步处理堵住 UI 线程,用于拒绝的消息还没被处理,应用就先变成“无响应”了。

[DllImport("user32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
static extern bool ShutdownBlockReasonCreate(IntPtr hWnd, string pwszReason);

[DllImport("user32.dll", SetLastError = true)]
static extern bool ShutdownBlockReasonDestroy(IntPtr hWnd);

// 必须从创建主窗口的线程调用(从其他线程调用会失败)
_criticalOperationInProgress = true;
ShutdownBlockReasonCreate(this.Handle, "正在把测量数据写入文件");
try
{
    // 不可中断的处理要在工作线程中执行。如果在 UI 线程上同步执行,
    // 消息泵就会停住,在下面拒绝 WM_QUERYENDSESSION 的代码运行之前,
    // 应用会被当成“无响应”而被强制继续
    await Task.Run(() => WriteMeasurementData());
}
finally
{
    ShutdownBlockReasonDestroy(this.Handle);
    _criticalOperationInProgress = false;
}

// 同时,只在保护期间对 WM_QUERYENDSESSION 返回 FALSE 表示拒绝
protected override void WndProc(ref Message m)
{
    const int WM_QUERYENDSESSION = 0x0011;
    if (m.Msg == WM_QUERYENDSESSION && _criticalOperationInProgress)
    {
        m.Result = IntPtr.Zero;   // 拒绝。注册的原因字符串会显示在全屏界面上
        return;
    }
    base.WndProc(ref m);
}

这个例子是把原因注册、工作线程处理和拒绝查询组合起来的骨架。包括 API 调用失败在内的错误处理还需要另行补上。原因字符串要简短具体,而且不要在应用运行期间一直挂着,只限于真正不可中断的处理期间。7

即便如此,用户仍然可以选择强制继续。另外还有 ENDSESSION_CRITICAL 等强制结束路径,因此不能把“挡得住”当作数据保全的前提。挡不住时的准备,就是第 8 章的保存与恢复设计。6

5. 控制台与 .NET——确认通知送达的条件

5.1. 用 SetConsoleCtrlHandler 接收的通知与宽限期

在控制台应用中,控制信号会送到用 SetConsoleCtrlHandler 注册的处理例程。与 GUI 的消息处理不同,这个处理例程在另一个线程上执行。2

信号 触发场景 默认宽限期
CTRL_C_EVENT / CTRL_BREAK_EVENT Ctrl+C / Ctrl+Break 没有明确的超时
CTRL_CLOSE_EVENT 关闭控制台、任务管理器的“结束任务”等 约 5 秒
CTRL_SHUTDOWN_EVENT 系统关机时的服务进程 约 20 秒

从任务管理器的“详细信息”选项卡等处强制结束进程,属于没有通知的立即终止,不在本表的范围内。2

不要把交互式会话中的控制台应用设计成等待 CTRL_LOGOFF_EVENT 或 CTRL_SHUTDOWN_EVENT。 交互式应用在注销时就会被结束,因此实际上只有以服务形式运行的进程才能收到这些信号。此外,一旦加载了 gdi32.dll 或 user32.dll,进程就会被当作 Windows 应用,LOGOFF/SHUTDOWN 系列的处理例程不会被调用。这种情况下官方给出的规避办法,是创建一个不可见的窗口,接收 WM_QUERYENDSESSION / WM_ENDSESSION。28

控制台能收到退出通知的条件交互式控制台即使能收到关闭通知,也不能指望注销和关机通知,而服务一旦加载 GUI 用的 DLL,也需要另一条通知路径交互式会话服务否是控制台进程关闭会送到控制处理例程是否等待 LOGOFF 与 SHUTDOWN不能依赖这些通知是否加载了 GUI 用的 DLL处理相应的控制信号用不可见窗口接收

图8:不要把处理控制台关闭直接当成处理关机。

下面是应对 Ctrl+C 和关闭控制台的节选。保留委托以防被 GC 回收,做完简短的收尾后转到默认处理例程。仅仅注册它,并不能保证交互式应用也能收到关机通知。

// 控制台应用:在 Ctrl+C 与关闭控制台时执行收尾
[DllImport("kernel32.dll", SetLastError = true)]
static extern bool SetConsoleCtrlHandler(HandlerRoutine handler, bool add);

delegate bool HandlerRoutine(int ctrlType);   // 2 = CTRL_CLOSE_EVENT

static readonly HandlerRoutine s_handler = OnCtrlEvent;  // 保留引用以防被 GC 回收

static bool OnCtrlEvent(int ctrlType)
{
    // 只做能在 5 秒内完成的收尾
    FlushAndCloseDataFile();
    return false;   // 转到默认处理例程,进程随之结束
}

static void Main()
{
    SetConsoleCtrlHandler(s_handler, add: true);
    // ...
}

5.2. 从 .NET 10 起,不要把收尾只放在 ProcessExit 里

从 .NET 10 起,运行时不再为 CTRL_CLOSE_EVENT / CTRL_SHUTDOWN_EVENT 提供默认的处理例程。如果没有自己写的或上层库提供的处理例程,进程就会按操作系统的默认处理结束,在这条路径上 AppDomain.ProcessExit 和 AssemblyLoadContext.Unloading 都不会触发。4

这是针对外部退出信号的默认行为变更。它并不意味着从 Main 返回之类的正常退出也不再触发 ProcessExit。

摆脱对 .NET 默认结束处理例程的依赖以前的运行时会把相关信号接到 ProcessExit,而 .NET 10 之后不再提供默认处理例程,因此要使用应用模型自身的通知路径没有有CLOSE 与 SHUTDOWN 信号以前运行时的默认处理触发 ProcessExit 等通知.NET 10 起没有默认处理应用一侧是否有处理按操作系统默认处理结束转到符合模型的收尾

图9:区分正常退出与外部信号导致的退出,并在自己的应用模型里准备必要的处理例程。

收尾要集中到符合应用模型的位置:GUI 用第 3 章的事件和确定通知,Generic Host 用 IHostApplicationLifetime 和 BackgroundService.StopAsync,纯控制台用 SetConsoleCtrlHandler 或 PosixSignalRegistration。使用后者时,也要根据目标退出路径选择 SIGINT、SIGTERM、SIGHUP 等相应的信号。4

在 Generic Host 中要显式设置 HostOptions.ShutdownTimeout。但只改 Host 一侧的配置,并不会延长 GUI、控制台、SCM 各自外层的宽限期。即使 Ctrl+C 没有明确的超时,也仍要为断电和其他退出路径做准备。无论哪种模型,保持已保存的状态、把收到通知后的工作量压小,这个方针都是一样的。

Host 的停止处理与外层的退出宽限期Generic Host 的停止集中在 StopAsync,但设置 HostOptions 的超时并不会延长操作系统或 SCM 的退出宽限期,因此两边都要确认来自操作系统或 SCM 的退出请求Host 的停止路径在 StopAsync 中收尾设置 Host 一侧的停止时间外层的宽限期另行存在实测能否在短时间内完成

图10:分别确认 Host 一侧与操作系统一侧的时间限制,不要只靠调大配置了事。

6. Windows 服务——控制处理例程要立即返回

6.1. SHUTDOWN 与 PRESHUTDOWN 的区别

服务不会因注销而停止,但在关机和重启时属于停止对象。通知来自 SCM(服务控制管理器),需要声明与要接收的控制码相对应的标志。3

声明的标志 送达的控制码 如何取舍
SERVICE_ACCEPT_SHUTDOWN SERVICE_CONTROL_SHUTDOWN 普通的关机通知。默认宽限期约 20 秒,取决于 WaitToKillServiceTimeout
SERVICE_ACCEPT_PRESHUTDOWN SERVICE_CONTROL_PRESHUTDOWN 比普通的 SHUTDOWN 更早收到通知。SCM 会一直等到服务停止或达到所配置的超时
发给服务的退出通知的阶段SCM 先通知接受 PRESHUTDOWN 的服务并等待其停止或超时,之后再进入普通的 SHUTDOWN 通知系统开始关机通知接受 PRESHUTDOWN 的服务等待停止或所配置的期限通知接受 SHUTDOWN 的服务宽限期过后继续走向系统终止

图11:通知的先后与等待时长是两个不同的条件,PRESHUTDOWN 也有配置好的期限。

PRESHUTDOWN 的超时用 ChangeServiceConfig2(SERVICE_CONFIG_PRESHUTDOWN_INFO) 配置。默认值是 Windows 10 Creators Update(内部版本 15063)之后为 10 秒,之前为 3 分钟。如果以为“只要用 PRESHUTDOWN 就一定能等 3 分钟”,就拿不到预期的时间。9

PRESHUTDOWN 会让整个系统的关机一直等待,因此只在真正需要时才使用。由服务一侧改写普通的 WaitToKillServiceTimeout 来延长时间,同样不被推荐。3

6.2. 把接收通知的处理与实际停止的处理分开

控制处理例程必须在 30 秒内返回,但不要理解成“可以用满 30 秒”,而要做成下达停止指令后立即返回的结构。耗时的处理交给另一个线程,并报告 SERVICE_STOP_PENDING。3

// Win32 服务: 接受 PRESHUTDOWN,停止处理交给工作线程
g_status.dwControlsAccepted = SERVICE_ACCEPT_STOP | SERVICE_ACCEPT_PRESHUTDOWN;

DWORD WINAPI ServiceHandlerEx(DWORD control, DWORD, LPVOID, LPVOID)
{
    switch (control)
    {
    case SERVICE_CONTROL_PRESHUTDOWN:
    case SERVICE_CONTROL_STOP:
        ReportStatus(SERVICE_STOP_PENDING, /*waitHintMs*/ 3000);
        SetEvent(g_stopEvent);   // 通知工作线程停止,并立即返回
        return NO_ERROR;
    }
    return ERROR_CALL_NOT_IMPLEMENTED;
}

// 工作线程一侧: 如果收尾比 waitHint 更久,就一边递增 dwCheckPoint,
// 一边定期持续报告 SERVICE_STOP_PENDING。SCM 依据 waitHint 与检查点的
// 推进来判断“还活着并在推进”。一旦报告中断,就会被当成挂起,
// 关机可能继续往下走。完成后必须报告 SERVICE_STOPPED

在工作线程一侧,如果收尾比等待提示更久,就一边推进 dwCheckPoint 一边报告 SERVICE_STOP_PENDING。进度报告一旦中断,就可能被判定为挂起。停止处理要一直做到完成后报告 SERVICE_STOPPED 为止。39

服务控制处理例程与工作线程的分工控制处理例程报告正在停止并向工作线程发出信号后立即返回,由工作线程执行收尾与进度报告并在最后报告已停止控制处理例程报告 STOP_PENDING向工作线程发出停止信号处理例程立即返回工作线程执行收尾耗时较久时报告进度完成时报告 STOPPED

图12:不要用耗时的停止处理堵住接收通知的线程。

6.3. 即使所依赖的服务先停止,也要能在短时间内结束

关机时的 SCM 默认不考虑服务之间的依赖关系就发出通知。停止处理要能应对所依赖的服务已经不可用的情况,不要过久地等待网络对端的回应。与其把时间花在释放内存上,不如优先在短时间内把必要的数据落定。3

这个方针在与 UPS 配合时同样重要。服务把等待拖得越久,就越难在电池耗尽之前完成整个操作系统的关机。与其加长宽限期,不如靠按节点保存来减少停止时的工作量,这才是基本做法。3

用 UseWindowsService 运行 .NET 的 Worker Service 时,STOP / SHUTDOWN 会被转换成主机的停止,进而接到 BackgroundService.StopAsync。原文撰写时的标准实现不接受 PRESHUTDOWN,需要的话得自行扩展处理例程。显式设置 HostOptions.ShutdownTimeout、让 StopAsync 本身在几秒内结束,这个方针没有变化。整体实现请参阅 Windows 服务的创建与运维。

7. 重启后的恢复——把注册、还原数据与登录凑齐

7.1. RegisterApplicationRestart 是对恢复路径的事先注册

在设备 PC 和无人值守运行的场景中,设计不能止于保存后退出,还要一直做到重启后恢复业务。RegisterApplicationRestart 是一个 API,可以在崩溃(未处理异常)、无响应、更新导致的应用重启、更新导致的操作系统重启这几种场景下,把应用登记为重启对象。10

可以在重启时的命令行参数里指定当时打开的文件或还原点。不过,仅仅注册并不意味着在任何情况下都能自动恢复。

检查项 条件与限制
注册的时机 在问题发生之前。在更新场景中,处理 WM_QUERYENDSESSION 期间是最后的机会
防止重启循环 启动不足 60 秒的进程不会被重启
崩溃与挂起 需经用户同意后重启。更新导致的重启是自动的
跨越操作系统重启时 发起方必须带上 EWX_RESTARTAPPS / SHUTDOWN_RESTARTAPPS 调用关机 API
提升权限的进程 不属于自动重启的对象,需要另一条显式的启动路径

对需要提升权限的应用,可以把界面放在标准权限下、把特权处理分离到服务中,或者利用任务计划程序的“使用最高权限运行”任务等方式来设计恢复路径。10

7.2. 恢复回调与 ARSO 各有各的职责

同时使用 RegisterApplicationRecoveryCallback 时,崩溃后 WER(Windows 错误报告)会调用恢复回调,从而可以保存正在处理的数据。保存持续期间,要在所注册的 ping 间隔内调用 ApplicationRecoveryInProgress,完成时用 ApplicationRecoveryFinished 通知。进度通知一旦中断,恢复处理就可能被中止。

恢复回调中的保存与进度通知被 WER 调用的恢复回调在保存期间要按 ping 间隔持续调用 ApplicationRecoveryInProgress,并在完成时通知 ApplicationRecoveryFinished尚未已完成事先注册恢复回调崩溃后由 WER 调用保存正在处理的数据在 ping 间隔内通知进度保存是否已完成通知恢复完成中断就可能被终止

图13:不仅要保存,还要把进度和完成情况通知给 WER。

更新时替换正在使用的文件并重启,是 Restart Manager 的职责。这在如何替换正在使用中的 exe/DLL中有说明。

另一方面,在操作系统重启后把用户会话恢复回来的机制是 ARSO(Winlogon 自动重启登录)。当 Windows Update 开始自动重启时,它会安全地保存最后一位交互用户的凭据并配置 Autologon,在重启后让该用户登录并锁定屏幕。11

重启注册、登录与数据还原即使事先做了重启注册,崩溃时也需要用户同意,而操作系统重启后的用户应用还需要 ARSO 等机制恢复会话并还原已保存的状态在问题发生前注册重启崩溃或无响应取得用户同意重启应用带必要标志的操作系统重启用 ARSO 等恢复会话读取已保存的还原点确认策略与启动条件

图14:不仅要注册应用重启,还要准备好会话与工作状态恢复回来的路径。

shutdown /g 是用来指示重启并恢复已注册应用的命令。ARSO 有可能被 DisableAutomaticRestartSignOn 等组织策略禁用,因此要连同无人值守恢复的要求一起确认。始终需要运行的后台处理,与其依赖用户自动登录,不如做成 Windows 服务更合适。11

8. 没有通知的断电——把保存与读取成对设计

8.1. 把临时文件、替换、备份与启动时验证做成一套

空气开关跳闸、电源模块故障、插头脱落,这些情况既没有 WM_ENDSESSION 也没有 PRESHUTDOWN。如果一直直接覆写原文件,中途被切断时就可能留下新旧内容混在一起的文件。

基本做法是,先把内容完整写入同一卷上的临时文件,刷新之后再进行替换。ReplaceFile 把相当于“保存到新文件、转存原文件、重命名、删除”的一连串步骤合并起来,并且会继承创建时间、ACL、备用流等属性。被替换的文件、替换用的文件和备份必须位于同一个卷上。.NET 的 File.Replace 调用的就是这个 API。12

文件保存与下次启动时的恢复写入同一卷上的临时文件并刷新,留下备份后完成替换,下次启动时验证主文件并在必要时回退到备份是否同一卷上的临时文件完整写入并刷新替换并把旧内容留作 bak下次启动主文件是否正常读取主文件回退到备份

图15:不仅要实现安全的写法,也要一并实现损坏时的读取方式。

// 配置与数据保存的标准做法: 先完整写入临时文件再替换,并保留旧内容
public static void SaveAtomically(string path, string content)
{
    string dir = Path.GetDirectoryName(path)!;
    string tmp = Path.Combine(dir, Path.GetRandomFileName());  // 创建在同一个卷上

    try
    {
        using (var fs = new FileStream(tmp, FileMode.CreateNew, FileAccess.Write))
        using (var writer = new StreamWriter(fs))
        {
            writer.Write(content);
            writer.Flush();
            fs.Flush(flushToDisk: true);   // 相当于 FlushFileBuffers,把操作系统的缓冲
                                           // 写到磁盘(设备一侧缓存的局限见 8.2 节)
        }

        if (File.Exists(path))
            File.Replace(tmp, path, path + ".bak");  // 调用 ReplaceFile,把旧内容留作 .bak
        else
            File.Move(tmp, path);
    }
    catch
    {
        // 中途失败时不要留下临时文件。定期保存持续失败的话,
        // 完整的副本会逐渐把卷占满
        try { File.Delete(tmp); } catch { /* 删除失败时以原异常为准 */ }
        throw;
    }
}

虽然这个例子叫 SaveAtomically,但它并不保证跨越断电的原子性。 在正常运行中,它能让读到的要么是完整的旧文件、要么是完整的新文件;但 ReplaceFile 是多步的命名空间操作,规格上并不保证在突然断电时的原子性。正因如此才要保留 .bak,并把启动时验证主文件、损坏时回退到备份的读取处理一并实现。12

例子里的 catch,是为了在普通失败时不让临时文件堆积、把卷占满。它并不指望在断电时还能运行。对追加型的日志和 CSV,不要直接套用整文件替换的方式,而应采用“一行写一条记录,读取时丢弃损坏的末行”这类把损坏方式考虑在内的格式。

8.2. 区分 WriteFile 成功与数据到达磁盘

即使 WriteFile 成功,数据也可能仍在操作系统的缓存里。Windows 通常先写入系统缓冲,再通过延迟写入落到磁盘。在重要节点上,要用 FlushFileBuffers 刷新,或在调用 CreateFile 时指定 FILE_FLAG_WRITE_THROUGH 来要求立即写入。文件系统的元数据同样会被缓存,因此让元数据落定也与刷新或 write-through 有关。13

不过,write-through 与“不使用操作系统缓存”并不是一回事。频繁调用 FlushFileBuffers 效率低下,必要时可以考虑与 FILE_FLAG_NO_BUFFERING 搭配使用。实际工作中,在事务的节点或关闭文件之前等对数据保全重要的位置刷新,是比较现实的设计。13

写入成功与持久化之间的界线普通的写入会先进操作系统缓存再延迟落到存储,即使用刷新或 write-through 促使其落定,设备一侧缓存的制约依然存在WriteFile 成功可能仍在操作系统缓存中延迟写入在节点上刷新写入存储设备一侧缓存的局限

图16:不要把 API 成功、操作系统缓存落定和抗断电能力当成同一件事。

设备一侧的易失缓存同样有局限,不能断言“只要刷新了,在任何硬件上都能完全扛住断电”。缓存管理器、延迟写入与硬件缓存之间的关系,在缓存管理器:你的 WriteFile 何时才能到达磁盘中有详细说明。

8.3. UPS 的用途,是把断电接到有计划的关机上

UPS 的作用不是消除停电,而是把没有通知的断电,变成有通知的计划关机。需要满足的条件是下面这个关系。

UPS 的电池续航时间 > 检测到切换 + 应用与服务的收尾 + 操作系统完成关机所需的时间

交流电与电池之间的切换以及电量降低,会通过 PBT_APMPOWERSTATUSCHANGE 通知。GUI 通过 WM_POWERBROADCAST 接收;服务则要先声明 SERVICE_ACCEPT_POWEREVENT,再在 HandlerEx 中接收 SERVICE_CONTROL_POWEREVENT。WM_POWERBROADCAST 并不会送到服务的控制处理例程。14

收到之后,用 GetSystemPowerStatus 确认 ACLineStatus 和 BatteryLifePercent,再接到中断测量、保存以及发出关机请求上。14

从 UPS 的检测到关机完成通过相应的通知路径检测到 UPS 切换为电池供电,确认电源状态后进入保存与关机,并把整体设计在电池续航时间之内停电,UPS 切到电池通过相应路径收到电源通知确认电源状态与剩余电量中断测量并保存向操作系统发出关机请求完成收尾与操作系统的关机把整体控制在续航时间内

图17:不只是应用,连操作系统关完机的时间也要装进 UPS 的续航时间里。

对于 USB 连接的常见 UPS 等在 Windows 看来就是电池的配置,可以用标准 API 监视。如果厂商的管理软件带有“剩余电量 N% 时关机”的功能,还要确认这个阈值与收尾时间是否匹配。从睡眠和休眠恢复属于另一条主线,请参阅睡眠、休眠、Modern Standby 与长时间运行应用。

9. 验证——确认通知、时间与恢复结果这三项

9.1. 不在生产机上,而在验证机上复现退出路径

不要直接在生产用的设备 PC 上尝试,而要使用验证机或 Hyper-V 之类的虚拟机。在虚拟机上先建立检查点,用丢了也没关系的验证数据反复试验。

尝试的操作 要确认的内容
注销 GUI 的 WM_QUERYENDSESSION→WM_ENDSESSION 与保存处理。不能代替对服务停止的验证
shutdown /s /t 0 完整关机时的行为
shutdown /s /hybrid /t 0 使用快速启动的配置下的混合关机行为
shutdown /r /t 0 伴随完整引导的重启,以及此后的恢复
关闭虚拟机电源 客户机操作系统在没有通知的情况下停止后,下次启动时能否恢复
在与生产等同的实际设备上断电 包含物理存储和控制器在内的耐受能力

注销时会有 ENDSESSION_LOGOFF 位被置位这一差异,但它便于快速确认 GUI 的通知路径。完整关机、混合关机和重启的命令不要混淆,要分别试验。15

逐步扩大关机验证的范围先在验证环境中试验 GUI 通知与各种退出操作,用虚拟机确认突然停止后的恢复,再在与生产等同的实际设备上确认包含存储在内的抗断电能力准备验证机与数据试验通知路径与退出操作测量收尾所需的时间用虚拟机突然停止确认恢复连同实际设备的存储一起验证确认下次启动的数据与恢复

图18:不仅要试能否正常退出,还要试突然停止之后能恢复出什么。

关闭虚拟机电源能复现的,只有客户机毫无预告地停止这一点。物理磁盘易失缓存丢失以及依赖控制器的损坏方式都无法复现,因此作为设备 PC 出货时的最终确认,要使用与生产等同的硬件。

在收尾函数的开头和结尾记录时刻日志,实测它能否装进 GUI 等约 5 秒,或为服务配置的宽限期之内。不仅要确认是否正常退出,还要确认下次启动时读取到了什么、能从哪里继续。

9.2. 夜里发生了什么,要从事件日志来定位

在 Windows 的 System 日志中,事件 ID 1074 会记录发起关机的进程、用户和原因。发生意外关机时,下次启动会记录 41(Kernel-Power)和 6008。15

# 查看关机相关事件的最近记录
Get-WinEvent -FilterHashtable @{ LogName = 'System'; Id = 1074, 6008, 41 } -MaxEvents 20 |
    Select-Object TimeCreated, Id, ProviderName, Message |
    Format-List

如果 1074 显示的是 Windows Update 引起的重启,数据却坏了,那就先查退出通知的处理和保存路径。另一方面,不能只凭 41 或 6008 就断定是断电。它们表示的只是意外终止,蓝屏和强制重置同样是候选原因。15

从终止事件日志中选定调查对象从 1074 确认发起进程与原因,把 41 和 6008 当作意外终止的线索,与 bug check 代码、转储等周边信息相互对照System 事件日志1074,发起进程与原因调查通知处理与保存路径41、6008,意外终止确认 BugcheckCode 与转储区分崩溃与断电等原因

图19:41 和 6008 不是断电本身的证明,而是进一步调查的入口。

如果 41 的 BugcheckCode 不为 0,那就是崩溃的线索。若为 0 且没有内存转储,就可以怀疑是断电,但仍要与周边信息对照之后再判断。确认是断电,就重点查第 8 章的保存设计和 UPS;如果是有计划的终止,就重点查第 3 到第 6 章的通知与收尾。

10. 小结——不只设计退出处理,还要设计到下次启动

应对关机,并不是“在退出时只运行一次的事件处理例程”这么一件事。重要的是把日常保存 → 简短收尾 → 下次启动时的验证与恢复串成一条链。

需要复查的地方 设计要点
日常处理 勤保存,减少退出时剩下的差量
GUI 的退出通知 对查询原则上立即返回 TRUE。把幂等保存与确定之后的收尾分开
控制台与服务 使用符合应用模型的通知。不要只依赖 .NET 的 ProcessExit 或延长宽限期
不可中断的处理 只在必要期间把原因注册与拒绝组合起来。也要为强制继续做好准备
保存与读取 在临时文件、刷新、替换之外,还要准备备份与启动时验证
重启之后 确认重启注册、还原数据,以及登录或服务的启动路径

在启用快速启动的关机中,内核和驱动程序有可能是从休眠状态恢复回来的。排查异常时要明确写“重启”,应用一侧则要做到在完整关机和混合关机下都能正常工作。5

从退出处理到恢复的最终确认在日常处理中保持已保存的状态,收到退出通知时做少量收尾,即使没有通知也要验证留存的数据并在下次启动时恢复有没有平时就保持已保存的状态是否有退出通知用简短的收尾退出只能依靠最后一次保存的状态下次启动时验证与恢复在必要条件下恢复业务

图20:不要把退出事件的实现与日常保存和下次启动的恢复割裂开。

最后,在验证机上试验通知路径和所需时间,并一直确认到突然停止之后的恢复。事后调查时,以 1074 / 41 / 6008 为线索,区分是有计划的终止还是意外终止。

下次添加功能时,请确认“如果在这段处理进行中收到退出通知,或者电源被拔掉,下次启动时还会剩下什么”。把这个答案纳入设计,才是对“到了早上才发现数据损坏”这类问题的准备。

相关文章

相关咨询领域

小村软件有限公司承接设备 PC 与长时间运行应用的关机、断电对策的设计与实现,针对由 Windows Update 重启或注销引发的数据损坏、“早上发现已经停了”这类故障的原因调查,以及 Windows 服务停止处理与自动恢复相关的设计评审。从“总觉得每次关机都会坏点什么,但不知道该从哪里下手”这个阶段开始咨询也完全可以。

参考链接

  1. Microsoft Learn, WM_QUERYENDSESSION message. 关于会话结束时会发送 WM_QUERYENDSESSION、应用应返回 TRUE 以尊重用户意图(DefWindowProc 的默认行为也是 TRUE),关于收尾应推迟到 WM_ENDSESSION,关于 5 秒后系统会显示正在阻止关机的应用的界面且用户可以强制结束,关于 lParam 中 ENDSESSION_LOGOFF/CLOSEAPP/CRITICAL 各位的含义,关于无法区分关机与重启,以及关于应勤保存数据以减少退出时的保存量。 ↩ ↩2 ↩3 ↩4 ↩5 ↩6

  2. Microsoft Learn, HandlerRoutine callback function. 关于用 SetConsoleCtrlHandler 注册的处理例程会收到 CTRL_C/BREAK/CLOSE/LOGOFF/SHUTDOWN 各事件,关于 CTRL_CLOSE_EVENT 的默认超时约为 5000 毫秒、服务进程的 CTRL_SHUTDOWN_EVENT 约为 20000 毫秒,关于 CTRL_LOGOFF/SHUTDOWN_EVENT 由于交互式应用在注销时就会被结束因而实际上只有服务才能收到,以及关于处理例程在另一个线程上执行。 ↩ ↩2 ↩3 ↩4

  3. Microsoft Learn, Service Control Handler Function. 关于声明了 SERVICE_ACCEPT_PRESHUTDOWN 的服务会先收到 SERVICE_CONTROL_PRESHUTDOWN、之后声明 SERVICE_ACCEPT_SHUTDOWN 的服务才收到 SERVICE_CONTROL_SHUTDOWN,关于关机时的默认宽限期约为 20 秒、操作系统重启时的上限是 WaitToKillServiceTimeout,关于不应延长这个值,关于控制处理例程应在 30 秒内返回并报告 STOP_PENDING 与等待提示、把耗时处理交给另一个线程,关于考虑到 UPS 供电应尽可能快地完成收尾,以及关于关机时的 SCM 默认不考虑依赖关系。 ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7

  4. Microsoft Learn, .NET runtime no longer provides default termination signal handlers. 关于从 .NET 10 起运行时不再为 Windows 的 CTRL_SHUTDOWN_EVENT/CTRL_CLOSE_EVENT(相当于 Unix 的 SIGTERM/SIGHUP)提供默认处理例程,关于操作系统的默认处理会立即结束应用因而 AppDomain.ProcessExit 和 AssemblyLoadContext.Unloading 不再触发,以及关于符合应用模型的信号处理应由上层库或应用代码自行注册。 ↩ ↩2 ↩3

  5. Microsoft Learn, Fast startup causes hibernation or shutdown to fail in Windows 10 or Windows 8.1. 关于快速启动不会关闭内核会话而是按休眠处理、内核与设备驱动程序的状态会保存到 hiberfil.sys,关于“重启”因为需要全新的 Windows 状态所以总是执行完整引导,关于快速启动默认启用且不建议禁用,以及关于 Shutdown.exe 的默认行为是完整关机、加 /hybrid 选项才是混合关机。 ↩ ↩2 ↩3 ↩4 ↩5

  6. Microsoft Learn, Shutdown Changes for Windows Vista. 关于对 WM_QUERYENDSESSION/WM_ENDSESSION 的响应各自最多只能推迟 5 秒、之后由用户选择继续或取消,关于控制台应用和没有可见窗口的应用无法取消关机且在 5 秒无响应或返回 FALSE 时会被自动结束,关于需要阻止时应用 ShutdownBlockReasonCreate 注册原因,以及关于应用不得依赖“能够阻止关机”这一点。 ↩ ↩2 ↩3

  7. Microsoft Learn, ShutdownBlockReasonCreate function (winuser.h). 关于在不可中断的处理开始时调用它注册原因字符串、完成时调用 ShutdownBlockReasonDestroy,关于只能从创建窗口的线程调用,以及关于用户只会花几秒读这段原因因而字符串应简短明确。 ↩ ↩2 ↩3

  8. Microsoft Learn, SetConsoleCtrlHandler function. 关于加载了 gdi32.dll 或 user32.dll 的进程会被当作 Windows 应用、CTRL_LOGOFF_EVENT/CTRL_SHUTDOWN_EVENT 的处理例程不会被调用,关于其规避办法是创建不可见窗口并处理 WM_QUERYENDSESSION/WM_ENDSESSION,以及关于在处理信号期间控制台函数有时无法正常工作。 ↩

  9. Microsoft Learn, SERVICE_PRESHUTDOWN_INFO structure (winsvc.h). 关于 PRESHUTDOWN 通知之后 SCM 会一直等到服务停止或超时,关于默认超时在 Windows 10 Creators Update(内部版本 15063)之后为 10 秒、之前为 3 分钟,关于用 ChangeServiceConfig2 进行配置,以及关于在 SERVICE_STOP_PENDING 期间可以持续更新状态。 ↩ ↩2

  10. Microsoft Learn, RegisterApplicationRestart function (winbase.h). 关于可以在崩溃、无响应、更新以及更新引起的计算机重启这几种场景下注册重启,关于可以指定重启时的命令行参数,关于注册应在问题发生之前完成且在更新场景中处理 WM_QUERYENDSESSION 期间是最后的机会,关于启动不足 60 秒的进程不会被重启,关于崩溃和挂起时需经用户同意后才重启,以及关于跨越操作系统重启需要以 EWX_RESTARTAPPS/SHUTDOWN_RESTARTAPPS 发起关机。 ↩ ↩2

  11. Microsoft Learn, Winlogon automatic restart sign-on (ARSO). 关于 Windows Update 开始自动重启时会保存最后一位交互用户的凭据并配置 Autologon,关于重启后会让该用户自动登录并锁定会话,关于登录成功后已保存的凭据会被删除,以及关于可以通过组策略(DisableAutomaticRestartSignOn 等)进行配置。 ↩ ↩2

  12. Microsoft Learn, ReplaceFileW function (winbase.h). 关于 ReplaceFile 把相当于“保存到新文件、临时重命名原文件、重命名新文件、删除原文件”的多个步骤合并到一个函数里,关于它会保留创建时间、DACL、加密、压缩、命名流等原文件的属性,以及关于备份、被替换的文件和替换用的文件必须位于同一个卷上。 ↩ ↩2

  13. Microsoft Learn, File Caching. 关于写入默认会进入系统缓存并通过延迟写入落到磁盘,关于用 FILE_FLAG_WRITE_THROUGH 可以让写入立即写到磁盘,关于用 FlushFileBuffers 可以显式刷新,以及关于文件系统的元数据总是被缓存因而让元数据落定需要刷新或 write-through。 ↩ ↩2

  14. Microsoft Learn, PBT_APMPOWERSTATUSCHANGE event. 关于在电池与交流电之间切换或电量降低时会通过 WM_POWERBROADCAST 通知这个事件,以及关于收到之后应调用 GetSystemPowerStatus 确认 SYSTEM_POWER_STATUS 的 ACLineStatus、BatteryFlag、BatteryLifePercent 等字段。 ↩ ↩2

  15. Microsoft Learn, Troubleshoot unexpected reboots using system event logs. 关于正常重启会记录事件 ID 1074(哪个进程为谁、出于什么原因发起了关机),关于意外重启会记录事件 ID 41(Kernel-Power)和 6008(上一次关机是意外的),以及关于可以用这些 ID 区分重启的类型。 ↩ ↩2

共享相同标签的最新文章。可以围绕相近的主题进一步加深理解。

与本文相近的主题页面。以本文为起点,可以进一步了解相关服务和其他文章。

本文与以下服务页面相关联,欢迎从最接近的入口查看。

常见问题

汇总了咨询这一主题时常见的问题。

“关机”没能解决的问题,“重启”之后却好了。这是为什么?
在 Windows 8 之后的客户端操作系统上,如果启用了快速启动(支持休眠的多数 PC 的默认设置),“关机”走的是混合关机机制。用户会被注销,但内核和驱动程序的状态会保存到休眠文件,并在下次启动时原样还原。也就是说,操作系统的核心部分并没有被重置。而“重启”总是执行完整引导,因此驱动程序和服务的异常会被清掉。在排查步骤里请明确写“重启”,而不是“关机后重新加电”。要用命令执行完整关机,可以用 shutdown /s。
能不能挡住关机,等应用把数据保存完?
可以让它暂时等一会儿,但无法可靠地挡住。只在不可中断的处理进行期间用 ShutdownBlockReasonCreate 注册原因字符串,“此应用正在阻止关机”的界面上就会显示这个原因,用户可以判断是继续还是取消。不过用户仍然可以选择强制继续,而强制关机或更新导致的重启也可能根本不等。因此正路不是“挡住”,而是靠频繁自动保存减少会丢失的数据,并把收尾设计成从收到退出通知起几秒内就能完成。
Windows 服务的停止处理很耗时。关机时的宽限期能延长吗?
在默认接收 SERVICE_CONTROL_SHUTDOWN 的配置下,宽限期大致为 20 秒左右,取决于注册表中的 WaitToKillServiceTimeout。不建议由应用自行改写这个值来延长。如果需要更长的宽限期,可以声明 SERVICE_ACCEPT_PRESHUTDOWN 来接收 SERVICE_CONTROL_PRESHUTDOWN,它会比其他服务更早收到通知,超时可以用 ChangeServiceConfig2 配置(默认值在 Windows 10 Creators Update 之后为 10 秒,之前为 3 分钟)。但 PRESHUTDOWN 会让整个关机过程一直等待,因此只限真正需要的场合,根本上还是应把停止处理本身设计成几秒内完成。
用 .NET 的 AppDomain.ProcessExit 做关机时的收尾可以吗?
建议不要依赖它。以前运行时会注册默认的信号处理例程,CTRL_CLOSE_EVENT 和 CTRL_SHUTDOWN_EVENT 会触发 ProcessExit 事件;但从 .NET 10 起,运行时不再提供默认的结束信号处理例程,在这些场景下 ProcessExit 也就不再触发。请按照符合应用模型的通知路径来实现收尾:GUI 应用用 FormClosing 或 SessionEnding(不过它们属于查询阶段的通知,只能做幂等保存,只有在退出确定之后才能做的收尾要通过挂钩 WM_ENDSESSION 来执行),Generic Host / Worker Service 用 IHostApplicationLifetime 和 StopAsync,控制台应用用 SetConsoleCtrlHandler 或 PosixSignalRegistration。
怎样才能避免突然断电把文件弄坏?
断电完全没有通知,所以只能采用“无论什么时候被切断都不会坏”的写法。基本做法是不要直接覆写原文件,而是把内容完整写入同一卷上的临时文件并刷新,再用 ReplaceFile(.NET 中是 File.Replace)进行替换。在正常运行中,这能保证读到的要么是完整的旧文件、要么是完整的新文件;但 ReplaceFile 跨越断电的原子性在规格上并没有保证,因此要保留备份(第三个参数),并把启动时验证主文件、损坏时回退到备份的读取处理一并实现。另外,WriteFile 成功并不表示数据已经到达磁盘,在重要节点要用 FlushFileBuffers 或 FILE_FLAG_WRITE_THROUGH 让写入落定。在设备 PC 上,搭配 UPS、检测到切换为电池供电后引导系统安全关机,是常规做法。

作者简介

本文作者的个人简介页面。

Go Komura

小村软件有限公司 代表

以 Windows 软件开发、技术咨询与故障排查为中心,擅长难以复现的故障调查,以及既有资产仍在运行的项目。

返回博客列表