如何替换正在使用中的 exe/DLL ── Restart Manager 与自动更新的「文件被占用」问题

· · Restart Manager, 自动更新, 安装程序, MSI, Windows API, C#, P/Invoke, 分发

「因为要更新应用,请所有人都点击 × 按钮关闭它——每次更新都要在馆内广播这样一句话」「在夜间设置了替换文件服务器上 exe 的批处理,结果早上一看,却因为『进程无法访问该文件』而失败了」── 在业务应用分发・更新的咨询中,这个「文件被占用」问题真的非常常见。安装程序最后弹出的「需要重启」,根源也是同一个:归根结底,都是因为无法替换被某个东西占用着的文件。

麻烦的地方在于,这个问题「只是偶尔发生」。在开发机上,因为是自己先关闭应用再更新,所以不会重现;而在生产环境的共享场景中,只要有一个人开着应用就直接下班回家,夜间更新就会全军覆没。而且,仅凭错误消息根本无法得知究竟是谁占用着文件。

其实 Windows 针对这个问题准备了 OS 标准的机制。那就是能够列举「谁占用着文件」、让它们行为得体地结束、并在更新后完成重启的 Restart Manager API。此外,利用「正在运行的 exe 可以重命名」这一特性,还可以设计出不停止进程、从下次启动开始切换到新版本的更新方案。本文面向被业务应用自动更新・安装程序中的「文件正在使用中」问题困扰的开发者,从锁定的原理到 Restart Manager 的用法、应用侧的规范做法、自动更新的实现模式,结合官方文档的依据与 C# 诊断代码进行整理。

1. 先说结论

  • 正在运行的 exe・已加载的 DLL 无法「覆盖写入」和「删除」,但在同一卷内进行「重命名(移动)」是可以的。先把旧文件重命名为退避名,再放置新文件的rename-then-replace,是自定义更新程序的基本形态。
  • Restart Manager 是 Windows Vista 以后 OS 标准提供的 API,专门为「文件占用问题」而存在。只要注册更新目标文件,它就会列举正在占用该文件的应用・服务,并一路照顾到终止与重启。其目的是减少・消除 OS 重启。1
  • API 的流程是 RmStartSession → RmRegisterResources → RmGetList → RmShutdown →(更新)→ RmRestart → RmEndSession。停止按 GUI 应用→控制台应用→服务→资源管理器的顺序进行,重启则按相反顺序进行。12
  • 仅凭 RmGetList 就能做出一个显示「谁占用着文件」的诊断工具。从 C# 用 P/Invoke 调用只需几十行代码(参见后文代码)。3
  • MSI(Windows Installer 4.0 以后)会自动使用 Restart Manager。只要在安装包中加入 MsiRMFilesInUse 对话框,就能向用户提示「自动关闭应用并重启」的选项。4
  • 应用侧也有相应的规范做法。用 RegisterApplicationRestart 注册重启命令行,响应 WM_QUERYENDSESSION(lParam=ENDSESSION_CLOSEAPP),在 WM_ENDSESSION 中保存未保存的数据后再结束。实现了这三点的应用就能做到「即使因更新而被关闭,更新后也会恢复原状重新启动」。56
  • 为防止重启循环,启动后不满 60 秒的应用不会被重启。此外,Restart Manager 强制终止的超时时间为应用 30 秒・服务 20 秒。62
  • 无论如何都无法替换时的最终手段是 MoveFileEx + MOVEFILE_DELAY_UNTIL_REBOOT(预约在 OS 重启时替换)。需要管理员权限,预约会被记录在注册表的 PendingFileRenameOperations 中。7

2. 为什么使用中的文件无法替换 ── 以及「重命名可以通过」这一突破口

当 Windows 执行 exe 或加载 DLL 时,OS 会把该文件映射为内存映射文件(镜像节)。由于分页机制的原因,即使在运行期间,文件的实体也会持续被引用,因此内容的改写与删除会被阻止。这就是用资源管理器尝试复制时会提示「另一个进程正在使用该文件」、File.Copy 会抛出 IOException(共享冲突)、File.Delete 会抛出 UnauthorizedAccessException 的那种行为。

这里重要的是 Windows 一个鲜为人知的特性。即使文件的「内容」被锁定,目录上的「名称」也是可以更改的。即使是正在运行的 exe,只要在同一卷内,重命名(移动)也会成功。因为执行映像抓住的是文件的实体,而不是路径名。

从这种不对称性出发,就可以推导出自定义更新的基本形态——rename-then-replace 模式

1. 将 MyApp.exe(正在运行)重命名为 MyApp.exe.old   ← 即使正在运行也会成功
2. 将新的 MyApp.exe 放置到原来的路径                ← 名称已空出,因此会成功
3. 下次启动开始使用新的 MyApp.exe
4. 旧进程结束后,在某个时机删除 .old 文件
   (运行中无法删除,因此在下次更新时或启动时的清理处理中删除)

这种模式的价值在于能够在不停止进程的情况下实现「从下次启动开始使用新版本」,Chrome 的更新程序以及 Squirrel/Velopack 之类的更新框架,本质上也是建立在这一特性之上的。需要注意的地方有三点:重命名只能在同一卷内使用(移动到其他卷会变成复制+删除,而删除会失败);要把 .old 文件的清理纳入设计之中;以及「当前正在运行的旧进程」终究会继续以旧版本运行下去,因此存在新旧进程并存的一段时间。

另外,如果想在故障排查中手动查明「究竟是谁占用着文件」,用 Process Explorer 或 Handle 命令检索句柄・已加载的 DLL 是最快的途径。具体步骤已整理在同一天发布的姊妹文章《Process Explorer / Handle / VMMap 实战 ── 从此刻的状态追踪挂起、泄漏与「文件被占用」》中。本文则从程序的角度,也就是作为内置到更新程序自身中的方法来使用 Restart Manager。

3. Restart Manager 的机制 ── 从 RmStartSession 到 RmRestart

Restart Manager 是 Windows Vista / Windows Server 2008 以后标准搭载的一组 API(rstrtmgr.dll),其目的在文档开头就写得很明确:「安装或更新要求系统重启的主要原因,是更新目标文件正被正在运行的应用程序或服务使用,Restart Manager 通过终止・重启除关键对象以外的应用・服务,来减少或消除这种重启的需要」1。想必大家都见过在安装 MSI 包的过程中弹出「以下应用程序正在使用相关文件…」这类进程列表对话框,而 Visual Studio、Office 这类大型产品的安装程序・更新,同样是这套机制的使用者。

API 的流程是一条直线。

步骤 函数 作用
1 RmStartSession 开始会话,获得句柄与会话密钥(GUID 字符串)
2 RmRegisterResources 注册要更新的文件路径(也可以是进程・服务名)
3 RmGetList 列举正在使用已注册资源的应用・服务3
4 RmShutdown 将它们终止(通常是温和方式,指定后可强制)2
5 ── 在此期间替换文件
6 RmRestart 重启已注册的应用
7 RmEndSession 关闭会话

「什么时候替换文件」是最容易出错的地方,因此这里按时间轴排列出来。可以替换文件的时机,仅限于 RmShutdown 返回之后、调用 RmRestart 之前的那一瞬间。

使用中的应用(GUI/服务)Restart Manager(rstrtmgr.dll)更新程序使用中的应用(GUI/服务)Restart Manager(rstrtmgr.dll)更新程序此时「谁占用着文件」已经确定RebootReasons≠0 则需要 OS 重启⑤ 在此替换文件← 只有这段时间锁才会解除① RmStartSession会话句柄 + 会话密钥② RmRegisterResources(更新目标文件)③ RmGetList使用中的进程列表 + RebootReasons④ RmShutdownWM_QUERYENDSESSION / WM_ENDSESSION(控制台为 CTRL_C_EVENT)保存并结束结束完成⑥ RmRestart按逆序重启已注册的应用⑦ RmEndSession

有几项规范需要牢记。

  • 停止顺序与重启顺序。按 GUI 应用→控制台应用→服务→资源管理器的顺序停止,更新后按相反顺序重启已注册的应用。1
  • 终止的方式是「按体面程度依次进行」。会向 GUI 应用发送 WM_QUERYENDSESSION/WM_ENDSESSION(lParam=ENDSESSION_CLOSEAPP),对不响应的应用还会发送 WM_CLOSE。控制台应用会收到 CTRL_C_EVENT,服务则通过 SCM 停止。即便指定了 RmForceShutdown,也会先尝试温和地结束,再对不响应的应用在 30 秒(服务为 20 秒)后强制终止。52
  • 能够被重启的,只有通过 RegisterApplicationRestart 完成注册的应用。在 RmShutdown 中指定 RmShutdownOnlyRegistered,就可以实现「只有当所有对象都已完成重启注册时才终止」这种偏安全的行为。25
  • 无法跨会话终止。以 LocalSystem 服务形式运行的安装程序,无法终止・重启在用户会话中运行的应用。这是设计夜间无人值守更新时最容易卡住的地方(具体对策见下一节)。2
  • 重要的系统服务与关键进程不在处理范围内。这种情况下会返回「需要 OS 重启」的判定(RM_REBOOT_REASON)。13

3.1 如何跨越会话边界

在「由服务在夜间发起更新」这种设计中,必然会碰到的就是这项限制。无法从 LocalSystem(会话 0)终止・重启处于登录中的用户会话里的应用。2 规避方法归根结底不是「从会话 0 伸手过去」,而是把手脚放进目标会话内部。现实中可行的选项有三个。

方法 具体做法 适合・不适合
(A) 用任务计划程序以目标用户身份启动 将更新代理任务以目标用户账户注册为「仅在该用户登录时运行」,在更新信号到来时启动 最省事。适合能够确定登录用户的公司内部终端
(B) 登录时启动常驻代理 在各个用户会话中运行一个小型常驻进程,通过共享文件夹或事件等待更新信号 即使目标用户不固定・多人同时登录也有效。常驻进程的维护成本较高
(C) 从服务向用户会话中启动进程 LocalSystem 服务获取目标会话的令牌,用该令牌启动更新程序 自由度最高,但实现与权限管理最为繁重

(A)可以用 schtasks 这样注册。要点是将目标用户指定给 /RU,再加上 /IT,使其成为一个「仅在该用户登录时以交互方式运行」的任务。

:: 注册一个让更新代理「在目标用户的会话内」运行的任务
:: 凭据指定(/RP)与覆盖现有任务(/F)请根据环境策略自行调整
schtasks /create /TN "MyApp Update Agent" /TR "C:\App\Updater.exe /shutdown-and-update" ^
         /SC ONCE /ST 02:00 /RU CORP\taro /IT

:: 也可以从夜间批处理一侧不等到指定时刻就立即执行
schtasks /run /TN "MyApp Update Agent"

如果选择(C),需要用 WTSGetActiveConsoleSessionIdWTSEnumerateSessions 求出目标会话 ID,再用 WTSQueryUserToken 获取该用户的主令牌,然后用 CreateProcessAsUser 启动更新程序。调用 WTSQueryUserToken 需要以 LocalSystem 账户运行,并具备 SE_TCB_NAME 特权,官方文档也明确写明「面向高度可信的服务」「需注意避免令牌泄漏,用完后务必关闭句柄」。8 如果处理不当就会成为权限提升的漏洞,因此如果(A)或(B)已经足够,就没有必要勉强选择(C)。

无论采用哪种方法,由在会话内运行的进程负责从 RmStartSession 到 RmRestart 的全过程这一结构都是相同的。会话 0 的服务只负责「分发更新文件」和「发出信号」这两件事。

这里也一并整理与 MSI 的关系。Windows Installer 4.0 以后的 MSI 会自动使用 Restart Manager。默认行为是「不重启 OS,而是尽可能终止・重启应用」。安装包作者能做的事情包括:添加MsiRMFilesInUse 对话框,在完整 UI 时向用户提供「自动关闭应用程序并重启」的选项(在较旧的 Installer 上会回退到传统的 FilesInUse 对话框);通过 MSIRESTARTMANAGERCONTROL 等属性控制行为;以及从自定义操作中经由 MsiRestartManagerSessionKey 属性调用 RmJoinSession 来追加注册资源(该自定义操作要排在进行使用中文件检测的 InstallValidate 操作之前)。静默安装时会始终使用 Restart Manager,应用会被自动终止。4

也就是说,如果采用 MSI 分发,几乎不需要自己编写「调用 Restart Manager 的代码」,整理好应用侧的规范做法(后面第 5 节)才是重点。只有在编写自定义更新程序时,直接调用这套 API 才有价值。分发方式本身的选择,在《Windows 应用发布方式怎么选 - MSI / MSIX / ClickOnce / xcopy / 自定义 updater 判断指南》中讨论过。

4. 用 C# 输出「谁占用着文件」── RmGetList 诊断代码

在 Restart Manager 之中,即使不编写更新处理逻辑也用得上的是 RmGetList。因为可以通过这个 API 获取「正在使用该文件的进程和服务清单」,所以能把更新程序的错误消息从「文件正在使用中」变成「财务部终端上的 MyApp.exe(PID 4132)占用着它」。

4.1 骨架 ── 只需调用 4 个函数

除去 P/Invoke 声明与结构体定义,处理的主体就只有这些。请先把这个形态记在脑子里,再阅读接下来的完整版。

// 【骨架】结构体定义与 P/Invoke 声明请见 4.3 的完整版
var sessionKey = new StringBuilder(CCH_RM_SESSION_KEY + 1);

// ① 开始会话
int rc = RmStartSession(out uint session, 0, sessionKey);
if (rc != 0) throw new Win32Exception(rc);
try
{
    // ② 注册要检查的文件(必须是完整路径)
    var fullPaths = Array.ConvertAll(args, Path.GetFullPath);
    rc = RmRegisterResources(session, (uint)fullPaths.Length, fullPaths, 0, null, 0, null);
    if (rc != 0) throw new Win32Exception(rc);

    // ③ 列举正在使用中的进程/服务
    //    如果缓冲区不足会返回 ERROR_MORE_DATA 及所需件数,因此按需分配后重新获取
    uint count = 0;
    RM_PROCESS_INFO[] apps = null;
    while (true)
    {
        rc = RmGetList(session, out uint needed, ref count, apps, out uint reasons);
        if (rc == 0) break;
        if (rc != ERROR_MORE_DATA) throw new Win32Exception(rc);
        count = needed;
        apps = new RM_PROCESS_INFO[needed];
    }

    // apps[0] ~ apps[count-1] 中保存着「占用者是谁」的信息
}
finally
{
    RmEndSession(session);   // ④ 务必关闭
}

4.2 会返回什么 ── 输出的形态与 RM_APP_TYPE

WhoLocks.exe C:\App\MyApp.exe 的方式运行完整版(4.3),输出会是下面这种形态。其中的值只是用于说明的示例,实际内容会因环境而异。

使用中的进程/服务: 2 件 (RebootReasons=0)
  PID=4132   类型=1 可重启=True  会话=2 名称=MyApp 服务=
  PID=6284   类型=3 可重启=False 会话=0 名称=MyAppAgent 服务=MyAppAgent

解读的要点有三个。类型(RM_APP_TYPE)决定了终止的方式可重启(bRestartable)如果是 False,更新后就不会自动启动会话(TSSessionId)如果与自身不同,就会遇到第 3 章中「无法跨会话」的限制

类型的数值与含义如下。9

名称 含义
0 RmUnknownApp 无法归入其他任何类别的应用。只能通过强制终止来停止
1 RmMainWindow 作为独立进程运行、拥有顶层窗口的 Windows 应用
2 RmOtherWindow 既非独立进程、也没有顶层窗口的 Windows 应用
3 RmService Windows 服务
4 RmExplorer Windows 资源管理器
5 RmConsole 独立的控制台应用
1000 RmCritical 无法停止,因此安装的完成需要 OS 重启。可能是关键进程、权限不足,或是启动 Restart Manager 的进程本身

在实际工作中,一旦出现 0 或 1000,就可以判断「温和的更新是行不通的」。如果是 1 或 5,可以按第 3 章的规范做法(WM_QUERYENDSESSION / CTRL_C_EVENT)来关闭;如果是 3,则会经由服务控制管理器停止。

4.3 完整版

用 C# 的 P/Invoke 编写,会是下面这样(.NET Framework 4.8 / .NET 8 均可运行)。

// WhoLocks.cs ── 用 Restart Manager 列举「谁占用着」指定文件
// 用法: WhoLocks.exe C:\App\MyApp.exe C:\App\MyLib.dll
using System;
using System.ComponentModel;
using System.IO;
using System.Runtime.InteropServices;
using System.Text;

internal static class Program
{
    private const int CCH_RM_SESSION_KEY = 32;      // 会话密钥是 GUID 字符串(32 个字符+终止符)
    private const int CCH_RM_MAX_APP_NAME = 255;
    private const int CCH_RM_MAX_SVC_NAME = 63;
    private const int ERROR_MORE_DATA = 234;

    [StructLayout(LayoutKind.Sequential)]
    private struct RM_UNIQUE_PROCESS
    {
        public uint dwProcessId;
        public System.Runtime.InteropServices.ComTypes.FILETIME ProcessStartTime;
    }

    // RM_APP_TYPE: 1=GUI 应用, 2=其他窗口, 3=服务, 4=Explorer, 5=控制台, 1000=关键进程

    [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
    private struct RM_PROCESS_INFO
    {
        public RM_UNIQUE_PROCESS Process;
        [MarshalAs(UnmanagedType.ByValTStr, SizeConst = CCH_RM_MAX_APP_NAME + 1)]
        public string strAppName;
        [MarshalAs(UnmanagedType.ByValTStr, SizeConst = CCH_RM_MAX_SVC_NAME + 1)]
        public string strServiceShortName;
        public int ApplicationType;
        public uint AppStatus;
        public uint TSSessionId;
        [MarshalAs(UnmanagedType.Bool)]
        public bool bRestartable;
    }

    // Restart Manager API 不使用 GetLastError,而是通过返回值返回 Win32 错误代码
    [DllImport("rstrtmgr.dll", CharSet = CharSet.Unicode)]
    private static extern int RmStartSession(
        out uint pSessionHandle, int dwSessionFlags, StringBuilder strSessionKey);

    [DllImport("rstrtmgr.dll")]
    private static extern int RmEndSession(uint dwSessionHandle);

    [DllImport("rstrtmgr.dll", CharSet = CharSet.Unicode)]
    private static extern int RmRegisterResources(uint dwSessionHandle,
        uint nFiles, string[] rgsFileNames,
        uint nApplications, RM_UNIQUE_PROCESS[] rgApplications,
        uint nServices, string[] rgsServiceNames);

    [DllImport("rstrtmgr.dll")]
    private static extern int RmGetList(uint dwSessionHandle,
        out uint pnProcInfoNeeded, ref uint pnProcInfo,
        [In, Out] RM_PROCESS_INFO[] rgAffectedApps, out uint lpdwRebootReasons);

    private static void Main(string[] args)
    {
        if (args.Length == 0)
        {
            Console.Error.WriteLine("用法: WhoLocks <文件路径> ...");
            Environment.Exit(2);
        }

        var sessionKey = new StringBuilder(CCH_RM_SESSION_KEY + 1);
        int rc = RmStartSession(out uint session, 0, sessionKey);
        if (rc != 0) throw new Win32Exception(rc, $"RmStartSession 失败 (rc={rc})");
        try
        {
            // 按 API 约定,注册的文件名必须是完整路径。
            // 为了在以相对路径调用时也能正常工作,在注册前先做规范化
            var fullPaths = Array.ConvertAll(args, Path.GetFullPath);
            rc = RmRegisterResources(session, (uint)fullPaths.Length, fullPaths, 0, null, 0, null);
            if (rc != 0) throw new Win32Exception(rc, $"RmRegisterResources 失败 (rc={rc})");

            // 先查询所需件数→分配数组→再获取。如果两次调用之间进程数
            // 增加了,会再次返回 ERROR_MORE_DATA,因此用循环重试
            uint count = 0;
            uint rebootReasons;
            RM_PROCESS_INFO[] apps = null;
            while (true)
            {
                rc = RmGetList(session, out uint needed, ref count, apps, out rebootReasons);
                if (rc == 0) break;
                if (rc != ERROR_MORE_DATA) throw new Win32Exception(rc, $"RmGetList 失败 (rc={rc})");
                count = needed;
                apps = new RM_PROCESS_INFO[needed];
            }

            Console.WriteLine($"使用中的进程/服务: {count} 件 (RebootReasons={rebootReasons})");
            for (int i = 0; i < count; i++)
            {
                RM_PROCESS_INFO a = apps[i];
                Console.WriteLine(
                    $"  PID={a.Process.dwProcessId,-6} 类型={a.ApplicationType} " +
                    $"可重启={a.bRestartable} 会话={a.TSSessionId} " +
                    $"名称={a.strAppName} 服务={a.strServiceShortName}");
            }
        }
        finally
        {
            RmEndSession(session);   // 会话的释放务必放在 finally 中
        }
    }
}

要点有三个。第一,Restart Manager API 的返回值本身就是 Win32 错误代码,不使用 GetLastError(DllImport 上不需要 SetLastError = true)。第二,RmGetList 的调用约定是「缓冲区不够时返回 ERROR_MORE_DATA(234)及所需件数」,因此要写成「查询→分配→获取」的循环。3 第三,lpdwRebootReasons 只要不是 0(RmRebootReasonNone),就意味着「仅终止应用是不够的,需要 OS 重启」这一判断。3 关于用 ByValTStr 对结构体字符串进行编组的写法,以及安全编写此类 P/Invoke 的一般性方法,请参见《在 C# 中安全调用 Win32 API —— P/Invoke 实务指南(DllImport / LibraryImport / CsWin32)》。

只要在这段代码基础上再加上 RmShutdown/RmRestart 这两个 P/Invoke,就成为「列举→温和终止→替换→重启」这一自定义更新程序的骨架。不过需要注意的是,能够调用 RmShutdown 的只限于自己调用了 RmStartSession 的进程,不能从 MSI 的自定义操作中调用(因为 MSI 自身在管理会话)。4

5. 应用侧的规范做法 ── 为了「行为得体地关闭、又能恢复原状重新启动」的三件套

无论是 Restart Manager 还是 MSI,都不会(除非指定强制)不由分说地用 terminate 直接杀死进程。如果应用侧不响应,最终还是会通过「文件使用中」对话框来麻烦用户。要让业务应用「对更新更有韧性」,就需要在应用侧实现下面这三件套。5

(1) 用 RegisterApplicationRestart 注册重启。在启动后立即调用一次。Restart Manager 只能重启已注册的应用,这是唯一「向 OS 传达重启时命令行」的手段。主要规范是:命令行中不包含 exe 名称(OS 会自动加上)、最大长度为 RESTART_MAX_CMD_LINE、以及启动后 60 秒内为防止重启循环而不会被重启。如果不需要在崩溃・挂起时重启,可以指定 RESTART_NO_CRASH RESTART_NO_HANG,把范围限定为「仅在更新时重启」。6
[DllImport("kernel32.dll", CharSet = CharSet.Unicode)]
private static extern int RegisterApplicationRestart(string commandLine, int flags);

// 在启动时注册好。可以用「/restored」标志分支到重启后的恢复处理
// RESTART_NO_CRASH(1) | RESTART_NO_HANG(2) = 仅在因更新而结束时才重启
RegisterApplicationRestart("/restored", 1 | 2);

(2) 响应 WM_QUERYENDSESSION(lParam=ENDSESSION_CLOSEAPP)。Restart Manager 在结束之前会用这条消息询问「是否可以关闭」。这里还不结束,如果已经准备就绪就返回 TRUE(因为其他应用可能还没准备好)。返回 FALSE 可以取消关机,但在指定强制时最终还是会被结束,因此应避免依赖 FALSE 的设计。在更新场景下,这个时机也是重新调用 RegisterApplicationRestart 的最后机会。把恢复所需的状态(例如打开着的表单 ID)烧录进命令行参数中重新注册,重启后就能恢复到「原来的画面」。56

(3) 在 WM_ENDSESSION 中保存未保存的数据后结束。实际的结束指令会通过 WM_ENDSESSION(wParam=TRUE、lParam=ENDSESSION_CLOSEAPP)送达。需要在超时(强制时应用为 30 秒)之内完成未保存数据的自动保存・工作状态的序列化・连接的关闭,然后结束。官方指南推荐「首先应该定期保存用户数据」。控制台应用的情况下,收到的不是 WM_ 消息而是 CTRL_C_EVENT,因此要用 SetConsoleCtrlHandler(C# 中则是 Console.CancelKeyPress)来做同样的事情。52

在 WPF/WinForms 中,入口是 Application.SessionEnding / Form.FormClosing(CloseReason.WindowsShutDown),但如果要区分到 ENDSESSION_CLOSEAPP 标志(即区分「不是 OS 重启,而是仅关闭应用之后再重启」),就需要挂钩窗口过程。此外,为了避免重启后的进程与「上一个正在收尾的自己」发生冲突,也请一并重新审视防止重复启动的 Mutex 设计(参见《防止 Windows 应用程序重复启动 ── 命名 Mutex 与重复执行时的窗口前置》)。

一旦具备了这三件套,MSI 更新的体验就会从「向所有人广播,请大家关闭应用」变成「更新一执行,应用就会自动关闭,更新后又能自动恢复到原来的画面」。这与 Office 那种「重启后会重新打开文档」的行为是同一套机制。

6. 自动更新的实现模式 ── 独立进程、rename-then-replace、重启时替换

整理一下自行搭建自动更新时的设计模式。大前提只有一个:无法自己覆盖自己(正在运行的 exe),因此更新处理必须在某个环节逃到「独立进程」或「别的名称」中去。

模式 A:独立进程的更新程序 + 等待后替换。主程序检测到更新后,启动更新程序 exe(或复制出来的临时 exe),自身随之结束。更新程序等待主程序进程结束(Process.WaitForExit 或等待 Mutex 释放),替换文件后再重启主程序。这种方式朴素且可靠,但为应对「主程序迟迟不结束」「多进程构成中需要等待的对象很多」的情况,组合使用前一节的 RmGetList 确认占用者,再用 RmShutdown 关闭,会很有效。

模式 B:rename-then-replace(不停止的更新)。如第 2 节所述,重命名正在运行的 exe/DLL 并放置新版本,实现「从下次启动开始使用新版本」。优点是不打断用户的操作,适合常驻型・长时间运行型的业务应用。Squirrel、Velopack 这类面向 .NET 的更新框架,也是以按版本分文件夹 + 替换入口 exe 的形式利用这一特性,把「更新在后台应用、从下次启动开始使用新版本」作为整套框架提供出来。如果自行实现,流程大致会落到:下载→验证→解压→重命名退避→放置→在下次启动时清理退避文件。

模式 C:MoveFileEx + MOVEFILE_DELAY_UNTIL_REBOOT(重启时替换)。对于服务的宿主进程、Shell 扩展这类「无论如何都停不下来・用重命名也逃不掉」的对象,最终手段就是预约在 OS 重启时替换。预约内容会记录在注册表的 HKLM\SYSTEM\CurrentControlSet\Control\Session Manager\PendingFileRenameOperations 中,并在下次启动的较早阶段(AUTOCHK 之后、页面文件创建之前)按注册顺序执行。使用时需要理解:只能由管理员组或 LocalSystem 调用;不能与 MOVEFILE_COPY_ALLOWED 同时使用(即仅限同一卷内);如果替换目标文件已存在,需要同时指定 MOVEFILE_REPLACE_EXISTING(不这样做的话,即使预约本身成功,重启时的替换也不会发生);以及函数的成功指的是「预约成功」,而无法得知实际的替换结果。安装程序说「需要重启」的时候,内部积压的正是这类预约。7

无论哪种模式,都有两点共通的注意事项。一是服务的更新:由于 Windows 服务无法自己替换自己,常规做法是分离出一个负责「通过 SCM 执行 Stop→替换→Start」的更新用进程(或专用的更新服务)(服务设计的基础参见《Windows 服务的创建与运维 ── 从任务计划程序的取舍到 BackgroundService 服务化》)。二是更新文件的验证:自行实现替换机制,也就等于自行实现了一套「分发任意 exe 并让其执行的机制」,如果省略了签名验证与下载路径的保护,更新机制本身就会变成攻击途径。这一议题在《自动更新的安全设计——为什么仅靠 HTTPS 还不够》中单独讨论过。

7. 更新方式的判断表

方式 适合的场景 注意事项
MSI + 接受 OS 重启 发布频率低(每年数次)、可以在夜间・休息日安排维护时间 实现最简单。但会持续向用户显示「需要重启」。依赖重启时替换(PendingFileRenameOperations)7
MSI + Restart Manager 集成(MsiRMFilesInUse + 应用侧三件套) 想在保持 MSI 分发的同时改善更新体验。公司内部分发的标准应用 安装程序一侧几乎是自动的。效果取决于应用侧 RegisterApplicationRestart/WM_QUERYENDSESSION 的实现45
自定义更新程序(独立进程 + rename-then-replace,包括 Squirrel/Velopack) 更新频率高(每周以上)、用户没有管理员权限、不想中断用户的操作 需要有自行实现清理处理・签名验证・失败时回滚的觉悟。加入 RmGetList/RmShutdown 做「占用者是谁」的对策会更稳妥3
ClickOnce 公司内部 Windows 客户端只想要「启动时自动更新」 采用启动时更新模型,文件占用问题本身就不容易发生。相关限制请参见《ClickOnce 是什么
服务端的自我更新(拆分更新用进程) 无人值守环境・设备 PC 上 24 小时运行的服务无法停下来维护 服务自身无法替换自己。必须由负责 Stop→替换→Start 的独立进程来完成。需注意会话边界(LocalSystem 无法关闭用户会话中的应用)2

如果拿不定主意,请首先考虑「MSI + Restart Manager 集成」。因为搭乘的是 OS 标准机制,实现量最小,而应用侧的三件套,即使将来迁移到自定义更新程序,也能原封不动地作为资产沿用。

8. 总结

  • 使用中的 exe/DLL 无法覆盖写入・删除,但在同一卷内进行重命名是可以的。利用这种不对称性的 rename-then-replace,就是「不停止的更新」的基本形态。
  • Restart Manager 是一路照顾「谁占用着文件」的列举(RmGetList)、温和的终止(RmShutdown)、更新后的重启(RmRestart)的 OS 标准 API,Windows Installer 4.0 以后的 MSI 会自动使用它。
  • 应用侧的规范做法是三件套:用 RegisterApplicationRestart 注册重启,对 WM_QUERYENDSESSION(ENDSESSION_CLOSEAPP)返回 TRUE,在 WM_ENDSESSION 中保存未保存的数据后结束。仅凭这些,就能成为「更新后恢复原状重新启动的应用」。
  • 60 秒规则(刚启动时不会被重启)、强制终止的超时时间(应用 30 秒・服务 20 秒)、会话边界(无法从 LocalSystem 关闭用户应用),是实际运维中容易绊倒的地方。
  • 无论如何都无法替换时,可以用 MoveFileEx + MOVEFILE_DELAY_UNTIL_REBOOT 预约在 OS 重启时替换。必须具备管理员权限・仅限同一卷内・成败指的是预约的成败。
  • 自行实现更新机制,就等于自行实现「分发任意 exe 的机制」。请将签名验证・路径保护・回滚也一并纳入设计。

相关文章

相关咨询领域

合同会社小村软件在业务应用的自动更新机制设计与实现(Restart Manager 集成、自定义更新程序、引入 Squirrel/Velopack)、改善「每次更新都要让所有人关闭应用」的运维方式、对现有安装程序中「文件被占用」「需要重启」问题的调查与对策等方面提供支持。

参考链接

  1. Microsoft Learn, About Restart Manager。关于 Restart Manager 的目的(通过减少・消除因使用中文件导致的重启)、按 GUI 应用→控制台应用→服务→资源管理器的顺序停止并按相反顺序重启、不支持跨会话关闭、Windows Installer 4.0 会自动使用 Restart Manager、重要的系统服务在不重启 OS 的情况下无法停止等内容。  2 3 4 5

  2. Microsoft Learn, RmShutdown function。关于即使指定 RmForceShutdown,不响应的应用也会在 30 秒(服务为 20 秒)后被强制终止、可通过 RmShutdownOnlyRegistered 实现「只有当所有对象都已注册重启时才终止」、LocalSystem 的服务无法终止・重启其他用户会话中的应用、以及 ERROR_FAIL_NOACTION_REBOOT 等返回值等内容。  2 3 4 5 6 7 8 9

  3. Microsoft Learn, RmGetList function。关于该函数以 RM_PROCESS_INFO 数组的形式返回正在使用已注册资源的应用・服务、缓冲区不足时返回 ERROR_MORE_DATA(234)及所需件数的调用约定、lpdwRebootReasons 会返回需要 OS 重启的原因(RM_REBOOT_REASON)、自 Windows Vista 起由 rstrtmgr.dll 提供等内容。  2 3 4 5 6

  4. Microsoft Learn, Using Windows Installer with Restart Manager。关于 Windows Installer 4.0 会自动使用 Restart Manager,默认优先终止・重启应用而非重启 OS、MsiRMFilesInUse 对话框可以在完整 UI 安装时提供「自动关闭应用并重启」的选项(旧环境会回退到 FilesInUse 对话框)、静默安装时始终使用 Restart Manager 且应用会被自动终止、自定义操作应排在 InstallValidate 之前并通过 MsiRestartManagerSessionKey 调用 RmJoinSession,且不应调用 RmShutdown 等函数、以及 MSIRESTARTMANAGERCONTROL 等控制属性等内容。  2 3 4

  5. Microsoft Learn, Guidelines for Applications (Restart Manager)。关于 GUI 应用会收到 WM_QUERYENDSESSION(lParam=ENDSESSION_CLOSEAPP)、如果已准备就绪应返回 TRUE 且此时尚不结束、实际的结束通过 WM_ENDSESSION 进行、不响应的应用还会收到 WM_CLOSE、控制台应用会收到 CTRL_C_EVENT、重启必须先通过 RegisterApplicationRestart 完成注册等内容。  2 3 4 5 6 7

  6. Microsoft Learn, RegisterApplicationRestart function。关于重启时命令行的注册(不包含 exe 名称,最大长度为 RESTART_MAX_CMD_LINE)、RESTART_NO_CRASH/NO_HANG/NO_PATCH/NO_REBOOT 等标志、因更新而重启是自动进行的,而崩溃・挂起时的重启需要用户同意、为防止循环而设置的「启动不满 60 秒不会被重启」的规则、以及在 WM_QUERYENDSESSION 处理过程中是更新时重新注册的最后机会等内容。  2 3 4

  7. Microsoft Learn, MoveFileExW function。关于 MOVEFILE_DELAY_UNTIL_REBOOT 会将预约写入 HKLM\SYSTEM\CurrentControlSet\Control\Session Manager 下的 PendingFileRenameOperations(REG_MULTI_SZ)、在 AUTOCHK 执行之后・页面文件创建之前按注册顺序执行、需要管理员组或 LocalSystem 权限、不能与 MOVEFILE_COPY_ALLOWED 同时使用、当目标文件已存在时需要同时指定 MOVEFILE_REPLACE_EXISTING、返回值表示的是预约是否成功而非实际移动是否成功、以及向 lpNewFileName 传入 NULL 则表示重启时删除等内容。  2 3

  8. Microsoft Learn, WTSQueryUserToken function (wtsapi32.h)。关于该函数用于根据会话 ID 获取正在登录用户的主访问令牌、调用时需要在 LocalSystem 账户上下文中运行并具备 SE_TCB_NAME 特权、该函数面向高度可信的服务,需注意避免令牌泄漏并在用完后务必用 CloseHandle 关闭句柄、以及可以用 WTSEnumerateSessions 枚举会话 ID 等内容。 

  9. Microsoft Learn, RM_APP_TYPE enumeration (restartmanager.h)。关于 RM_PROCESS_INFO 结构体所表示的应用类型定义了 RmUnknownApp(0,无法归类,只能通过强制终止来停止)、RmMainWindow(1,拥有顶层窗口的独立进程 Windows 应用)、RmOtherWindow(2,既非独立进程也没有顶层窗口的 Windows 应用)、RmService(3,Windows 服务)、RmExplorer(4,Windows 资源管理器)、RmConsole(5,独立的控制台应用)、RmCritical(1000,因进程无法停止,安装的完成需要系统重启,原因可能是关键进程、权限不足,或是启动 Restart Manager 的安装程序自身)等内容。 

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

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

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

常见问题

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

为什么正在运行的 exe 或已加载的 DLL 无法覆盖写入,却可以重命名?
Windows 会把正在运行的 exe 或已加载的 DLL 作为内存映射(镜像节)来持有,因此对文件内容的改写和删除会出错(共享冲突或拒绝访问)。而目录上的「名称」与文件内容是相互独立的,因此在同一卷内进行重命名(移动)即使在运行中也会成功。利用这种不对称性的就是 rename-then-replace 模式:先把旧的 exe 重命名为退避名,再把新的 exe 以原来的名称放置,从下次启动开始就会使用新版本。Chrome 以及 Squirrel/Velopack 系的自动更新,本质上也是建立在这一特性之上的。
Restart Manager 是一个能做什么的 API?
只要注册想要更新的文件,它就会列举出「当前正在使用该文件的应用程序・服务清单」,并在可能的情况下将它们终止,还能在更新后完成重启,是 Windows Vista 以后标准提供的 API。流程是:用 RmStartSession 创建会话,用 RmRegisterResources 注册目标文件,用 RmGetList 列举占用进程,用 RmShutdown 终止,用 RmRestart 重启,最后用 RmEndSession 结束。停止顺序为 GUI 应用→控制台应用→服务→资源管理器,重启则按相反顺序进行。这是用来减少「需要重启」提示的机制,Windows Installer 4.0 以后的 MSI 会自动使用它。
预先调用 RegisterApplicationRestart 会发生什么?
应用可以向 OS 注册自己的重启命令行,在因更新而关闭之后,Restart Manager 会用该命令行自动重新启动应用(Restart Manager 只能重启已注册的应用)。崩溃或挂起时该应用同样会被纳入重启对象,但那种情况下需要用户同意,而因更新而触发的重启则会自动进行。请注意,为了防止循环,启动后不满 60 秒的应用不会被重启,并且命令行中不能包含 exe 名称。恢复所需的状态(例如打开着的文件)按惯例应包含在命令行参数中重新注册。
MoveFileEx 的 MOVEFILE_DELAY_UNTIL_REBOOT 在什么情况下使用?
这是在进程无论如何都无法停止、rename-then-replace 也无法使用时的最终手段,用来预约在 OS 重启时进行文件的移动・删除。预约会写入注册表的 PendingFileRenameOperations(HKLM\\SYSTEM\\CurrentControlSet\\Control\\Session Manager),并在下次启动的较早阶段(AUTOCHK 之后、页面文件创建之前)按注册顺序执行。调用需要管理员组或 LocalSystem 权限,且不能与 MOVEFILE_COPY_ALLOWED 同时使用,因此无法用于移动到其他卷。还应该掌握一点:函数的成功与否指的是「预约是否成功」,而不是实际替换是否成功。
在 MSI 安装程序中,如何妥善地处理「文件被占用」?
Windows Installer 4.0 以后会自动与 Restart Manager 联动,默认优先终止・重启应用而不是重启 OS。只要在安装包中添加 MsiRMFilesInUse 对话框,在完整 UI 安装时就会向用户呈现「自动关闭应用程序并重启」的选项。如果在较旧的 Windows Installer 上运行,则会回退到传统的 FilesInUse 对话框,因此两者都加入是常规做法。行为可以通过 MSIRESTARTMANAGERCONTROL 等属性来控制,静默安装时会始终使用 Restart Manager,应用会被自动终止。如果应用侧实现了 RegisterApplicationRestart 与 WM_QUERYENDSESSION 响应,就能实现更新后应用自动恢复原状的「接近无停机的更新」。

作者简介

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

Go Komura

小村软件有限公司 代表

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

返回博客列表