Windows 应用安全处理子进程的检查清单

· 更新日期: · · Windows, Process, Job Object, IPC, C++, .NET, C#

更新记录(2 条,最后更新 2026年09月03日)

本文的修改记录。已保存的更新前版本,可通过带有 DOI 的永久链接阅读。

本文此前是日文原文的节译,缺少大量章节、表格、Mermaid 图、图题、脚注与 FAQ。现已改写为日文原文的完整译文,技术主张与日文版一致,并补上了此前缺失的图与表,同时统一了全篇的术语译法。 查看更新前的版本 (DOI: 10.5281/zenodo.22276911)
补充了日文原文中已有的咨询引导(consultation_services)。正文内容没有改动。 查看更新前的版本 (DOI: 10.5281/zenodo.21615521)
首次发布
引用本文(DOI: 10.5281/zenodo.21615520)

本文保存于 Zenodo。以下同时提供始终指向最新版本的 DOI,以及固定于您正在阅读版本的 DOI。

小村 豪(2026)。《Windows 应用安全处理子进程的检查清单》。小村软件有限公司。https://doi.org/10.5281/zenodo.21615520 https://comcomponent.com/zh-CN/blog/2026/03/20/001-windows-app-safe-child-process-handling-job-object-exit-propagation-stdio-watchdog/

DOI(最新版本)
10.5281/zenodo.21615520
DOI(此版本)
10.5281/zenodo.22282040

下载附带日英双语工作表的 Excel 检查清单

转换工具、更新程序、解析 worker、外部 CLI、PowerShell、ffmpeg、公司内部工具。 Windows 应用比想象中更容易依赖子进程。

不过,真正出事的地方不是“有没有启动成功”。

  • 父进程挂了,子进程却还留着
  • 只剩下孙进程还活着
  • stdout / stderr 堵住,导致 WaitForExit 一直不返回
  • watchdog 和被监视对象一起死掉
  • 以为 Kill(entireProcessTree: true) 已经收尾,其实只是观测先结束了

在 Windows 上安全处理子进程的诀窍,不在于 选择哪个启动 API,而在于 决定进程树的所有者,并设计好结束流程和 I/O。

本文把 Job Object、退出传播、标准输入输出、watchdog 整理成一张统一的设计。

出事的地方在启动之外说明子进程出问题的地方不在于有没有启动成功,而在于父进程挂了却只剩子进程残留、stdout 堵塞这类情况;诀窍不是选择启动 API,而是决定进程树的所有者并设计好结束流程和 I/O。选择启动 API这里不是问题的本体决定进程树的所有者安全地处理子进程设计结束流程设计 I/O

图1:子进程是否安全,取决于所有权、结束流程和 I/O 的设计,而不是启动 API。

本文用到的术语

先把以英文原样出现的词逐条整理一下。

术语 一句话解释
process tree 进程树。指从父进程启动的子进程,以及子进程再启动的孙进程在内的整个族系
graceful shutdown 协调结束。先请求对方“请结束”,让对方做完收尾工作后自行退出的做法。与强制终止相对
I/O completion port Windows 的异步 I/O 完成通知机制。与 Job Object 关联后,可以收到进程启动或结束的通知
message pump 消息循环。拥有窗口的线程不断从操作系统取出消息并处理的机制。这里一旦停下,画面就会卡住
heartbeat 为了确认存活,由子进程定期发出的信号。用于检测进程“活着但没有推进”的状态
restart budget 重启预算。规定一定时间内最多可以重启多少次的上限。用来阻止 crash loop
drain 抽取。把 pipe 里积压的输出读完,让写入一侧不至于堵塞

整体图景

先把登场角色的关系画成一张图。

带 JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE 的 Job Object用 exit handle 检测结束用 heartbeat 检测卡死在 restart budget 范围内重建父应用 / worker 本体job handle 的最终所有者子进程 helper.exe孙进程 converter.exe孙进程 ffmpeg.exewatchdog放在 Job 之外

图2:整体图景。Job 的边界就是进程树的边界,只有 watchdog 在外面。

要点有两个。

  • Job 的边界就是进程树的边界。归拢的依据不是父子关系而是 Job 归属,所以孙进程再多也不会漏掉
  • 只有 watchdog 在 Job 之外。放进去的话,会和被监视对象一起被清理掉

1. 先下结论

先把实务上最有效的部分列出来。

  • 想把父进程的生死和子进程树的寿命绑定在一起,基准点是 Job Object
  • 向 console 请求结束 和 回收进程树 是两件不同的事
    • 前者用 process group 和 GenerateConsoleCtrlEvent
    • 后者用 Job Object
  • 想从启动那一刻起就加入 Job,用 STARTUPINFOEX 和 PROC_THREAD_ATTRIBUTE_JOB_LIST 的设计更直接
  • 标准输出 / 标准错误要并行抽取,这是基本原则
  • 使用 stdin 时,要设计到写完后 close 传递 EOF 为止
  • watchdog 放在被监视对象所属 Job 的外面 更安全
  • .NET 的 Kill(entireProcessTree: true) 作为显式终止的 API 很方便,但不能替代包含父进程崩溃时自动回收和 graceful shutdown 的整体设计

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

2. 什么才是危险的地方

子进程启动的实现,最初大概 10 行左右就能写出来。 但真正出事的,往往在这 10 行之外。

  • 父进程挂掉之后,子进程或孙进程继续残留
  • helper 又启动了另一个 helper,而调用方只等待直属子进程就以为万事大吉
  • stdout / stderr 其中一侧堵住,父进程和子进程互相等待
  • 在 UI 线程里等待,导致画面和 COM 都卡死
  • watchdog 和被监视对象成了命运共同体,出异常时一起挂掉

这里要注意的是,“子进程管理”不是单个 API 的话题。

至少把下面这 4 件事分开考虑,思路会更清晰。

  1. 由谁拥有进程树
  2. 如何请求协调结束
  3. 标准输入输出如何流转
  4. 如何监控异常终止和卡死
需要分开考虑的 4 个问题说明子进程管理不是单个 API 的话题,把由谁拥有进程树、如何请求协调结束、标准输入输出如何流转、如何监控异常终止和卡死这 4 件事分开考虑,思路会更清晰。1. 进程树的所有者2. 请求协调结束的方式3. 标准输入输出的流转方式4. 异常终止和卡死的监控不是单个 API 的话题

图3:子进程管理要拆成这 4 个问题来设计。

3. 不要混淆机制的角色

process handle / process group / Job Object 看起来相似,但角色不同。

机制 主要角色 适合的场景 单靠它不够的地方
process handle 等待单个进程结束、获取 exit code 等待单次工具执行完成 回收孙进程
process group 向 console 传播 Ctrl+Break console 子进程的协调结束 父进程崩溃时的 cleanup、GUI 子进程
Job Object 归拢进程树、施加限制、统一结束 worker tree、updater、helper chain 应用专属的“先保存再关闭”

process group 是决定 console signal 发往哪里 的机制,而不是 父进程死亡时把整棵树一起清理 的机制。 另一方面,Job Object 是 Windows 提供的、把一组进程当作一个单位来管理 的机制。

3.1 按语言对照

本文混杂着 Win32 和 .NET 的内容。为了方便只看自己那一列,先把对应关系列出来。

想做的事 Win32 / C++ .NET / C#
启动进程 CreateProcessW Process.Start
创建 Job 并施加限制 CreateJobObjectW + SetInformationJobObject 用 P/Invoke 调用同样的 API。标准库里没有 Job Object 的封装
从启动那一刻起加入 Job STARTUPINFOEX + PROC_THREAD_ATTRIBUTE_JOB_LIST 同上。无法通过 ProcessStartInfo 指定
事后加入 Job AssignProcessToJobObject 用 P/Invoke 调用同一 API,并传入 Process.Handle
等待结束 WaitForSingleObject Process.WaitForExit,异步则用 WaitForExitAsync(.NET 5 以后)
获取 exit code GetExitCodeProcess Process.ExitCode
读取 stdout / stderr 创建匿名 pipe,在单独的线程里读取 RedirectStandardOutput 与 BeginOutputReadLine
让 GUI 子进程自行关闭 发送 WM_CLOSE Process.CloseMainWindow
向 console 子进程发送 Ctrl+Break CREATE_NEW_PROCESS_GROUP + GenerateConsoleCtrlEvent 没有对应 API,需要 P/Invoke
强制终止整棵树 TerminateJobObject,或者关闭最后一个 job handle Process.Kill(entireProcessTree: true)(.NET Core 3.0 以后),或者同样用上面的 P/Invoke
等待大量子进程结束 RegisterWaitForSingleObject / SetThreadpoolWait Process.Exited 事件,或者 WaitForExitAsync

从这里可以看出,只有 Job Object 相关的部分,即使在 .NET 里也要直接调用 Win32 API。.NET 侧提供的能力,止步于单个进程的操作。

.NET 的覆盖范围与 Job 的边界说明 .NET 标准库提供的只有启动、等待结束等单进程级别的操作,Job Object 相关的操作没有封装,需要用 P/Invoke 直接调用 Win32 API。单个进程级别的操作用 .NET 标准的 Process 就够了Job Object 相关的操作没有封装用 P/Invoke 调用 Win32 API

图4:即使在 .NET 中,归拢进程树的那部分也要直接调用 Win32 API。

4. 以 Job Object 为基准

Job Object 最强的地方在于,可以按照 “属于哪个 Job”而不是“是谁的子进程” 来归拢 process tree。加入 Job 的进程通过 CreateProcess 创建的子进程,默认也会进入同一个 Job。

此外,加上 JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE 之后,当最后一个 job handle 被关闭时,与该 Job 关联的所有进程都会终止。

用 Job 归拢就不会漏掉回收说明 Job Object 按属于哪个 Job 而不是按是谁的子进程来归拢进程树,加入 Job 的进程创建的子进程默认也进入同一个 Job,加上 KILL_ON_JOB_CLOSE 后最后一个 job handle 被关闭时所有进程都会终止。按“是谁的子进程”追踪孙进程一多就会漏按“属于哪个 Job”归拢子进程创建的子进程也进同一个 JobKILL_ON_JOB_CLOSE最后一个 handle 关闭时全部终止

图5:归拢的依据是 Job 归属而不是父子关系,所以连孙进程也能回收。

4.1 首先要掌握的 4 点

1. 想在父进程结束时把整棵树一起清理,就用 KILL_ON_JOB_CLOSE

这是 Windows 应用处理 helper / worker 时的基础。也可以设计成显式调用 TerminateJobObject,但如果想把 包括父进程异常退出在内的清理 都寄托在父进程的生命周期上,KILL_ON_JOB_CLOSE 会更直观。

2. 不要随意加上 BREAKAWAY

JOB_OBJECT_LIMIT_BREAKAWAY_OK 和 JOB_OBJECT_LIMIT_SILENT_BREAKAWAY_OK 看起来很方便,但也可能成为 原本以为能清理的树中有一部分脱离出去 的原因。除非确实有意为之,否则不加 breakaway 会降低出事概率。

3. 想从启动那一刻就加入 Job,就用 PROC_THREAD_ATTRIBUTE_JOB_LIST

也可以用 AssignProcessToJobObject 事后关联。 但如果场景是 希望启动后立刻就以 Job 归属为前提,用 STARTUPINFOEX 和 PROC_THREAD_ATTRIBUTE_JOB_LIST 在创建时就指定 Job,会更合理。

4. 不要让 job handle 的所有者变得模糊

KILL_ON_JOB_CLOSE 在 最后一个 handle 被关闭时 才生效。 反过来说,如果把 job handle 复制到其他进程,或者无意中让它被继承,那么即使父进程死了,也不会按预期完成清理。谁是 job handle 的最终所有者,应该提前决定清楚。

不要让 job handle 的所有者变得模糊说明 KILL_ON_JOB_CLOSE 在最后一个 handle 关闭时才生效,所以把 job handle 复制到其他进程或无意中让它被继承时,即使父进程死了也不会按预期清理,应该提前决定谁是最终所有者。所以把 job handle 复制或让它被继承最后一个 handle 一直不关闭父进程死了也不会清理提前决定最终所有者

图6:KILL_ON_JOB_CLOSE 要等“最后一个 handle”关闭才生效。

4.2 Job Object 也能用于 observability,但通知并非万能

Job Object 有一种机制,可以关联 I/O completion port 来接收通知。不过,最好不要把 completion port 的通知当作在所有情况下都完全保证送达的通知。

因此,completion port 用于:

  • 监控
  • 汇总统计
  • 日志
  • 指标

会很方便,但 最好不要仅靠它来构建 correctness。

completion port 通知的适用范围说明 Job Object 可以关联 I/O completion port 接收通知,但不应把它当作在所有情况下都完全保证送达的通知,它适合用于监控、汇总、日志和指标,不要仅靠它来构建 correctness。Job 的 completion port 通知用于监控、汇总、日志很方便不要当作完全保证送达的通知不要仅靠它来构建 correctness

图7:通知用来观测,不要拿它当作正确性的依据。

4.3 用最小代码来看

文字说明反而更长,所以两种语言的最小形式都放在这里。

C++ 侧分 3 步:创建 Job → 加上 KILL_ON_JOB_CLOSE → 在启动时指定 Job。

// Windows 10 以后 / C++17。把 helper.exe 放进 Job 里启动,父进程结束时连整棵树一起清理
#include <windows.h>
#include <memory>
#include <string>

int wmain()
{
    // 0. 先把要启动的文件用绝对路径确定下来。
    // 如果把 lpApplicationName 设为 nullptr,让系统从命令行开头去查找,
    // 搜索范围里就会包含“父进程的当前目录”和“PATH”。
    // 当 helper.exe 不在自己所在的文件夹里时,只要有人把同名的可执行文件
    // 放到可写位置,它就会以父进程的权限运行
    wchar_t modulePath[MAX_PATH]{};
    DWORD moduleLen = GetModuleFileNameW(nullptr, modulePath, MAX_PATH);
    if (moduleLen == 0 || moduleLen >= MAX_PATH)   // 被 MAX_PATH 截断的情况也按失败处理
    {
        return 1;
    }

    std::wstring application(modulePath, moduleLen);
    application.resize(application.find_last_of(L'\\') + 1);   // 自己的可执行文件所在的文件夹
    application += L"helper.exe";

    // 1. 创建 Job,并让最后一个 handle 关闭时把里面的内容全部终止
    HANDLE job = CreateJobObjectW(nullptr, nullptr);
    if (job == nullptr)
    {
        return 1;
    }

    JOBOBJECT_EXTENDED_LIMIT_INFORMATION limits{};
    limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
    if (!SetInformationJobObject(job, JobObjectExtendedLimitInformation, &limits, sizeof(limits)))
    {
        CloseHandle(job);
        return 1;
    }

    // 2. 创建属性列表,使进程从启动那一刻起就归属于 Job
    SIZE_T attributeSize = 0;
    InitializeProcThreadAttributeList(nullptr, 1, 0, &attributeSize);  // 空跑一次以取得所需大小
    auto storage = std::make_unique<BYTE[]>(attributeSize);
    auto attributes = reinterpret_cast<LPPROC_THREAD_ATTRIBUTE_LIST>(storage.get());

    if (!InitializeProcThreadAttributeList(attributes, 1, 0, &attributeSize))
    {
        CloseHandle(job);
        return 1;
    }

    // job 的值必须一直存活到调用 DeleteProcThreadAttributeList 为止
    if (!UpdateProcThreadAttribute(attributes, 0, PROC_THREAD_ATTRIBUTE_JOB_LIST,
                                   &job, sizeof(job), nullptr, nullptr))
    {
        DeleteProcThreadAttributeList(attributes);
        CloseHandle(job);
        return 1;
    }

    // 3. 启动
    STARTUPINFOEXW startup{};
    startup.StartupInfo.cb = sizeof(startup);
    startup.lpAttributeList = attributes;

    PROCESS_INFORMATION info{};
    // CreateProcessW 要求传入可写的缓冲区。
    // argv[0] 也放同一个路径。因为其中含有空格,所以一定要用引号括起来
    std::wstring commandLine = L"\"" + application + L"\" --input data.bin";

    BOOL created = CreateProcessW(
        application.c_str(), commandLine.data(), nullptr, nullptr,
        FALSE,                          // 收窄需要继承的 handle
        EXTENDED_STARTUPINFO_PRESENT,
        nullptr, nullptr,
        &startup.StartupInfo, &info);

    DeleteProcThreadAttributeList(attributes);

    if (!created)
    {
        CloseHandle(job);
        return 1;
    }

    WaitForSingleObject(info.hProcess, INFINITE);

    DWORD exitCode = 0;
    GetExitCodeProcess(info.hProcess, &exitCode);

    CloseHandle(info.hThread);
    CloseHandle(info.hProcess);
    CloseHandle(job);   // 最后一个 job handle。此时残留的子孙进程会被一并终止
    return static_cast<int>(exitCode);
}

这段代码踩到了 UpdateProcThreadAttribute 文档里写明的两个约束。两者都很容易看漏。

  • PROC_THREAD_ATTRIBUTE_JOB_LIST 只能在 Windows 10 / Windows Server 2016 以后 使用。如果要面向更早的版本,就必须退回到 AssignProcessToJobObject
  • 传给 UpdateProcThreadAttribute 的值,必须一直存活到调用 DeleteProcThreadAttributeList 为止。传入局部变量后立刻离开作用域的写法会出问题

启动的文件一定要用绝对路径指定

开头“0.”处之所以要用 GetModuleFileNameW 拼出路径,不是写法上的偏好,而是为了确定究竟哪个可执行文件会被运行。

如果给 lpApplicationName 传 nullptr,命令行开头的那个词就会被当作模块名。如果其中不含路径,Windows 会按下面的顺序查找。

  1. 加载应用程序的目录
  2. 父进程的当前目录
  3. 32 位的系统目录
  4. 16 位的系统目录
  5. Windows 目录
  6. PATH 环境变量中排列的目录

问题出在 2 和 6。当 helper.exe 不在 1 里时 —— 部署遗漏、按其他配置构建、卸载残留 —— 查找就会推进到 2。如果当前目录是可写位置(直接从用户的下载文件夹里启动、把共享文件夹当作工作目录),那么放在那里的 helper.exe 就会 以和父进程相同的权限 运行。在 PATH 可被改写的环境里,6 也是一样。

Microsoft 的文档对这一点专门设了一节“安全备注”,并明确写着 “要避免这个问题,请不要给 lpApplicationName 传 NULL”。同一节里还有那个著名的例子:含空格的路径如果不用引号括起来,可能会启动 C:\Program.exe。所以命令行一侧也用 "..." 括了起来。

C# 的 ProcessStartInfo 也是一样。当 UseShellExecute = false 时,.NET 会把 FileName 和参数拼成一条命令行,并给 lpApplicationName 传 null,因此 只传文件名的话上述查找会原样发生。请传入用 AppContext.BaseDirectory 拼出的绝对路径。

即使在你觉得“不可能出现这种部署失误”的环境里,写上去的成本也几乎为零。在启动子进程的代码里,用相对名称写可执行文件基本没有任何理由。

相对名称启动的查找会带来安全隐患说明给 lpApplicationName 传 NULL 并只用文件名启动时,查找范围会包含父进程的当前目录和 PATH,当原本的 helper.exe 不存在时,放在可写位置的同名可执行文件会以父进程的权限运行,因此一定要用绝对路径指定。要防止就只用文件名启动查找范围包含当前目录和 PATH原本位置上没有 helper.exe 时被放置的同名 EXE 以父进程权限运行用绝对路径指定,并用引号括起来

图8:要启动的文件不要交给查找,用绝对路径确定下来。

.NET 里没有 Job Object 的封装,所以要用 P/Invoke。结构体的定义看起来很长,但实际调用的只有 2 个函数。

// .NET 8 / C# 12。创建 Job 并加上 KILL_ON_JOB_CLOSE,再把已启动的进程放进去
using System;
using System.Diagnostics;
using System.Runtime.InteropServices;
using Microsoft.Win32.SafeHandles;

internal static class KillOnCloseJob
{
    private const int JobObjectExtendedLimitInformation = 9;
    private const uint JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x2000;

    [StructLayout(LayoutKind.Sequential)]
    private struct JOBOBJECT_BASIC_LIMIT_INFORMATION
    {
        public long PerProcessUserTimeLimit;
        public long PerJobUserTimeLimit;
        public uint LimitFlags;
        public nuint MinimumWorkingSetSize;
        public nuint MaximumWorkingSetSize;
        public uint ActiveProcessLimit;
        public nuint Affinity;
        public uint PriorityClass;
        public uint SchedulingClass;
    }

    [StructLayout(LayoutKind.Sequential)]
    private struct IO_COUNTERS
    {
        public ulong ReadOperationCount;
        public ulong WriteOperationCount;
        public ulong OtherOperationCount;
        public ulong ReadTransferCount;
        public ulong WriteTransferCount;
        public ulong OtherTransferCount;
    }

    [StructLayout(LayoutKind.Sequential)]
    private struct JOBOBJECT_EXTENDED_LIMIT_INFORMATION
    {
        public JOBOBJECT_BASIC_LIMIT_INFORMATION BasicLimitInformation;
        public IO_COUNTERS IoInfo;
        public nuint ProcessMemoryLimit;
        public nuint JobMemoryLimit;
        public nuint PeakProcessMemoryUsed;
        public nuint PeakJobMemoryUsed;
    }

    [DllImport("kernel32.dll", SetLastError = true)]
    private static extern SafeJobHandle CreateJobObjectW(IntPtr attributes, IntPtr name);

    [DllImport("kernel32.dll", SetLastError = true)]
    [return: MarshalAs(UnmanagedType.Bool)]
    private static extern bool SetInformationJobObject(
        SafeJobHandle job, int infoClass, ref JOBOBJECT_EXTENDED_LIMIT_INFORMATION info, uint infoSize);

    [DllImport("kernel32.dll", SetLastError = true)]
    [return: MarshalAs(UnmanagedType.Bool)]
    private static extern bool AssignProcessToJobObject(SafeJobHandle job, IntPtr process);

    /// <summary>创建 Job。返回的 handle 要在应用的整个生命周期内保持打开。</summary>
    public static SafeJobHandle Create()
    {
        var job = CreateJobObjectW(IntPtr.Zero, IntPtr.Zero);
        if (job.IsInvalid)
        {
            throw new InvalidOperationException($"CreateJobObject 失败。code={Marshal.GetLastWin32Error()}");
        }

        var info = default(JOBOBJECT_EXTENDED_LIMIT_INFORMATION);
        info.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;

        var size = (uint)Marshal.SizeOf<JOBOBJECT_EXTENDED_LIMIT_INFORMATION>();
        if (!SetInformationJobObject(job, JobObjectExtendedLimitInformation, ref info, size))
        {
            // 不要把“创建成功但配置失败”这种半途而废的 Job 就这么丢掉。
            // 如果调用方捕获初始化错误后会重试,
            // 那么每试一次就会漏掉一个内核句柄(上面的 C++ 版本
            // 在这条路径上调用了 CloseHandle)
            var error = Marshal.GetLastWin32Error();
            job.Dispose();
            throw new InvalidOperationException($"SetInformationJobObject 失败。code={error}");
        }

        return job;
    }

    public static void Add(SafeJobHandle job, Process process)
    {
        if (!AssignProcessToJobObject(job, process.Handle))
        {
            throw new InvalidOperationException($"AssignProcessToJobObject 失败。code={Marshal.GetLastWin32Error()}");
        }
    }
}

// 如果用裸的 IntPtr 持有,在初始化失败的路径上就没人能关闭它。
// 改成 SafeHandle 之后,失败路径只需调用一次 Dispose 即可
internal sealed class SafeJobHandle : SafeHandleZeroOrMinusOneIsInvalid
{
    // 由封送器作为 P/Invoke 的返回值来创建,所以必须能够无参构造
    private SafeJobHandle() : base(ownsHandle: true) { }

    protected override bool ReleaseHandle() => CloseHandle(handle);

    [DllImport("kernel32.dll", SetLastError = true)]
    [return: MarshalAs(UnmanagedType.Bool)]
    private static extern bool CloseHandle(IntPtr handle);
}

调用方是这样写的。只调用 Create 却忘了 Add,就会陷入“Job 有了但子进程没进去”这种最难察觉的状态。

// job handle 要放在字段之类的地方持有,在应用结束前都不要关闭。
// 因为带了 KILL_ON_JOB_CLOSE,一关闭 Job 里的子进程就全都结束。
// 这里绝对不能加 using(离开作用域的瞬间子进程就会死掉)
SafeJobHandle job = KillOnCloseJob.Create();

try
{
    // 要启动的文件用绝对路径传入。只传文件名的话,
    // CreateProcess 的查找范围里就会包含当前目录和 PATH
    string helperPath = Path.Combine(AppContext.BaseDirectory, "helper.exe");

    var startInfo = new ProcessStartInfo(helperPath, "--input data.bin")
    {
        UseShellExecute = false,
        CreateNoWindow = true,
    };

    using var child = Process.Start(startInfo)
        ?? throw new InvalidOperationException("无法启动 helper.exe。");

    try
    {
        KillOnCloseJob.Add(job, child);   // 忘了这一步,Job 就一直是空的
    }
    catch (Exception assignFailed)
    {
        // Add 失败的情形,例如父进程一侧已经被施加了互不相容的 Job 限制。
        // 此时 helper.exe 已经在运行了。`using` 的 Dispose 只会丢掉 Process
        // 的包装,操作系统层面的进程不会结束;而 Job 是空的,所以
        // job.Dispose() 也收拾不了。这里要自己终止它,并等到它结束
        try
        {
            if (!child.HasExited)
            {
                child.Kill(entireProcessTree: true);
            }

            // Kill 只是发出结束请求就立刻返回。不等待就 throw 的话,
            // 可能会和重新初始化后启动的第二个 helper 同时运行
            child.WaitForExit();
        }
        catch (Exception killFailed)
        {
            // 没能终止它,比 Add 失败更严重。如果吞掉这个异常,
            // 就会留下一个“既没进 Job、也没被终止的子进程”继续往下走
            throw new AggregateException(
                "分配到 Job 失败,并且也无法终止 helper.exe。",
                assignFailed, killFailed);
        }

        throw;
    }
}
catch
{
    // 如果启动和投入 Job 都失败了,这个 Job 就不会再用了。
    // 不关闭就离开的话,每重新初始化一次就会残留一个内核句柄。
    // 此时 Job 是空的(或者已经在上面的 catch 里终止了子进程),
    // 所以关闭它不会误伤任何东西
    job.Dispose();
    throw;
}

不过,这个 .NET 版本存在 从启动到放进 Job 之间的空隙。如果子进程在这期间又创建了孙进程,孙进程就会诞生在 Job 之外。C++ 版本之所以使用 PROC_THREAD_ATTRIBUTE_JOB_LIST,正是为了消除这个空隙。如果要应对会创建孙进程的 helper,那么即使在 .NET 里,也值得深入到使用 STARTUPINFOEX 的 P/Invoke。

从启动到 Assign 之间的空隙说明从启动到用 AssignProcessToJobObject 放进 Job 之间,如果子进程又创建了孙进程,孙进程会诞生在 Job 之外,因此面对会创建孙进程的 helper 时,值得用创建时指定 Job 的 PROC_THREAD_ATTRIBUTE_JOB_LIST 来消除这个空隙。要消除空隙就先启动,之后再放进 Job启动和 Assign 之间存在空隙这期间诞生的孙进程在 Job 之外在创建时指定 Job 并启动

图9:事后加入 Job 的做法,存在让孙进程溜走的空隙。

5. 用 protocol 和 timeout 设计退出传播

子进程的结束,不是靠一发 kill API 就能了事的。 最不容易出事的做法,是走这 3 个阶段。

  1. 请求协调结束
  2. 用较短的 timeout 等待
  3. 最后连同整个 Job 强制终止

按这个顺序来,既能保留正常的结束路径,也能在卡死时完成回收。

三段式的结束流程说明子进程的结束不是靠一发 kill API 就能了事,走请求协调结束、用较短的 timeout 等待、最后连同整个 Job 强制终止这三个阶段,既能保留正常的结束路径也能在卡死时完成回收。1. 请求协调结束2. 用较短的 timeout 等待3. 最后连同整个 Job 强制终止保住正常路径,卡死时也能回收

图10:结束要按请求、等待、强制这三段来设计。

5.1 GUI child

如果子进程带有 GUI,在 .NET 中 CloseMainWindow 会发送 close message。 但这只是 结束请求,并不是强制终止。因此流程写成

  • CloseMainWindow
  • 等待一段时间
  • 不行的话就连同 Job 一起 kill

会更自然。

5.2 Console child

对于 console child,无法使用 GUI 的 close message。 这时要用 process group 和 console signal。

流程是用 CREATE_NEW_PROCESS_GROUP 启动,再用 GenerateConsoleCtrlEvent 发送 CTRL_BREAK_EVENT。 这里要注意的是,

  • CTRL_C_EVENT 不适合限定发给特定的 group
  • 只有共享同一个 console 的进程才能接收到 signal
  • 使用 CREATE_NEW_PROCESS_GROUP 之后,CTRL+C 的含义也会改变

这几点。

5.3 Worker / headless child

worker 或 headless child 往往既不是 GUI 也不是 console。 这种情况下,拥有一套 专属于子进程的结束 protocol 会更安全。

  • 向 stdin 发送 quit
  • 通过 named pipe / socket / RPC 发送 shutdown command
  • 用 event object 传递停止请求

在 Windows 层面由 Job Object 负责 tree cleanup,在应用层面由 pipe 或 stdin 负责 graceful shutdown,这样分离不容易出事。

按子进程的类型区分协调结束说明协调结束的请求手段要按子进程的类型区分,GUI 子进程用 CloseMainWindow 之类的 close message,console 子进程用 CREATE_NEW_PROCESS_GROUP 和 CTRL_BREAK_EVENT,worker 用经由 stdin 或 pipe 的结束 protocol。请求协调结束GUI 子进程:close messageconsole 子进程:CTRL_BREAK_EVENTworker:stdin 或 pipe 的结束 protocoltree cleanup 交给 Job Object

图11:协调结束的手段按子进程类型来选,回收则交给 Job。

6. 不要让标准输入输出堵塞

6.1 stdout / stderr 要并行 drain

最基本的原则就是这一条。 stdout 和 stderr 要并行抽取。先读完一个再读另一个,很容易堵塞。

Windows 的 pipe 不是无限缓冲区。如果子进程往 stderr 大量输出,而父进程只读取 stdout,那么子进程卡在 write、父进程卡在等待结束,这种情形会很自然地发生。

画成图就是这个样子。

子进程stderr 的 pipestdout 的 pipe父进程子进程stderr 的 pipestdout 的 pipe父进程pipe 的缓冲区被写满write 不返回。子进程在这里停住子进程停住了,所以什么也读不到父进程等着读,子进程等着写。WaitForExit 也不返回只不断读取 stdout只写一点点读到了大量写入警告还想继续写想读取后续内容

图12:只读 stdout 的话,stderr 的 pipe 会被写满,父子进程互相等待。

停住的地方 既不是父进程也不是子进程,而是 pipe,所以看哪边的日志都照不出原因。“没有读取 stderr”这一行的遗漏,直接就变成了卡死。

只要用不同的处理器分别接收 stdout 和 stderr,各自独立地推进读取,这个循环就不成立。用 .NET 写是下面这样。

// .NET 8 / C# 12。并行 drain stdout 和 stderr,并等到输出全部读完
using System;
using System.ComponentModel;   // Win32Exception
using System.Diagnostics;
using System.IO;
using System.Text;

// 要启动的文件用绝对路径传入(理由见 Job Object 一节)
string helperPath = Path.Combine(AppContext.BaseDirectory, "helper.exe");

var startInfo = new ProcessStartInfo(helperPath, "--input data.bin")
{
    UseShellExecute = false,        // 使用重定向时必须设为 false
    RedirectStandardOutput = true,
    RedirectStandardError = true,
    CreateNoWindow = true,
};

using var process = new Process { StartInfo = startInfo };

var stdout = new StringBuilder();
var stderr = new StringBuilder();

// 不要先读完一个再读另一个。两个都用事件来接收
process.OutputDataReceived += (_, e) =>
{
    if (e.Data is not null)
    {
        stdout.AppendLine(e.Data);
    }
};

process.ErrorDataReceived += (_, e) =>
{
    if (e.Data is not null)
    {
        stderr.AppendLine(e.Data);
    }
};

process.Start();
process.BeginOutputReadLine();   // 只注册事件不会开始读取。两个都必须调用
process.BeginErrorReadLine();

if (!process.WaitForExit(30_000))
{
    // 这里做的是“不再等下去”的判断,并不能代替 cleanup
    try
    {
        process.Kill(entireProcessTree: true);
    }
    catch (Exception ex) when (ex is Win32Exception or InvalidOperationException)
    {
        // 存在这样一种竞争:30 秒的等待刚一超时,子进程就自己结束了。
        // 在 .NET 中,对正在结束的进程调用 Kill 会抛出 Win32Exception("The process is
        // terminating.");在 .NET Framework 中,对已结束的进程调用 Kill 会抛出
        // InvalidOperationException。
        // 如果它已经结束了,那这就不算失败,所以吞掉它并继续走到下面的
        // TimeoutException。如果它还活着,说明是真的没能终止,
        // 那就原样重新抛出
        if (!process.HasExited)
        {
            throw;
        }
    }
    // AggregateException(部分子孙进程没能终止)不要吞掉。
    // 那正是“整棵树没有清理干净”本身,必须让它抛到外面

    // Kill 只是发出结束请求就立刻返回。如果这里不等待就 throw,
    // 那么等到 using 的 Dispose 执行时子进程可能还活着,
    // “抛出了超时异常=整棵树已经清理干净”就不成立了
    process.WaitForExit();

    throw new TimeoutException("helper.exe 未能在 30 秒内结束。");
}

// 即使带 timeout 的 WaitForExit 返回了 true,异步的输出处理也可能还没结束。
// 再调用一次无参数的 WaitForExit,等到输出全部读完。
process.WaitForExit();

Console.WriteLine($"exit code : {process.ExitCode}");
Console.WriteLine($"stdout    : {stdout.Length} 个字符");
Console.WriteLine($"stderr    : {stderr.Length} 个字符");

超时的边界上一定存在竞争。从 WaitForExit(30_000) 返回 false 到调用 Kill 之间那一小段时间里,子进程可能会自己结束。这时 Kill 不会成功 —— 在 .NET 中会因为进程正在结束而抛出 Win32Exception(“The process is terminating.”),在 .NET Framework 中则会对已结束的进程抛出 InvalidOperationException。如果放任不管,本该抛出的 TimeoutException 就会被收尾阶段的失败取代。调用方收到的不是“超时了”,而是“出了个搞不清楚的错误”,而且最后那次用于读完输出的 WaitForExit() 也会被跳过。要像上面那样,先用 HasExited 确认“是不是真的已经结束”,再把异常吞掉。如果它还活着,就说明没能终止,那就原样重新抛出。另外,Kill(entireProcessTree: true) 抛出的 AggregateException(部分子孙进程没能终止)不要吞掉。那正是“整棵树没有清理干净”,也正是本节想要防止的状态。

超时边界上的竞争说明从 WaitForExit 返回 false 到调用 Kill 之间的一小段时间里子进程可能自己结束,若放任不管,收尾阶段的失败会掩盖本该抛出的 TimeoutException,因此要用 HasExited 确认是否真的已经结束再决定吞掉还是重新抛出。已经结束还活着等待刚超时,子进程就自己结束Kill 失败用 HasExited 确认不算失败,吞掉没能终止,重新抛出抛出本该抛出的 TimeoutException

图13:在边界的竞争中,用 HasExited 分辨 Kill 的失败是不是真的失败。

最后那个 WaitForExit() 不是忘了删,而是必需的。 WaitForExit(int) 的文档里写着,如果把标准输出重定向到了异步事件处理器,那么这个重载返回时输出处理有可能尚未完成,并建议在收到 true 之后再调用一次无参数的 WaitForExit()。省掉它,就会以 只丢失输出末尾 这种难以复现的形式出问题。

6.2 使用 stdin 就要设计到 EOF 为止

能往 stdin 写入,和子进程能够结束,并不是一回事。

  • 写完输入后没有 close
  • 父进程以为“已经交完了”
  • 子进程以为“还会有后续”而一直等待

就会出现这种状态。使用 stdin 时,必须把 写完后 close 并传递 EOF 也纳入设计。

stdin 要设计到 EOF 为止说明往 stdin 写完输入后如果不 close,父进程会以为已经交完,子进程会以为还有后续而一直等待,因此必须把写完后 close 并传递 EOF 也纳入设计。写完输入后不 close父进程以为“已经交完了”子进程以为“还会有后续”而等待写完后 close 并传递 EOF

图14:stdin 的设计不止于能写入,而要做到 EOF 能传达。

6.3 一定要关闭不需要的 pipe end

如果父侧、子侧不关闭未使用的 end,EOF 就无法传递,结束条件也会因此崩溃。 这一点很简单,但在实务中是相当多见的问题。

6.4 不要含糊处理 UseShellExecute=false 与 handle 继承

如果要使用标准输入输出重定向,在 .NET 中前提是 UseShellExecute=false。 在 Win32 里也一样,尽量收窄要继承的内容 会更安全。如果保持 bInheritHandles=TRUE 让所有 handle 都被继承,就容易成为意料之外的 handle leak 的原因。

7. 把 watchdog 放在“外面”

引入 watchdog 时最重要的一点是,不要把它放进和被监视对象相同的 Job。 因为如果 worker 挂掉后想重启,而负责重启的角色也一起死掉,那就没有意义了。

watchdog 要放在被监视对象之外说明把 watchdog 放进和被监视对象相同的 Job 时,清理挂掉的 worker 会连负责重启的角色一起弄死,因此 watchdog 要放在被监视对象所属 Job 的外面。所以把 watchdog 放进同一个 Job清理时负责重启的角色也一起死watchdog 放在 Job 之外worker 挂掉后还能重启

图15:不让负责重启的角色成为命运共同体,是 watchdog 布置的第一条件。

7.1 exit 监测要基于 wait handle

进程结束后会进入 signaled 状态。 所以 exit 监测本来就不需要用 polling loop 每 100ms 去看一次 HasExited。

在 Win32 中,

  • WaitForSingleObject
  • WaitForMultipleObjects
  • RegisterWaitForSingleObject
  • SetThreadpoolWait

才是正统做法。如果要处理多个子进程,基于 wait handle 会比 timer polling 更自然。

7.2 不要在 UI 线程上无限等待

WaitForSingleObject(INFINITE) 很方便,但如果在拥有 window 的线程上使用,很容易让 message pump 停下来。 在 UI 线程、COM apartment 线程、拥有 message pump 的线程上,最好先想清楚 等待放在哪里。

exit 监测要基于 wait handle说明进程结束后会进入 signaled 状态,所以 exit 监测不应该定期查看 HasExited 做 polling,而应基于 wait handle,并且要避免在 UI 线程上无限等待以免停住 message pump。定期 polling HasExited本来就没有必要等待结束时变为 signaled 的 handle基于 wait handle 的监测在 UI 线程上无限等待会让画面卡住

图16:结束的检测交给 wait handle,而不是轮询。

7.3 hang watchdog 需要 heartbeat

exit watchdog 用 process handle 就足够了。 但 hang watchdog 不一样。

  • CPU 占用 100% 卡死
  • 发生 deadlock
  • event loop 还活着,但没有进展
  • 卡在等待输入

这些状态,仅凭“进程是否还活着”是判断不出来的。所以如果还想看到 hang,就需要

  • heartbeat
  • progress sequence
  • last successful work timestamp
  • health probe

这类 应用层的存活确认。

检测 hang 需要 heartbeat说明 CPU 占用 100% 卡死、发生 deadlock、没有进展这些状态仅凭进程是否还活着判断不出来,所以要看到 hang 就需要 heartbeat 或进展信息这类应用层的存活确认。所以进程还活着但可能并没有在推进仅靠 exit 监测判断不出来用 heartbeat 或进展等应用层确认

图17:“是否活着”和“是否在推进”是两种不同的监测。

7.4 负责重启的角色要放在被监视对象之外

实务中常见的是这两种模式。

  • 父应用只是临时启动一个 helper
    • 父进程持有 Job,父进程结束时回收 helper tree
  • 长时间驻留 worker,挂掉后想重启
    • 由外部的 watchdog process / service 为每一代 worker 创建 Job

在后者中,把 worker tree 和 restart authority 分离开 的设计会更稳定。

7.5 restart policy 要用 budget 来持有

引入 watchdog 之后,接下来就会开始出现 crash loop。

  • 立刻重启
  • 又立刻挂掉
  • 只是刷出大量日志

要避免这种情况,最好拥有

  • backoff
  • 一定时间内的重启次数上限
  • 连续失败时停止并通知

这样的 restart budget。

用 restart budget 阻止 crash loop说明为了避免立刻重启又立刻挂掉的 crash loop,要持有 backoff、一定时间内的重启次数上限、连续失败时停止并通知这样的 restart budget。要防止就立刻重启→又立刻挂掉crash loop 和日志洪水加入 backoff一定时间内的次数上限连续失败就停止并通知

图18:重启要按预算管理,用完了就停下来通知人。

8. 典型模式下的推荐构成

场景 推荐构成
桌面应用启动单次 CLI helper 1 次启动 = 1 个 Job。加上 KILL_ON_JOB_CLOSE,并行 drain stdout / stderr。取消时走协调结束 → timeout → Job kill
helper 还会启动孙进程 以 Job Object 为前提,不允许 breakaway。如果想从启动时就固定下来,用 PROC_THREAD_ATTRIBUTE_JOB_LIST
service / watchdog 长时间监视 worker tree watchdog 放在外部 process / service。为每一代 worker 创建 Job,用 exit handle + heartbeat 监视
想妥善停止 console tool 用 CREATE_NEW_PROCESS_GROUP 启动,用 CTRL_BREAK_EVENT 协调结束。之后用 timeout 触发 Job kill
想关闭 GUI helper CloseMainWindow / 相当于 WM_CLOSE → timeout → Job kill
想监视大量子进程 与其增加 blocking thread,不如使用 RegisterWaitForSingleObject / SetThreadpoolWait

这里最重要的是,把 graceful shutdown 的机制 和 cleanup 的机制 分开。

请求的机制与清理的机制说明在任何一种典型模式下,最重要的都是把 close message 或结束 protocol 这样的 graceful shutdown 机制,与依靠 Job Object 的 cleanup 机制分开持有。graceful shutdown 的机制两者分开持有cleanup 的机制(Job)正常时靠前者,异常时靠后者

图19:在任何模式下,请求的通路和回收的通路都要分别准备。

9. 不应该做的事

把各章提到的注意事项,重新整理成可以直接用于评审的形式。 因为把“会发生什么”和“写在哪里”并排列出,所以从卡住的那一行就能回到正文。

不应该做的事 会发生什么 正文
以为仅靠 Kill(entireProcessTree: true) 就能解决 graceful shutdown 和父进程崩溃时的回收 它只在显式终止时才有效。父进程挂掉时的回收,以及让子进程做收尾的通路都缺失了 第 5 章
保持 bInheritHandles=TRUE 让所有 handle 都被继承 非预期的 handle 会传给子进程,成为 handle leak 和 EOF 传不到的原因 6.4
读完全部 stdout 之后才读 stderr 另一侧的 pipe 会被写满,父进程卡在等着读,子进程卡在等着写 6.1
不关闭 pipe 未使用的 end EOF 传不过去,读取一侧的结束条件不成立 6.3
在 UI 线程上执行 WaitForSingleObject(INFINITE) message pump 停下来,画面和 COM 都卡住 7.2
把 watchdog 放进和被监视对象相同的 Job 清理被监视对象时,负责重启的角色也一起消失 第 7 章
把 259 当作普通的 exit code 使用 GetExitCodeProcess 在进程运行中会返回 STILL_ACTIVE,也就是 259。如果子进程以 259 正常结束,就会被误判成明明已经结束却还在运行 7.1
把 Job completion port 的通知当作唯一的真相 通知面向的是监控和汇总,仅靠它构建 correctness 会有遗漏 4.2

10. 总结

在 Windows 应用中安全处理子进程时,最有效的整理方式是这样。

由谁拥有 process tree 如何传递结束请求 标准输入输出如何完整流转 watchdog 放在哪里

先把这 4 件事定下来。

在此基础上,粗略地说就是:

  • tree cleanup 的基准点是 Job Object
  • graceful shutdown 要按 GUI / console / worker 分别设计
  • stdio 要把并行 drain 和 EOF 都纳入设计
  • watchdog 放在被监视对象之外,用 wait handle 和 heartbeat 而不是 polling 来观察

CreateProcess 或 Process.Start 本身只是入口。 真正影响出事概率的,是 结束责任归属 和 I/O 是否流转干净。

要先定下来的 4 件事说明由谁拥有 process tree、如何传递结束请求、标准输入输出如何完整流转、watchdog 放在哪里这 4 件事先定下来,对降低子进程的出事概率最有效。树的所有者先定下来结束的传递方式stdio 的处理监视的放置位置启动 API 只是入口

图20:对出事概率最有效的,是在启动之前先定下这 4 件事。

11. 参考资料

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

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

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

常见问题

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

为什么父进程挂了,子进程却还残留着?
因为仅靠 process handle 或 process group,并没有在父进程崩溃时回收整棵进程树的机制。如果想把父进程的生死和子进程树的寿命绑定在一起,基准点就是 Job Object。加上 JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE 之后,当最后一个 job handle 被关闭时,属于该 Job 的所有进程都会终止,这样就能把清理工作(包括父进程异常退出的情况)都寄托在父进程的生命周期上。
为什么 WaitForExit 一直不返回?
很可能是标准输出或标准错误的 pipe 堵住了。Windows 的 pipe 不是无限缓冲区,如果子进程往 stderr 大量写入而父进程只读取 stdout,子进程就会卡在 write,父进程则卡在等待结束上。stdout 和 stderr 基本上应该并行抽取,先读完一个再读另一个的实现很容易堵塞。另外,如果不关闭 pipe 未使用的一端,EOF 就无法传递,结束条件也会因此崩溃。
.NET 的 Kill(entireProcessTree: true) 还不够吗?
并不足够。作为显式终止的 API 它很方便,但不能替代包含父进程崩溃时自动回收和 graceful shutdown 的整体设计。不容易出事的做法是三段式:先请求协调结束,再用较短的 timeout 等待,最后才连同整个 Job 强制终止。协调结束的手段要按子进程的类型区分:GUI 子进程用 CloseMainWindow,console 子进程用 CREATE_NEW_PROCESS_GROUP 和 CTRL_BREAK_EVENT,worker 则用 stdin 或 pipe 传递的结束 protocol。
watchdog 进程应该放在哪里?
最重要的是不要把它放进和被监视对象相同的 Job 里。因为如果 worker 挂掉后想要重启它,而负责重启的角色也一起死掉,那就没有意义了。如果要长时间驻留 worker,比较稳定的架构是让外部的 watchdog 进程或服务为每一代 worker 单独创建 Job。exit 监测应该基于 wait handle 而不是 polling,如果还需要检测 hang,就要结合 heartbeat 这类应用层的存活确认。

作者简介

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

Go Komura

小村软件有限公司 代表

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

返回博客列表