从应用看到的 Windows 关机 ── 正确扛住退出通知、重启与断电

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

「夜间 Windows Update 重启时,设备 PC 上的测量应用写到一半就停了,早上测量档已经坏了。」「有人从共用 PC 注销,抱怨未保存的编辑不见了。」── 对长时间运行的 Windows 应用来说,这两种咨询是经典款。

两个现场的共通点,是把关机当成「不该发生的异常事件」。实际上,从 Windows Update 自动重启、用户注销、UPS 发起的关机,到毫无预告的断电,从应用外面切断执行的事件迟早会来。你挡不住它来。挡得住的是「来的时候把数据弄丢」。

幸好,无论 GUI 应用、控制台应用还是服务,Windows 都有在关机前通知应用的机制。本文以中小企业信息人员与 Windows 应用开发者(尤其是设备 PC 与长时间运行应用)为对象,依 2026 年 8 月当下的 Microsoft Learn 一次信息,整理怎么接这些通知、怎么把收尾设计成「几秒内做完」、重启后怎么自动恢复,以及完全没有通知的断电该怎么准备。

1. 先讲结论

  • 把关机设计成「迟早会来的正常事件」。收到通知后能用的时间原则上只有大约 5 秒,当场手忙脚乱存一切的设计会崩。前提是平常就频繁自动保存,让「关机时必须存的差额」保持很小。1
  • 从 Windows 8 起的客户端 OS,在开启快速启动时(多数支持休眠的 PC 默认如此),「关机」是混合关机,核心只是在休眠。真正完整重设的只有「重启」。这才是「关机没好、重启就好了」的真正原因。2
  • GUI 应用应对 WM_QUERYENDSESSION 立刻回 TRUE,收尾放在 WM_ENDSESSION。原则上不可回 FALSE(拒绝)。1
  • 只有真的有不可中断的操作时,才用 ShutdownBlockReasonCreate 显示原因。即便如此,用户和 OS 仍可强制继续,因此「我们挡得住」的设计不成立。34
  • 控制台应用用 SetConsoleCtrlHandler 接收通知。宽限期更短──关闭控制台默认 5 秒。还有一个陷阱:载入了 gdi32.dll 或 user32.dll 的处理程序,有些事件不会送到。56
  • 依赖 .NET AppDomain.ProcessExit 的收尾,从 .NET 10 起,在处理程序「从外面被结束」的路径上不会跑。从 Main 正常返回时仍会跑,但运行时不再为关闭控制台与关机这类结束信号提供默认处理,那些路径的收尾必须改到符合应用模型的通知。7
  • Windows 服务可用 SERVICE_ACCEPT_PRESHUTDOWN,比 SERVICE_ACCEPT_SHUTDOWN(约 20 秒宽限)更早收到、且宽限期可设置。不过 PRESHUTDOWN 默认超时从 Windows 10 Creators Update 起缩成 10 秒,无论哪条路都不要过度依赖宽限期。89
  • 重启后的自动恢复,可把 RegisterApplicationRestart 与 ARSO(自动登录)合在一起。崩溃、无响应、更新触发的重启都有恢复路径。1011
  • 断电完全没有通知。标准模式是写完整临时文件、刷新、再用 ReplaceFile 交换;但 ReplaceFile 跨断电也不保证原子性,因此备份(.bak)加上载入时验证是一套。事后隔离可从事件记录(1074/41/6008)做。121314

用一句话说,本文的结论是:「随时保持通知一来就能在几秒内打烊的状态,并且用即使没有通知的断电也不坏的方式写档」

2. 关机时发生什么 ── 四种结束方式

2.1. 注销、关机、重启与断电

从应用的角度看,重要的是两轴:「用户工作阶段怎么结束」以及「核心会怎样」。

操作 用户工作阶段 核心与驱动程序 给应用的通知
注销 结束 继续跑 WM_QUERYENDSESSION(ENDSESSION_LOGOFF)→ WM_ENDSESSION
关机(开启快速启动) 结束 休眠(存进 hiberfil.sys) WM_QUERYENDSESSION → WM_ENDSESSION,服务收到(PRE)SHUTDOWN
重启 结束 完全结束;下次是完整启动 同上
断电 立刻消失 立刻消失 没有

注销与关机,从应用看几乎是同一件事。WM_QUERYENDSESSION 的 lParam 若设了 ENDSESSION_LOGOFF 位元就是注销;若是 0 就是关机或重启(两者分不出来)。1 也就是说,「只是注销,没关系」这种松懈不成立,正确设计是呼叫同一段收尾代码

四种结束方式与给应用的通知注销、关机与重启都会送到 WM_QUERYENDSESSION 到 WM_ENDSESSION 的通知,收尾在几秒内做完。只有断电完全没有通知,因此用第 8 章的写入设计与 UPS 来准备注销QUERY → ENDSESSION关机重启断电无通知:写入 + UPS几秒内收尾

图 1: 注销、关机与重启都会送到 WM_QUERYENDSESSION 到 WM_ENDSESSION 的通知,收尾在几秒内做完。只有断电完全没有通知,因此用第 8 章的写入设计与 UPS 来准备。

2.2. 「关机没好」的真正原因 ── 混合关机

表里容易漏看的是第二列。从 Windows 8 起的客户端 OS,支持休眠的 PC 默认开启快速启动(混合关机),「关机」的行为变了。用户工作阶段仍会照常注销,但核心工作阶段不会关闭;连同装置驱动程序一起存进休眠文件(hiberfil.sys),下次启动原样还原。这样启动较快,但核心与驱动程序状态即使拔电也还在。2 不过这是有条件的行为。休眠本身被关掉(powercfg /hibernate off)、原则或电源选项关掉快速启动、以及 Windows Server 上,关机仍是传统的完整关机。某台 PC 走哪条路,可从电源选项的「开启快速启动」复选框,或 powercfg /a(可用的睡眠状态)是否列出「Fast Startup」判断。

关机操作时核心会怎样关机操作依快速启动是否开启分成完整关机或核心休眠,重启则一定走完整启动快速启动开休眠关 / Server关机重启工作阶段结束 + 核心休眠完整关机下次:还原核心下次:完整启动

图 2: 关机操作依快速启动是否开启分成完整关机或核心休眠,重启则一定走完整启动。

另一方面,「重启」一定走完整启动周期。例如驱动程序更新后,你需要全新的状态。2 由此,现场常听到的几种现象就对上了。

  • 「关机再加电,装置麻烦还在」── 核心与驱动程序只是从休眠还原,并没有重设
  • 「重启之后就好了」── 因为完整启动把它们初始化了
  • 设备 PC 的事故处理步骤应写「重启」,不要写「关电再开」

若要从命令行明确做完整关机,用 shutdown /s(Shutdown.exe 默认就是完整关机);若要默认的混合行为,用 shutdown /s /hybrid2 不建议关掉快速启动。应用侧应假设「关机时核心可能只是在休眠」──例如不要用 OS 启动时间去估「累计运转时间」──并设计成两种都不会坏(快速启动开或关依环境而异)。

3. GUI 应用该怎么做 ── WM_QUERYENDSESSION 与 WM_ENDSESSION

3.1. 两则消息怎么分工

有窗口与消息队列的应用,会分两阶段收到工作阶段结束通知。1

  1. WM_QUERYENDSESSION──查询:「可以结束吗?」应用应立刻回 TRUE;DefWindowProc 的默认回应也是 TRUE。这里不要开始收尾。
  2. WM_ENDSESSION(wParam=TRUE)──已确定的通知:「工作阶段真的要结束了」。收尾在这里做。

对 WM_QUERYENDSESSION 回 FALSE 可以中止关机,但文件写得很清楚:「应回 TRUE 并尊重用户的意图」;回了 FALSE 的应用仍会在全屏 UI 被标成「正在阻止关机的应用」。控制台应用与没有可见窗口的应用本来就不能中止关机,5 秒内没回应就会被自动结束。14

两阶段工作阶段结束通知的流程对 WM_QUERYENDSESSION 查询回 TRUE 后以 WM_ENDSESSION 确定并做收尾。用 FALSE 拒绝会把应用标成正在阻止关机,约 5 秒没回应可能被强制继续TRUE(原则)FALSE(拒绝)约 5 秒没回强制继续取消WM_QUERYENDSESSIONWM_ENDSESSION(已确定)标成正在阻止关机当成当住在这里收尾处理程序结束关机中止

图 3: 对 WM_QUERYENDSESSION 查询回 TRUE 后以 WM_ENDSESSION 确定并做收尾。用 FALSE 拒绝会把应用标成正在阻止关机,约 5 秒没回应可能被强制继续。

3.2. 不回应会怎样 ── 5 秒这道墙

WM_QUERYENDSESSION 与 WM_ENDSESSION 都可以把回应延后大约 5 秒。超过之后,系统会显示「这个应用正在阻止关机」画面,用户可选择强制继续(=强制结束应用)。4 被强制结束的处理程序没有第二次机会把档存完。

因此设计重点是这两点。

  • 把收尾压在 5 秒内做完的量。 Microsoft 自己也建议平常就频繁存档,让关机时要存的变少,并把未存数据存到暂存位置、下次启动再还原。1
  • 关机过程中不要跳出确认对话框。你坐在那里等「要保存吗?」的时候,5 秒就过了。静默落到安全侧(自动保存)。

3.3. 在 WinForms 与 WPF 的实现

在 .NET 桌面应用里,这些消息会被转成架构事件。WinForms 会引发 FormClosing,CloseReason 告诉你是不是关机造成的。

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

    // 用户按 × 这类一般关闭,这里可以确认
}

在 WPF,对应的是 Application.SessionEnding 事件(XAML 的 SessionEnding 属性,或覆写 OnSessionEnding)。

WinForms/WPF 事件如何对应到消息WM_QUERYENDSESSION 的查询阶段对应 WinForms FormClosing 与 WPF SessionEnding,那里最多只做幂等快照保存。已确定的 WM_ENDSESSION 没有对应事件,因此用 WndProc 或挂钩接收,做只有确定后才能做的收尾WM_QUERYENDSESSIONWinForms:FormClosingWPF:SessionEnding只做幂等快照WM_ENDSESSION无事件:WndProc 挂钩确定后再收尾

图 4: WM_QUERYENDSESSION 的查询阶段对应 WinForms FormClosing 与 WPF SessionEnding,那里最多只做幂等快照保存。已确定的 WM_ENDSESSION 没有对应事件,因此用 WndProc 或挂钩接收,做只有确定后才能做的收尾。

// WPF:App.xaml.cs
protected override void OnSessionEnding(SessionEndingCancelEventArgs e)
{
    base.OnSessionEnding(e);

    // 可以分辨 ReasonSessionEnding.Logoff / Shutdown,
    // 但基准是两种都跑同一段快照保存
    SaveWorkingStateToTempFile();

    // 没有例外理由就不要设 e.Cancel = true
}

这里有一个注意点。FormClosing(CloseReason.WindowsShutDown)与 WPF 的 SessionEnding 都对应查询阶段(WM_QUERYENDSESSION)。若另一个应用拒绝,关机会中止,你的应用继续跑。因此这些事件里能做的,是即使关机中止也无害、跑几次结果都一样的幂等快照保存。若需要「只有真的要结束才能做的收尾」(断线、交还资源等),直接在 WndProc 挂已确定的 WM_ENDSESSION(wParam=TRUE)并在那里做。

无论哪条路径,把本体收进共用的「快照保存」函数,让正常结束、关机、以及(可能的话)崩溃的还原数据用同一格式写,下次启动的还原逻辑就是一条路。即使崩溃也要留下信息的设计,见「Windows 应用因程式错误的例外掉下也要确实留下日志」。

4. 真的必须挡住时 ── ShutdownBlockReasonCreate

写光碟或韧体这类「做到一半被切断就会实体损坏」的操作是例外。这里的正确做法是:不可中断操作开始时用 ShutdownBlockReasonCreate 注册原因字符串,结束时立刻呼叫 ShutdownBlockReasonDestroy。关机被要求时,那个原因会显示在「这个应用正在阻止关机」画面,用户可决定继续或取消。3

用 ShutdownBlockReasonCreate 保护的流程不可中断操作开始时注册原因;保护期间若来了关机要求,全屏显示原因并对 WM_QUERYENDSESSION 以 FALSE 拒绝。用户可取消或强制继续,操作结束时清除原因取消强制继续开始不可中断工作ShutdownBlockReasonCreate在工作线程跑结束:Destroy这段期间关机显示原因 + FALSE处理程序结束

图 5: 不可中断操作开始时注册原因;保护期间若来了关机要求,全屏显示原因并对 WM_QUERYENDSESSION 以 FALSE 拒绝。用户可取消或强制继续,操作结束时清除原因。

[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, "Writing measurement data to a file");
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;   // 拒绝。注册的原因字符串会显示在全屏 UI
        return;
    }
    base.WndProc(ref m);
}

这里容易误解的是角色分工。ShutdownBlockReasonCreate 只做注册原因字符串;它本身并不挡住关机。真正把关机拦住的,是上面这种在保护旗标立着时对 WM_QUERYENDSESSION 回 FALSE 的自己的处理。两者当一套用,操作结束立刻两边都清掉。另外,被保护的操作本身放在工作线程,让 UI 线程能处理消息──拒绝机制要等消息送到才会动(即便如此,用户和 OS 仍可强制继续,因此「如果没挡住」也不坏的写入设计──第 8 章──仍然必要)。

营运上有三个注意点。

  • 原因字符串要短而具体。用户很急,只会读几秒。文件自己举的适当例子是 “Burning a CD”。3
  • 不要让它注册整个应用生命周期。 API 假定的是「只在不可中断操作进行中」。
  • 不要用「挡得住」当设计前提。用户可选择强制继续,强制关机(ENDSESSION_CRITICAL)根本不会等。文件写得很清楚:”Applications should not depend on being able to block shutdown”。4

5. 控制台应用与背景处理程序该怎么做

5.1. SetConsoleCtrlHandler 与较短的宽限期

控制台应用收不到窗口消息,控制信号会送到用 SetConsoleCtrlHandler 注册的处理函数。各信号的默认宽限期如下。5

信号 何时发生 默认宽限期
CTRL_C_EVENT / CTRL_BREAK_EVENT Ctrl+C / Ctrl+Break 没有超时
CTRL_CLOSE_EVENT 关闭控制台、工作管理员「结束工作」(从「详细数据」选项卡强制杀处理程序是没有通知的立即结束,不在此表) 约 5 秒
CTRL_SHUTDOWN_EVENT 系统关机(服务处理程序) 约 20 秒

有两点要注意。第一,实质上只有以服务执行的处理程序能收到 CTRL_LOGOFF_EVENT 与 CTRL_SHUTDOWN_EVENT。互动工作阶段里的应用在注销时就被结束,等这些信号的设计不成立。5 第二,载入了 gdi32.dll 或 user32.dll 的处理程序,即使你当它是控制台应用,也会被当成 Windows 应用,LOGOFF/SHUTDOWN 处理例程不会被呼叫。官方变通是建立隐藏窗口,接收 WM_QUERYENDSESSION/WM_ENDSESSION。6

各控制台信号的宽限期Ctrl+C 与 Ctrl+Break 没有明确超时;关闭控制台约 5 秒,给服务处理程序的关机信号约 20 秒;超过就强制结束处理程序无超时约 5 秒约 20 秒CTRL_C / BREAKHandlerRoutine 收尾CTRL_CLOSECTRL_SHUTDOWN宽限期后强制结束

图 6: Ctrl+C 与 Ctrl+Break 没有明确超时;关闭控制台约 5 秒,给服务处理程序的关机信号约 20 秒;超过就强制结束处理程序。

// 控制台应用: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 的陷阱 ── 不要依赖 ProcessExit

在 .NET 里,「在 AppDomain.ProcessExit 收尾就好」长期是现成模式,但从 .NET 10 起运行时不再提供默认的结束信号处理例程,CTRL_CLOSE_EVENT/CTRL_SHUTDOWN_EVENT 既不触发 ProcessExit 也不触发 AssemblyLoadContext.Unloading。OS 默认处理例程会立刻结束处理程序。7

.NET 10 之后 ProcessExit 怎么变到 .NET 9 为止,运行时的默认信号处理例程接到结束信号、引发 ProcessExit 再结束。从 .NET 10 起运行时不提供默认处理例程,OS 默认处理立刻结束处理程序,要自己注册处理例程CTRL_CLOSE / SHUTDOWN到 .NET 9:ProcessExit从 .NET 10:立刻结束自己注册处理例程

图 7: 到 .NET 9 为止,运行时的默认信号处理例程接到结束信号、引发 ProcessExit 再结束。从 .NET 10 起运行时不提供默认处理例程,OS 默认处理立刻结束处理程序,要自己注册处理例程。

改走各应用模型的正规路径。

  • GUI 应用:前一章的 FormClosing / SessionEnding
  • Generic Host(含 Worker Service):IHostApplicationLifetime 与 BackgroundService.StopAsync。用 HostOptions.ShutdownTimeout 把停止宽限期写清楚
  • 裸控制台应用:SetConsoleCtrlHandler(或用 PosixSignalRegistration 订阅 SIGINT/SIGTERM 对等信号)
各应用模型在哪里接收结束通知GUI 应用用 FormClosing 与 SessionEnding,已确定的工作再用 WM_ENDSESSION 挂钩;Generic Host 用 IHostApplicationLifetime 与 StopAsync;裸控制台应用用 SetConsoleCtrlHandler 或 PosixSignalRegistration。依赖 ProcessExit 在外部信号路径上不会触发GUI非 GUIHost控制台哪种应用模型?FormClosing / SessionEndingHost 还是控制台?ENDSESSION 挂钩Lifetime + StopAsyncSetConsoleCtrlHandler设置 ShutdownTimeout不要依赖 ProcessExit

图 8: GUI 应用用 FormClosing 与 SessionEnding,已确定的工作再用 WM_ENDSESSION 挂钩;Generic Host 用 IHostApplicationLifetime 与 StopAsync;裸控制台应用用 SetConsoleCtrlHandler 或 PosixSignalRegistration。依赖 ProcessExit 在外部信号路径上不会触发。

宽限期依路径而异──GUI 与关闭控制台约 5 秒,服务是第 6 章的 SCM 宽限期(约 20 秒,或 PRESHUTDOWN 的设置值),Ctrl+C 没有明确超时。但每条路径的宽限期都有限、不能指望,因此设计主轴是平常就是「每个处理检查点都已存好」,而不是「在结束事件里拼命做」

6. Windows 服务该怎么做 ── SHUTDOWN 与 PRESHUTDOWN

6.1. 两种关机通知

服务不受注销影响,但关机与重启时会被停止。通知以控制码从 Service Control Manager(SCM)送来,接收前要宣告接受旗标。8

宣告 会送到的通知 时机与宽限期
SERVICE_ACCEPT_SHUTDOWN SERVICE_CONTROL_SHUTDOWN 关机处理中通知。默认约 20 秒,上限 WaitToKillServiceTimeout
SERVICE_ACCEPT_PRESHUTDOWN SERVICE_CONTROL_PRESHUTDOWN 在 SHUTDOWN 之前通知。SCM 等到服务停止或超时
给服务的关机通知顺序关机开始时,宣告了 PRESHUTDOWN 的服务先以设置的宽限期收到通知,然后再送默认约 20 秒的 SHUTDOWN 通知,宽限期到期就结束处理程序关机开始PRESHUTDOWN(若有宣告)SHUTDOWN(约 20 秒)宽限期到期 → 结束

图 9: 关机开始时,宣告了 PRESHUTDOWN 的服务先以设置的宽限期收到通知,然后再送默认约 20 秒的 SHUTDOWN 通知,宽限期到期就结束处理程序。

PRESHUTDOWN 超时可用 ChangeServiceConfig2(SERVICE_CONFIG_PRESHUTDOWN_INFO)设置;默认是 Windows 10 Creators Update(组建 15063)起 10 秒,之前是 3 分钟9 若还抱着「PRESHUTDOWN 有 3 分钟」的旧知识,在现行 OS 上宽限期只有预期的 1/18。另外,PRESHUTDOWN 会让整个系统的关机停在那个区间,因此文件也说它「只应在特殊情况使用」。8

处理例程侧的实务也很重要。控制处理例程必须在 30 秒内返回;花时间的停止工作交给另一条线程,回报 SERVICE_STOP_PENDING,立刻返回。8

// 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 还长,持续定期回报
// SERVICE_STOP_PENDING 并递增 dwCheckPoint。
// SCM 依 waitHint 与检查点前进判断「还活着而且有进度」。
// 若回报停了,可能被当成当住并继续关机。做完一定要回报 SERVICE_STOPPED

6.2. 不依赖宽限期的设计

从服务侧改写宽限期上限 WaitToKillServiceTimeout 来延长,明确不建议。文件要求的是反过来──服务应尽快做完收尾,让靠 UPS 供电的机器能在电池耗尽前完成关机。指引是平常就频繁存档、让未存数据最少,关机时不要花时间释放内存,通知网络对端时也不要等太久。另外,关机时 SCM 默认不考虑相依关系,因此停止处理必须「即使相依的服务已经倒下也还能动」。8

不依赖宽限期的停止处理设计若在每个处理检查点都存档、让未存数据始终最少,停止通知来时的收尾几秒就做完。把一切留到结束才存的设计塞不进宽限期,强制结束就丢数据每个检查点都存停止 → 存一点差额 → 完成结束时才存一切停止 → 存档赶不上宽限强制结束 → 数据遗失

图 10: 若在每个处理检查点都存档、让未存数据始终最少,停止通知来时的收尾几秒就做完。把一切留到结束才存的设计塞不进宽限期,强制结束就丢数据。

在 .NET Worker Service(UseWindowsService)里,SERVICE_CONTROL_STOP 与 SHUTDOWN 会被转成主机停止,并呼叫 BackgroundService.StopAsync。本文撰写时的现成实现接受 STOP/SHUTDOWN 一族;若也需要 PRESHUTDOWN,就要扩充处理例程。无论哪种,把 HostOptions.ShutdownTimeout 写清楚,并在几秒内做完 StopAsync。一般如何做服务,见「Windows 服务的建立与维运」。

7. 重启后自动恢复

对设备 PC 或无人值守 PC,设计范围不只是「扛过关机」,还有「重启后自己回来」。

7.1. RegisterApplicationRestart 与恢复回呼

若已呼叫 RegisterApplicationRestart,应用会被注册为崩溃(未处理例外)、无响应、更新触发的应用重启、以及更新触发的 OS 重启的重启候补。可以注册重启用的命令行参数,因此若包含「当时开着哪个档」与「哪个还原点」,重启后就能从停下的地方继续。10

要掌握的规格如下。10

  • 注册必须在问题发生前完成(更新情境里,处理 WM_QUERYENDSESSION 是最后机会)
  • 为避免重启回圈,执行未满 60 秒的处理程序不会被重启
  • 以提升权限执行的处理程序不是自动重启候补(没有提升同意就无法重建处理程序)。需要提升的应用的自动恢复,做法是 UI 维持标准权限、把特权工作隔离到服务,或用工作排程器「以最高权限执行」这类明确启动路径
  • 崩溃或当住后的重启要经用户同意;更新后的重启是自动的
  • 要跨过 OS 重启恢复,要求重启的那一方(安装程式等)必须以 EWX_RESTARTAPPS / SHUTDOWN_RESTARTAPPS 旗标呼叫关机 API

若也注册 RegisterApplicationRecoveryCallback,崩溃时 WER(Windows Error Reporting)会呼叫回呼,给你一段宽限期去存进行中的数据。不过若保存花时间,必须在注册时指定的 ping 间隔内持续呼叫 ApplicationRecoveryInProgress,否则恢复工作会做到一半被切断。存完后用 ApplicationRecoveryFinished 通知完成。应用更新时「替换使用中的档并重启」是 Restart Manager 的领域,细节见「如何替换使用中的 exe/DLL」。

7.2. ARSO ── 更新重启后的自动登录

Windows Update 重启后,若没有人登录,用户工作阶段的应用不会回来。补这段空隙的是 ARSO(Winlogon Automatic Restart Sign-On)。Windows Update 开始重启时,会安全地保存最后一位互动用户的认证数据、设置 Autologon,重启后自动让该用户登录,然后锁定画面11 也有 shutdown /g 这类要求重启并恢复已注册应用的命令。有些环境会用组织策略(DisableAutomaticRestartSignOn 等)关掉它,因此设计无人值守恢复时,把这个设置当一套检查。若背景工作一直需要依赖用户工作阶段的自动启动,一开始就做成 Windows 服务才对。

应用在重启后自动恢复的路径若在问题发生前用 RegisterApplicationRestart 注册,崩溃或无响应时经用户同意后重启应用,更新触发的重启则在 ARSO 自动登录并锁定画面后重启。执行未满 60 秒与提升权限的处理程序不在范围内同意RegisterApplicationRestart崩溃或当住更新重启应用重启ARSO 登录 + 锁定不包含:未满 60 秒/提升权限

图 11: 若在问题发生前用 RegisterApplicationRestart 注册,崩溃或无响应时经用户同意后重启应用,更新触发的重启则在 ARSO 自动登录并锁定画面后重启。执行未满 60 秒与提升权限的处理程序不在范围内。

8. 扛住完全没有通知的断电 ── 写入设计与 UPS

8.1. 「无论何时被切断都不坏」的写法 ── 临时文件 + ReplaceFile

跳电、电源供应器故障、插头被拔,既没有 WM_ENDSESSION 也没有 PRESHUTDOWN。只要对设置或测量结果「就地覆写原文件」,写到一半断电就可能留下新旧混在一起的损坏文件

标准模式是在同一卷写完整的临时文件再交换。ReplaceFile 把「存到新档 → 把原文件挪开 → 重新命名 → 删除」包成单一 API,也会带过原文件的建立时间、ACL、替代数据流等属性(三个档必须在同一卷)。12 .NET 的 File.Replace 就是直接呼叫它。

用临时文件与 ReplaceFile 保存并恢复的流程保存时写完整临时文件、刷新、再用 ReplaceFile 交换,旧内容留在 .bak。下次启动验证主文件,坏了就回退 .bak下次启动保存时完好损坏任一步断电验证主文件直接使用回退到 .bak刷新到磁盘写完整临时文件ReplaceFile → .bak

图 12: 保存时写完整临时文件、刷新、再用 ReplaceFile 交换,旧内容留在 .bak。下次启动验证主文件,坏了就回退 .bak。

// 设置与数据的标准模式:写完整临时文件再交换,并留下旧内容
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。把 OS 缓冲区
                                           // 写出到磁盘(装置侧快取的限制见 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;
    }
}

这样一来,平常运作下永远能读到「完整旧文件」或「完整新文件」。不过 ReplaceFile 是多步骤的命名空间操作,跨断电的原子性规格并不保证。所以上面的例子才留备份(.bak)──读取侧在启动时验证主文件、坏了就回退备份,这是一套。附加写入的记录或 CSV 不能用这招,那些用「一行=一笔纪录,读取时丢掉坏掉的最后一行」这种把损坏算进去的格式。

8.2. WriteFile 成功不代表已到磁盘

另一个前提是:即使 WriteFile 回成功,数据可能还只在 OS 快取里。Windows 把文件读写放进系统缓冲区,再用延迟写入定期反映到磁盘。要确定数据到磁盘,要嘛用 FlushFileBuffers 明确刷新,要嘛在 CreateFile 指定 FILE_FLAG_WRITE_THROUGH 让每次写入穿过快取。文件系统中继数据一律会被快取,因此确认中继数据也需要刷新或 write-through。13

不过每次都呼叫 FlushFileBuffers 没效率,文件也鼓励考虑 FILE_FLAG_NO_BUFFERING+WRITE_THROUGH,而不是频繁呼叫。13 实务上,「只在交易检查点或即将关闭文件时刷新」是务实的折衷。这一层的机制──快取管理员、延迟写入,以及「刷新了仍可能没到磁盘」的硬件快取──深入见「快取管理员:你的 WriteFile 究竟何时送达磁盘」。

8.3. UPS 与电池监视 ── 把断电变成关机

设备 PC 对抗断电的真正手段是 UPS。把 UPS 的角色想成不是「阻止停电」,而是把「没有通知的断电」变成「有通知的计划关机」。设计是两段。

  1. 设计宽限期:UPS 电池撑住时间 >「检测切到电池 → 应用与服务收尾 → OS 关机完成」的总和。若服务停止处理太慢,这个不等式就不成立(第 6.2 节)
  2. 检测:从交流电切到电池、以及剩余容量下降,会以 PBT_APMPOWERSTATUSCHANGE 事件通知。有窗口的应用以 WM_POWERBROADCAST 接收;没有窗口的服务宣告 SERVICE_ACCEPT_POWEREVENT,在 HandlerEx 以 SERVICE_CONTROL_POWEREVENT 接收(WM_POWERBROADCAST 不会送到服务控制处理例程)。收到后呼叫 GetSystemPowerStatus,检查 ACLineStatus(是否在交流电上)与 BatteryLifePercent,导入中断测量、保存、要求关机15
用 UPS 把断电变成计划关机的流程停电让 UPS 切到电池时会通知 PBT_APMPOWERSTATUSCHANGE,检查电源状态后,保存加上关机要求,把没有通知的断电变成有通知的计划关机停电UPS 切到电池PBT_APMPOWERSTATUSCHANGEGetSystemPowerStatus中断并保存要求关机平常的通知流程(3–6)

图 13: 停电让 UPS 切到电池时会通知 PBT_APMPOWERSTATUSCHANGE,检查电源状态后,保存加上关机要求,把没有通知的断电变成有通知的计划关机。

典型的 USB 连接 UPS 在 Windows 上看起来像电池,因此可用这套标准 API 检测。若厂商管理软件有「剩余 N% 时关 OS」功能,也要确认门槛与应用的收尾时间对得上。从睡眠或休眠恢复,以及长时间运行的问题,是另一条轴,见「睡眠、休眠、Modern Standby 与长时间运行的应用」。

9. 怎么验证 ── 安全地试关机

关机处理很容易变成「写了,却从没在接近正式的条件下试过」。准备一套安全验证的步骤。

  • 在测试机或 VM 上试:不要先在正式设备 PC 上试。在有 Hyper-V 检查点(快照)的测试环境,反复关机、重启、强制断电(关掉 VM)。不过 VM「关电」只重现「客体 OS 毫无预告地停」;它不重现实体磁盘挥发性快取消失、或控制器相依的损坏。若要以设备 PC 出货,最终检查是在等同正式的硬件上做真正的切断电源测试
  • 用注销做快速检查:WM_QUERYENDSESSION → WM_ENDSESSION 路径在注销时也会跑(唯一差别是 lParam 设了 ENDSESSION_LOGOFF 位元),因此可在开发机方便确认收尾代码的行为1
  • 完整关机与混合分别试:分别试 shutdown /s /t 0(完整)、shutdown /s /hybrid /t 0(默认行为)、shutdown /r /t 0(重启)2
  • 量收尾花多久:在收尾函数开头与结尾把时间戳写进记录,量是否塞进 5 秒(或服务的设置宽限期)
要验证的操作与各能确认什么注销方便检查通知路径;关机命令的完整、混合与重启确认正式通知路径与宽限期;VM 关电测试突然停止的耐受力;实体切断电源测试是含实体保存的最终检查注销QUERY → ENDSESSION 路径shutdown /s /hybrid /r正式路径 + 宽限VM 关电客体突然停止实体切断电源含保存(最终)

图 14: 注销方便检查通知路径;关机命令的完整、混合与重启确认正式通知路径与宽限期;VM 关电测试突然停止的耐受力;实体切断电源测试是含实体保存的最终检查。

事后隔离时,事件记录(系统)有用。正常关机或重启会记录 事件ID 1074(哪个处理程序为谁、因何开始关机)。突然断电或崩溃没有 1074,下次启动会记录 事件ID 41(Kernel-Power)与 6008(前一次系统关机非预期)14「夜里发生了什么」从这里开始。

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

若 1074 显示「Windows Update 的重启」且应用数据坏了,问题在收尾代码。6008/41 只显示「非预期关机」;蓝白崩溃(崩溃)或强制重设也会记,不只断电。若 41 的 BugcheckCode 非零就是崩溃;若是 0 且也没有内存倾印,断电的可能性较高──从周围信息隔离原因,一旦知道是断电,下一步就是第 8 章的写入设计与 UPS。

10. 总结

  • 关机是「迟早会来的正常事件」。通知后的宽限期原则上只有大约 5 秒,因此前提是频繁自动保存,让「结束时要做的事」最少。
  • 从 Windows 8 起的客户端 OS,若开启快速启动,「关机」是混合关机,核心只是在休眠。真正完整重设的只有「重启」──事故处理步骤请写「重启」。
  • GUI 应用对 WM_QUERYENDSESSION 立刻回 TRUE,已确定的收尾放在 WM_ENDSESSION。WinForms/WPF 的 FormClosing 与 SessionEnding 对应查询阶段,那里最多只做幂等快照保存。关机过程中不要跳出对话框。
  • 真的不可中断的操作,用 ShutdownBlockReasonCreate 显示原因来保护。但任何地方都不保证你挡得住。
  • 控制台应用用 SetConsoleCtrlHandler 接收通知;服务用 SERVICE_ACCEPT_PRESHUTDOWN/SHUTDOWN。现行 OS 上 PRESHUTDOWN 默认宽限期是 10 秒。在 .NET,停止依赖 ProcessExit,改走符合应用模型的正规路径。
  • 重启后的恢复可用 RegisterApplicationRestart(加上恢复回呼)与 ARSO 做成无人值守。
  • 断电没有通知。用临时文件 + ReplaceFile 交换(搭配备份 + 载入时验证)、检查点刷新,以及「把断电变成计划关机」的 UPS 来准备。
关机处理的整体图像有通知的结束,用几秒内能打烊的收尾回应,并导入重启后自动恢复;没有通知的断电,用无论何时被切断都不坏的写法与 UPS 准备,并含实体硬件验证。这两根支柱就是本文的结论有通知无通知怎么结束几秒收尾(3–6)安全写入 + UPS(8)重启后自动恢复(7)在硬件上验证(9)

图 15: 有通知的结束,用几秒内能打烊的收尾回应,并导入重启后自动恢复;没有通知的断电,用无论何时被切断都不坏的写法与 UPS 准备,并含实体硬件验证。这两根支柱就是本文的结论。

  • 在 VM 与注销上安全验证,事后用事件ID 1074/41/6008 隔离。

下次替应用加功能时,问自己一次:如果这段工作做到一半 WM_ENDSESSION 来了,或电源被拔掉,下次启动还剩下什么?把那个答案写进设计,就是以后不用在设备 PC 前面抱头站一早上的最短路径。

相关文章

相关咨询领域

小村软件有限公司承接设备 PC 与长时间运行应用的关机与断电对策设计与实现、从 Windows Update 重启或注销开始的数据损坏与「早上已经停了」事故的根本原因调查,以及 Windows 服务停止处理与自动恢复的设计审查。从「每次关机好像都会坏、不知道从哪下手」这个阶段开始就可以。

参考链接

  1. Microsoft Learn, WM_QUERYENDSESSION message. 关于工作阶段结束时会送出 WM_QUERYENDSESSION,应用应回 TRUE 并尊重用户意图(DefWindowProc 默认也是 TRUE);关于收尾应延到 WM_ENDSESSION;关于 5 秒后系统会显示正在阻止关机的应用 UI、用户可强制结束;关于 lParam 里 ENDSESSION_LOGOFF/CLOSEAPP/CRITICAL 位元的意义;关于关机与重启分不出来;以及应频繁存档、让结束时要存的变少。  2 3 4 5 6 7

  2. 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

  3. Microsoft Learn, ShutdownBlockReasonCreate function (winuser.h). 关于在不可中断操作开始时呼叫以注册原因字符串、结束时呼叫 ShutdownBlockReasonDestroy;关于只能从建立窗口的线程呼叫;以及用户只会读原因几秒,因此字符串应短而清楚。  2 3

  4. Microsoft Learn, Shutdown Changes for Windows Vista. 关于对 WM_QUERYENDSESSION/WM_ENDSESSION 的回应各可延后 5 秒、之后用户可选择继续或取消;关于控制台应用或没有可见窗口的应用不能中止关机,5 秒没回应或回 FALSE 会被自动结束;关于若需要挡住应以 ShutdownBlockReasonCreate 注册原因;以及应用不可依赖挡得住关机。  2 3 4

  5. 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

  6. Microsoft Learn, SetConsoleCtrlHandler function. 关于载入了 gdi32.dll 或 user32.dll 的处理程序会被当成 Windows 应用,CTRL_LOGOFF_EVENT/CTRL_SHUTDOWN_EVENT 处理例程不会被呼叫;关于变通是建立隐藏窗口并处理 WM_QUERYENDSESSION/WM_ENDSESSION;以及信号处理期间控制台函数可能无法正确运作。  2

  7. Microsoft Learn, .NET runtime no longer provides default termination signal handlers. 关于从 .NET 10 起运行时不再为 Windows 的 CTRL_SHUTDOWN_EVENT/CTRL_CLOSE_EVENT(Unix 的 SIGTERM/SIGHUP 对等)提供默认处理例程;关于 OS 默认处理会立刻结束应用,AppDomain.ProcessExit 与 AssemblyLoadContext.Unloading 不再触发;以及应在较高层函数库或应用代码注册符合应用模型的信号处理。  2

  8. Microsoft Learn, Service Control Handler Function. 关于宣告 SERVICE_ACCEPT_PRESHUTDOWN 的服务先收到 SERVICE_CONTROL_PRESHUTDOWN,然后宣告 SERVICE_ACCEPT_SHUTDOWN 的服务收到 SERVICE_CONTROL_SHUTDOWN;关于关机时默认宽限期约 20 秒、OS 重启时上限是 WaitToKillServiceTimeout;关于不应延长这个值;关于控制处理例程应在 30 秒内返回、回报 STOP_PENDING 与等待提示、把长工作交给另一条线程;关于考量 UPS 运作应尽快做完收尾;以及关机时 SCM 默认不考虑相依关系。  2 3 4 5

  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 秒的处理程序不会被重启;关于崩溃或当住后的重启要经用户同意;以及跨过 OS 重启需要以 EWX_RESTARTAPPS/SHUTDOWN_RESTARTAPPS 关机。  2 3

  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 3

  14. Microsoft Learn, Troubleshoot unexpected reboots using system event logs. 关于正常重启会记录事件ID 1074(哪个处理程序为谁、因何开始关机);关于非预期重启会记录事件ID 41(Kernel-Power)与 6008(前一次关机非预期);以及这些ID可隔离重启种类。  2

  15. Microsoft Learn, PBT_APMPOWERSTATUSCHANGE event. 关于电池与交流电切换或剩余容量下降时,此事件经 WM_POWERBROADCAST 通知;以及收到后应呼叫 GetSystemPowerStatus,检查 SYSTEM_POWER_STATUS 的 ACLineStatus、BatteryFlag、BatteryLifePercent 等栏位。 

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

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

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

常见问题

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

「关机」之后没好的问题,「重启」之后却好了。为什么?
从 Windows 8 起的客户端 OS,在开启快速启动时(多数支持休眠的 PC 默认如此),「关机」走的是混合关机。用户会注销,但核心与驱动程序状态会存进休眠文件,下次启动原样还原。也就是说,OS 核心并没有被重设。「重启」则一定走完整启动,驱动程序与服务的麻烦才会被清掉。隔离步骤请写「重启」,不要写「关机再加电」。若要从命令行做完整关机,可用 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 软件开发、技术咨询与故障排查为中心,擅长难以复现的故障调查,以及既有资产仍在运行的项目。

返回博客列表