引用本文(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
转换工具、更新程序、解析 worker、外部 CLI、PowerShell、ffmpeg、公司内部工具。 Windows 应用比想象中更容易依赖子进程。
不过,真正出事的地方不是“有没有启动成功”。
- 父进程挂了,子进程却还留着
- 只剩下孙进程还活着
stdout/stderr堵住,导致WaitForExit一直不返回- watchdog 和被监视对象一起死掉
- 以为
Kill(entireProcessTree: true)已经收尾,其实只是观测先结束了
在 Windows 上安全处理子进程的诀窍,不在于 选择哪个启动 API,而在于 决定进程树的所有者,并设计好结束流程和 I/O。
本文把 Job Object、退出传播、标准输入输出、watchdog 整理成一张统一的设计。
flowchart TB
accTitle: 出事的地方在启动之外
accDescr: 说明子进程出问题的地方不在于有没有启动成功,而在于父进程挂了却只剩子进程残留、stdout 堵塞这类情况;诀窍不是选择启动 API,而是决定进程树的所有者并设计好结束流程和 I/O。
a1["选择启动 API"] -.-> a2["这里不是问题的本体"]
a3["决定进程树的所有者"] --> a6["安全地处理子进程"]
a4["设计结束流程"] --> a6
a5["设计 I/O"] --> a6
图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 里积压的输出读完,让写入一侧不至于堵塞 |
整体图景
先把登场角色的关系画成一张图。
flowchart TB
W["watchdog<br/>放在 Job 之外"]
subgraph JOB["带 JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE 的 Job Object"]
P["父应用 / worker 本体<br/>job handle 的最终所有者"]
C["子进程 helper.exe"]
G1["孙进程 converter.exe"]
G2["孙进程 ffmpeg.exe"]
P --> C
C --> G1
C --> G2
end
W -.->|"用 exit handle 检测结束"| P
W -.->|"用 heartbeat 检测卡死"| P
W -.->|"在 restart budget 范围内重建"| JOB
图2:整体图景。Job 的边界就是进程树的边界,只有 watchdog 在外面。
要点有两个。
- Job 的边界就是进程树的边界。归拢的依据不是父子关系而是 Job 归属,所以孙进程再多也不会漏掉
- 只有 watchdog 在 Job 之外。放进去的话,会和被监视对象一起被清理掉
1. 先下结论
先把实务上最有效的部分列出来。
- 想把父进程的生死和子进程树的寿命绑定在一起,基准点是 Job Object
- 向 console 请求结束 和 回收进程树 是两件不同的事
- 前者用 process group 和
GenerateConsoleCtrlEvent - 后者用 Job Object
- 前者用 process group 和
- 想从启动那一刻起就加入 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 件事分开考虑,思路会更清晰。
- 由谁拥有进程树
- 如何请求协调结束
- 标准输入输出如何流转
- 如何监控异常终止和卡死
flowchart TB
accTitle: 需要分开考虑的 4 个问题
accDescr: 说明子进程管理不是单个 API 的话题,把由谁拥有进程树、如何请求协调结束、标准输入输出如何流转、如何监控异常终止和卡死这 4 件事分开考虑,思路会更清晰。
b1["1. 进程树的所有者"] --> b2["2. 请求协调结束的方式"]
b2 --> b3["3. 标准输入输出的流转方式"]
b3 --> b4["4. 异常终止和卡死的监控"]
b1 -.-> b5["不是单个 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 侧提供的能力,止步于单个进程的操作。
flowchart TB
accTitle: .NET 的覆盖范围与 Job 的边界
accDescr: 说明 .NET 标准库提供的只有启动、等待结束等单进程级别的操作,Job Object 相关的操作没有封装,需要用 P/Invoke 直接调用 Win32 API。
c1["单个进程级别的操作"] --> c2["用 .NET 标准的 Process 就够了"]
c3["Job Object 相关的操作"] --> c4["没有封装"]
c4 --> c5["用 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 关联的所有进程都会终止。
flowchart TB
accTitle: 用 Job 归拢就不会漏掉回收
accDescr: 说明 Job Object 按属于哪个 Job 而不是按是谁的子进程来归拢进程树,加入 Job 的进程创建的子进程默认也进入同一个 Job,加上 KILL_ON_JOB_CLOSE 后最后一个 job handle 被关闭时所有进程都会终止。
d1["按“是谁的子进程”追踪"] -.-> d2["孙进程一多就会漏"]
d3["按“属于哪个 Job”归拢"] --> d4["子进程创建的子进程也进同一个 Job"]
d4 --> d5["KILL_ON_JOB_CLOSE"]
d5 --> d6["最后一个 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 的最终所有者,应该提前决定清楚。
flowchart TB
accTitle: 不要让 job handle 的所有者变得模糊
accDescr: 说明 KILL_ON_JOB_CLOSE 在最后一个 handle 关闭时才生效,所以把 job handle 复制到其他进程或无意中让它被继承时,即使父进程死了也不会按预期清理,应该提前决定谁是最终所有者。
e1["把 job handle 复制或让它被继承"] --> e2["最后一个 handle 一直不关闭"]
e2 --> e3["父进程死了也不会清理"]
e3 -.->|"所以"| e4["提前决定最终所有者"]
图6:KILL_ON_JOB_CLOSE 要等“最后一个 handle”关闭才生效。
4.2 Job Object 也能用于 observability,但通知并非万能
Job Object 有一种机制,可以关联 I/O completion port 来接收通知。不过,最好不要把 completion port 的通知当作在所有情况下都完全保证送达的通知。
因此,completion port 用于:
- 监控
- 汇总统计
- 日志
- 指标
会很方便,但 最好不要仅靠它来构建 correctness。
flowchart TB
accTitle: completion port 通知的适用范围
accDescr: 说明 Job Object 可以关联 I/O completion port 接收通知,但不应把它当作在所有情况下都完全保证送达的通知,它适合用于监控、汇总、日志和指标,不要仅靠它来构建 correctness。
f1["Job 的 completion port 通知"] --> f2["用于监控、汇总、日志很方便"]
f1 -.-> f3["不要当作完全保证送达的通知"]
f3 -.-> f4["不要仅靠它来构建 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 会按下面的顺序查找。
- 加载应用程序的目录
- 父进程的当前目录
- 32 位的系统目录
- 16 位的系统目录
- Windows 目录
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 拼出的绝对路径。
即使在你觉得“不可能出现这种部署失误”的环境里,写上去的成本也几乎为零。在启动子进程的代码里,用相对名称写可执行文件基本没有任何理由。
flowchart TB
accTitle: 相对名称启动的查找会带来安全隐患
accDescr: 说明给 lpApplicationName 传 NULL 并只用文件名启动时,查找范围会包含父进程的当前目录和 PATH,当原本的 helper.exe 不存在时,放在可写位置的同名可执行文件会以父进程的权限运行,因此一定要用绝对路径指定。
g1["只用文件名启动"] --> g2["查找范围包含当前目录和 PATH"]
g2 --> g3["原本位置上没有 helper.exe 时"]
g3 --> g4["被放置的同名 EXE 以父进程权限运行"]
g4 -.->|"要防止就"| g5["用绝对路径指定,并用引号括起来"]
图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。
flowchart TB
accTitle: 从启动到 Assign 之间的空隙
accDescr: 说明从启动到用 AssignProcessToJobObject 放进 Job 之间,如果子进程又创建了孙进程,孙进程会诞生在 Job 之外,因此面对会创建孙进程的 helper 时,值得用创建时指定 Job 的 PROC_THREAD_ATTRIBUTE_JOB_LIST 来消除这个空隙。
h1["先启动,之后再放进 Job"] --> h2["启动和 Assign 之间存在空隙"]
h2 --> h3["这期间诞生的孙进程在 Job 之外"]
h3 -.->|"要消除空隙就"| h4["在创建时指定 Job 并启动"]
图9:事后加入 Job 的做法,存在让孙进程溜走的空隙。
5. 用 protocol 和 timeout 设计退出传播
子进程的结束,不是靠一发 kill API 就能了事的。 最不容易出事的做法,是走这 3 个阶段。
- 请求协调结束
- 用较短的 timeout 等待
- 最后连同整个 Job 强制终止
按这个顺序来,既能保留正常的结束路径,也能在卡死时完成回收。
flowchart TB
accTitle: 三段式的结束流程
accDescr: 说明子进程的结束不是靠一发 kill API 就能了事,走请求协调结束、用较短的 timeout 等待、最后连同整个 Job 强制终止这三个阶段,既能保留正常的结束路径也能在卡死时完成回收。
i1["1. 请求协调结束"] --> i2["2. 用较短的 timeout 等待"]
i2 --> i3["3. 最后连同整个 Job 强制终止"]
i2 -.-> i4["保住正常路径,卡死时也能回收"]
图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,这样分离不容易出事。
flowchart TB
accTitle: 按子进程的类型区分协调结束
accDescr: 说明协调结束的请求手段要按子进程的类型区分,GUI 子进程用 CloseMainWindow 之类的 close message,console 子进程用 CREATE_NEW_PROCESS_GROUP 和 CTRL_BREAK_EVENT,worker 用经由 stdin 或 pipe 的结束 protocol。
j0["请求协调结束"] --> j1["GUI 子进程:close message"]
j0 --> j2["console 子进程:CTRL_BREAK_EVENT"]
j0 --> j3["worker:stdin 或 pipe 的结束 protocol"]
j3 -.-> j4["tree cleanup 交给 Job Object"]
图11:协调结束的手段按子进程类型来选,回收则交给 Job。
6. 不要让标准输入输出堵塞
6.1 stdout / stderr 要并行 drain
最基本的原则就是这一条。
stdout 和 stderr 要并行抽取。先读完一个再读另一个,很容易堵塞。
Windows 的 pipe 不是无限缓冲区。如果子进程往 stderr 大量输出,而父进程只读取 stdout,那么子进程卡在 write、父进程卡在等待结束,这种情形会很自然地发生。
画成图就是这个样子。
sequenceDiagram
participant P as 父进程
participant SO as stdout 的 pipe
participant SE as stderr 的 pipe
participant C as 子进程
P->>SO: 只不断读取 stdout
C->>SO: 只写一点点
SO-->>P: 读到了
C->>SE: 大量写入警告
Note over SE: pipe 的缓冲区被写满
C->>SE: 还想继续写
Note over C: write 不返回。子进程在这里停住
P->>SO: 想读取后续内容
Note over P: 子进程停住了,所以什么也读不到
Note over P,C: 父进程等着读,子进程等着写。WaitForExit 也不返回
图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(部分子孙进程没能终止)不要吞掉。那正是“整棵树没有清理干净”,也正是本节想要防止的状态。
flowchart TB
accTitle: 超时边界上的竞争
accDescr: 说明从 WaitForExit 返回 false 到调用 Kill 之间的一小段时间里子进程可能自己结束,若放任不管,收尾阶段的失败会掩盖本该抛出的 TimeoutException,因此要用 HasExited 确认是否真的已经结束再决定吞掉还是重新抛出。
k1["等待刚超时,子进程就自己结束"] --> k2["Kill 失败"]
k2 --> k3{"用 HasExited 确认"}
k3 -->|"已经结束"| k4["不算失败,吞掉"]
k3 -->|"还活着"| k5["没能终止,重新抛出"]
k4 --> k6["抛出本该抛出的 TimeoutException"]
图13:在边界的竞争中,用 HasExited 分辨 Kill 的失败是不是真的失败。
最后那个 WaitForExit() 不是忘了删,而是必需的。
WaitForExit(int) 的文档里写着,如果把标准输出重定向到了异步事件处理器,那么这个重载返回时输出处理有可能尚未完成,并建议在收到 true 之后再调用一次无参数的 WaitForExit()。省掉它,就会以 只丢失输出末尾 这种难以复现的形式出问题。
6.2 使用 stdin 就要设计到 EOF 为止
能往 stdin 写入,和子进程能够结束,并不是一回事。
- 写完输入后没有 close
- 父进程以为“已经交完了”
- 子进程以为“还会有后续”而一直等待
就会出现这种状态。使用 stdin 时,必须把 写完后 close 并传递 EOF 也纳入设计。
flowchart TB
accTitle: stdin 要设计到 EOF 为止
accDescr: 说明往 stdin 写完输入后如果不 close,父进程会以为已经交完,子进程会以为还有后续而一直等待,因此必须把写完后 close 并传递 EOF 也纳入设计。
m1["写完输入后不 close"] --> m2["父进程以为“已经交完了”"]
m1 --> m3["子进程以为“还会有后续”而等待"]
m2 --> m4["写完后 close 并传递 EOF"]
m3 --> m4
图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 挂掉后想重启,而负责重启的角色也一起死掉,那就没有意义了。
flowchart TB
accTitle: watchdog 要放在被监视对象之外
accDescr: 说明把 watchdog 放进和被监视对象相同的 Job 时,清理挂掉的 worker 会连负责重启的角色一起弄死,因此 watchdog 要放在被监视对象所属 Job 的外面。
n1["把 watchdog 放进同一个 Job"] --> n2["清理时负责重启的角色也一起死"]
n2 -.->|"所以"| n3["watchdog 放在 Job 之外"]
n3 --> n4["worker 挂掉后还能重启"]
图15:不让负责重启的角色成为命运共同体,是 watchdog 布置的第一条件。
7.1 exit 监测要基于 wait handle
进程结束后会进入 signaled 状态。
所以 exit 监测本来就不需要用 polling loop 每 100ms 去看一次 HasExited。
在 Win32 中,
WaitForSingleObjectWaitForMultipleObjectsRegisterWaitForSingleObjectSetThreadpoolWait
才是正统做法。如果要处理多个子进程,基于 wait handle 会比 timer polling 更自然。
7.2 不要在 UI 线程上无限等待
WaitForSingleObject(INFINITE) 很方便,但如果在拥有 window 的线程上使用,很容易让 message pump 停下来。
在 UI 线程、COM apartment 线程、拥有 message pump 的线程上,最好先想清楚 等待放在哪里。
flowchart TB
accTitle: exit 监测要基于 wait handle
accDescr: 说明进程结束后会进入 signaled 状态,所以 exit 监测不应该定期查看 HasExited 做 polling,而应基于 wait handle,并且要避免在 UI 线程上无限等待以免停住 message pump。
p1["定期 polling HasExited"] -.-> p2["本来就没有必要"]
p3["等待结束时变为 signaled 的 handle"] --> p4["基于 wait handle 的监测"]
p4 -.-> p5["在 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
这类 应用层的存活确认。
flowchart TB
accTitle: 检测 hang 需要 heartbeat
accDescr: 说明 CPU 占用 100% 卡死、发生 deadlock、没有进展这些状态仅凭进程是否还活着判断不出来,所以要看到 hang 就需要 heartbeat 或进展信息这类应用层的存活确认。
q1["进程还活着"] --> q2["但可能并没有在推进"]
q2 --> q3["仅靠 exit 监测判断不出来"]
q3 -.->|"所以"| q4["用 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。
flowchart TB
accTitle: 用 restart budget 阻止 crash loop
accDescr: 说明为了避免立刻重启又立刻挂掉的 crash loop,要持有 backoff、一定时间内的重启次数上限、连续失败时停止并通知这样的 restart budget。
r1["立刻重启→又立刻挂掉"] --> r2["crash loop 和日志洪水"]
r2 -.->|"要防止就"| r3["加入 backoff"]
r3 --> r4["一定时间内的次数上限"]
r4 --> r5["连续失败就停止并通知"]
图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 的机制 分开。
flowchart TB
accTitle: 请求的机制与清理的机制
accDescr: 说明在任何一种典型模式下,最重要的都是把 close message 或结束 protocol 这样的 graceful shutdown 机制,与依靠 Job Object 的 cleanup 机制分开持有。
s1["graceful shutdown 的机制"] --> s3["两者分开持有"]
s2["cleanup 的机制(Job)"] --> s3
s3 -.-> s4["正常时靠前者,异常时靠后者"]
图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 是否流转干净。
flowchart TB
accTitle: 要先定下来的 4 件事
accDescr: 说明由谁拥有 process tree、如何传递结束请求、标准输入输出如何完整流转、watchdog 放在哪里这 4 件事先定下来,对降低子进程的出事概率最有效。
t1["树的所有者"] --> t5["先定下来"]
t2["结束的传递方式"] --> t5
t3["stdio 的处理"] --> t5
t4["监视的放置位置"] --> t5
t5 --> t6["启动 API 只是入口"]
图20:对出事概率最有效的,是在启动之前先定下这 4 件事。
11. 参考资料
- Microsoft Learn, Job Objects
- Microsoft Learn, JOBOBJECT_BASIC_LIMIT_INFORMATION
- Microsoft Learn, UpdateProcThreadAttribute
- Microsoft Learn, InitializeProcThreadAttributeList
- Microsoft Learn, Inheritance (Processes and Threads)
- Microsoft Learn, CreateProcessW
- Microsoft Learn, Creating a Child Process with Redirected Input and Output
- Microsoft Learn, Pipe Handle Inheritance
- Microsoft Learn, Process.Kill
- Microsoft Learn, Process.CloseMainWindow
- Microsoft Learn, GenerateConsoleCtrlEvent
- Microsoft Learn, WaitForSingleObject
- Microsoft Learn, RegisterWaitForSingleObject
- Microsoft Learn, GetExitCodeProcess
- Microsoft Learn, JOBOBJECT_ASSOCIATE_COMPLETION_PORT
相关文章
共享相同标签的最新文章。可以围绕相近的主题进一步加深理解。
参数为什么会坏掉 ── Windows 命令行参数的规则
在 Windows 上并不存在参数的数组,传给 CreateProcess 的是一条字符串,切分由接收方完成。本文讲解 CommandLineToArgvW・CRT・.NET 的切分规则,以及 .NET 的 ArgumentList 与 C++ 中正确的组装方法。
Time Travel Debugging ── 把长期运行中不复现的缺陷“录下来”再倒回去
一个月才出一次的缺陷,崩溃转储只拍得到结果。本文讲解如何用 WinDbg 的 Time Travel Debugging(TTD) 录制执行并倒回,涵盖 TTD.exe 的录制设计、环形缓冲区、TTD.Calls 查询,以及与转储的分工。
父进程消失之后还剩下什么 —— 用 Job Object 圈养子进程
为什么强制结束 UI 之后,SDK 的辅助进程仍然残留,一直占着摄像头或 COM 端口?本文从测量应用的视角,讲解如何用 Job Object 把进程树变成一个单位,并借助 KillOnJobClose 与完成端口来设计子进程的寿命。
命名管道实务 ── 从设计到安全,吃透 Windows 进程间通信的标准方案
以实务视角讲解 Windows 进程间通信的标准方案——命名管道。依据一手资料梳理字节模式与消息模式的取舍、同时接纳多个客户端的服务器结构、ACL 与模拟的安全设计,以及 .NET 的命名管道流。
虚假唤醒——条件变量为什么会“没有通知也醒来”,以及 Windows 上正确的等待写法
条件变量的 wait 即使没有收到通知也可能返回(虚假唤醒)。本文从 Windows 的实现出发说明规范为何允许这一点,并用 Win32、C++ 和 C# 的代码给出以 while 循环和谓词编写的正确等待写法。
相关主题
与本文相近的主题页面。以本文为起点,可以进一步了解相关服务和其他文章。
Windows 技术主题
汇整 KomuraSoft LLC 关于 Windows 开发、故障调查与既有资产活用文章的主题中心。
与本主题相关的服务
本文与以下服务页面相关联,欢迎从最接近的入口查看。
Windows 应用程序开发
在需要调用外部 CLI、转换工具、worker、updater 的 Windows 应用中,决定稳定性的不是启动方式,而是进程树的管理和结束设计。
故障调查 & 根本原因分析
父进程崩溃后只剩子进程残留、stdout 堵塞、watchdog 跟着一起挂掉,这类难以复现的运行故障,通过重新审视进程管理设计往往比较容易改善。
常见问题
汇总了咨询这一主题时常见的问题。
- 为什么父进程挂了,子进程却还残留着?
- 因为仅靠 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 这类应用层的存活确认。