更新记录(仅首版,2026年08月29日 发布)
- 首次发布
在任务管理器里结束了 UI,摄像头却再也打不开。监控应用明明已经挂掉,SDK 的辅助进程却还攥着 COM 端口不放。重启父进程会变成双重启动,共享内存和命名管道随之出问题。一旦把设备 SDK 拆到独立进程里,就会碰上这类「父进程死后」的麻烦。
思考原因的出发点是:在 Windows 上,父进程结束时,子进程和孙进程不会自动结束。启动时的父子关系,和结束时的寿命管理,是两码事。填补这段空白的工具就是 Job Object。
本文先确认为什么只靠父进程的收尾处理不够,接着整理如何把进程放进 Job、终止方针和监控方法。最后再连接到设备联动中会发生的麻烦与排查步骤。
目标读者是把设备 SDK 拆到独立进程里的 WinForms / WPF / 服务开发者。前提环境是 Windows 10/11(用到嵌套作业和 PROC_THREAD_ATTRIBUTE_JOB_LIST 的部分),代码用 C++(Win32 API)和 C#(.NET 6 及以上)给出。难度为中级。
本文是「无响应」、关机、睡眠恢复、命名管道几篇文章的延伸,讨论的是进程外侧的寿命。
1. 先说结论
设计的出发点,不是先决定「怎么结束」,而是先决定「什么绝不能留下、什么必须留下」。
Job Object 是把进程树变成一个单位的机制。不过,在父进程消失的同时强制结束子孙,与保留这些子孙的转储和最终状态,这两件事直接摆在一起是无法兼得的。是要回收设备的占用,还是要先留下诊断素材,需要事先决定。
- 管理寿命的单位不是父子谱系,而是 Job。它给一组进程附加限制、通知和批量终止。一旦让进程加入,直到它结束都无法脱离;Windows 8 之后可以嵌套。12
- 在让子进程跑起来之前,先把 Job 归属定下来。启动之后再 Assign,会漏掉这期间诞生的孙进程。
CREATE_SUSPENDED和创建时的JOB_LIST,能关闭的竞争窗口也不一样(第 4 章)。 - 自动回收和诊断信息的保存,要作为终止方针来取舍。KillOnJobClose 的触发条件是「最后一个 Job 句柄关闭」。它对父进程崩溃很有效,但对被连坐强制结束的子孙的事后分析很不利,所以两者都需要时,要让监控方先采集再终止(第 5 章)。3
测量应用想要的并不是「杀掉」本身,而是不让设备被占着不放,以及能观测到异常终止。
| 想了解的内容 | 该读的章节 |
|---|---|
| 为什么只靠父进程的收尾处理不够 | 第 2~3 章:父子关系与 Job 的角色 |
| 如何不漏掉子孙地管理它们 | 第 4~6 章:创建、终止方针、监控 |
| SDK 和服务上会出什么问题 | 第 7~9 章:故障案例、资源限制、嵌套与 breakaway |
| 在现场查什么、怎么选 | 第 10~11 章:调查步骤与判断表 |
下面的知识图谱,是用来回顾各要素之间关系的。如果想从机制读起,请直接前往第 2 章。
图中实线表示始终成立的关系,虚线表示带条件的关系(成立条件写在详情页各关系的说明中)。关系的完整列表(共 21 条,附依据与确信度)以及主要概念的定义,汇总在知识地图详情页(日文)。数据:JSON-LD / Turtle
2. 为什么只有 WaitForExit 还不够
「等待结束」和「把寿命绑在一起」是两回事
Process.WaitForExit() 是「父进程等待子进程结束」的 API。本文要考虑的是反方向的问题:父进程先死时,子进程该怎么办。仅凭 Windows 的父子关系,父进程的结束不会传给子进程。
Process.Kill() 和 CloseMainWindow() 本身,也不会照顾到孙进程以及孙进程握着的设备句柄。被结束的那个进程自己的句柄会被释放,但问题在于活下来的子孙握着的句柄。
.NET 的 Kill(entireProcessTree: true) 会沿着子孙关系逐个结束,但存在枚举过程中新建进程和父进程先死时的遗漏,而且父进程自己崩溃之后它根本不会被调用。我们需要的是不依赖父进程收尾代码的机制。
下面按父进程死亡的主要原因,列出现场会留下什么。
| 父进程死亡的原因 | 子进程会发生什么 | 现场留下的东西 |
|---|---|---|
| 点 × 关闭 UI,收尾处理不完整 | 什么也不会发生(只是 Process 对象被释放) |
辅助进程、摄像头的锁定 |
| 在任务管理器里只 Kill 掉父进程 | 子进程继续存活 | COM 端口、USB、共享内存 |
| 因未处理异常而崩溃 | 无法保证父进程的 finally 会运行 |
临时文件、独占锁 |
| 服务停止超时 | SCM 只照顾父进程 | 会话 0 里残留子进程 |
flowchart TB
accTitle: 父进程的寿命与设备占用寿命之间的错位
accDescr: 父进程结束后,父进程的等待和 Process 对象会消失,但子进程和孙进程仍在运行,设备句柄、命名管道、锁文件的占用也会一直残留
parent["父进程结束"] --> gone["消失:父进程的等待、Process 对象"]
parent --> live["残留:子进程、孙进程"]
live --> dev["设备句柄的占用"]
live --> pipe["命名管道的服务端"]
live --> lock["锁文件、共享内存"]
图 1: 父进程的寿命和设备占用的寿命是错开的。父进程一侧的收尾代码,恰恰在父进程死得最反常的时候不会运行。
处理对象是「操作系统还在跑,只有父进程结束了」的情形
关机和睡眠让操作系统整体停下来的流程,属于关机文章和睡眠恢复文章的范围。本文处理的是操作系统依然健在,只有父进程结束的情形。在测量现场,这一类的发生频率更高,而且残留也更不容易被察觉。
3. Job Object 是什么
基本操作是创建、加入、设置、查询
Job Object 是把一组进程当作一个单位来管理的内核对象。按职责划分,基本操作有下面 4 个。14
| API | 职责 |
|---|---|
CreateJobObject |
创建一个还没有进程加入的 Job |
AssignProcessToJobObject |
让进程加入 Job |
SetInformationJobObject |
设置限制等信息 |
QueryInformationJobObject |
读取 CPU 时间、页面错误、进程数等会计信息 |
归属不可逆,进程结束之前都无法脱离。另外,会计信息里也包含已经结束的进程的那一份。
已加入的进程用 CreateProcess 创建的子进程,默认属于同一个 Job。也就是说,孙进程、曾孙进程都会自动进入,这正是 Job 价值的核心。1 不过,像 breakaway 和 WMI 代理启动这样脱离归属的路径,会在第 9 章单独确认。
flowchart TB
accTitle: Job Object 的基本结构
accDescr: 父进程创建的 Job Object 里加入了子进程和孙进程,Job 以进程树为单位强制施加限制、向完成端口发出通知并执行批量终止
parent["父进程"] --> job["Job Object"]
job --> child["子进程(设备 SDK 宿主)"]
child --> gc1["孙进程(厂商辅助进程)"]
job -.-> lim["限制(内存、CPU)"]
job -.-> note["通知(完成端口)"]
job -.-> kill["批量终止"]
图 2: Job 是以进程树为单位提供「限制」「通知」「批量终止」这三样东西的容器。
操作系统的世代差异,以及与沙箱的区别
Windows 7 及更早的版本是一进程一作业,从 Windows 8 开始可以嵌套(同时属于多个)。5 正文以 Windows 10/11 为前提,Windows 7 及更早版本上需要注意的地方放在第 9 章和 FAQ 里。
光把进程放进 Job,并不会变成容器或沙箱。网络访问无法限制,访问令牌(权限)是另一套机制。仅靠 UI 限制也做不出安全边界。它在本文中的角色,始终是把进程树的寿命和资源变成一个单位。
4. 正确的加入方式 —— 创建与归属之间的竞争
启动之后再 Assign,这期间会诞生孙进程
子进程可能在开始运行的最初几毫秒内就生出孙进程。SDK 的辅助进程启动就是典型例子。在 Process.Start() 之后取得 PID 再 Assign,比 Assign 更早诞生的孙进程就跑到 Job 外面去了。
flowchart TB
accTitle: 跑起来之后再加入就会漏掉孙进程
accDescr: Process.Start 之后子进程立刻开始运行,在调用 AssignProcessToJobObject 之前的竞争窗口里,子进程生出的孙进程会跑到 Job 之外
s["Process.Start 让子进程跑起来"] --> w["直到 Assign 为止的竞争窗口"]
w --> g["这期间诞生的孙进程"]
g --> out["在 Job 之外继续运行"]
s --> a2["AssignProcessToJobObject"]
a2 --> in2["只有之后诞生的孙进程会进来"]
图 3: 竞争窗口哪怕只有几毫秒,SDK 辅助进程的启动恰好就发生在那里。
步骤 A:以挂起状态创建,加入之后再运行
兼容性最广的经典做法,是使用 CREATE_SUSPENDED 的步骤。子进程抢先运行并生出孙进程的窗口,按下面的顺序关闭。67
- 用
CreateJobObject创建 Job - 用
SetInformationJobObject先设好限制 - 加上
CREATE_SUSPENDED调用CreateProcess(初始线程不会运行) - 用
AssignProcessToJobObject把它放进去 - 失败就不要 Resume,当场
TerminateProcess(不让它在 Job 之外执行哪怕一条指令) - 用
ResumeThread让它跑起来
步骤 A 关闭的是与孙进程创建之间的竞争窗口。针对父进程自身崩溃的窗口依然存在。如果在步骤 3 和 4 之间父进程崩溃,就会留下还没进入 Job 的挂起状态子进程。它不会运行,但也不会自动消失。
如果想连父进程崩溃的耐受性一起把这个窗口关上,就使用下面的步骤 B。
flowchart TB
accTitle: 以 SUSPENDED 启动后再放进 Job 的步骤
accDescr: 创建 Job 并设置限制,用 CREATE_SUSPENDED 启动子进程后用 AssignProcessToJobObject 让它加入,失败时不 Resume 而用 TerminateProcess 停掉,成功时用 ResumeThread 让它运行
a["用 CreateJobObject 创建 Job"] --> b["用 SetInformationJobObject 设限制"]
b --> c["用 CREATE_SUSPENDED 启动子进程"]
c --> d["AssignProcessToJobObject"]
d -->|"成功"| e["用 ResumeThread 让它运行"]
d -->|"失败"| f["不 Resume,立即 Terminate"]
图 4: 步骤 A 的骨架。省掉「失败就不让它跑」这个分支的实现,只会在出事的时候生出野生进程。
// C++: 步骤 A 的最小内核(错误处理只给骨架)
HANDLE job = CreateJobObjectW(nullptr, nullptr); // 无名即可。不要让它被继承
if (!job) return HRESULT_FROM_WIN32(GetLastError());
JOBOBJECT_EXTENDED_LIMIT_INFORMATION limits = {};
limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
if (!SetInformationJobObject(job, JobObjectExtendedLimitInformation,
&limits, sizeof(limits))) {
DWORD err = GetLastError(); // 在被 CloseHandle 覆盖之前先存下来
CloseHandle(job); // 不用没有限制的 Job 去跑子进程
return HRESULT_FROM_WIN32(err);
}
STARTUPINFOW si = { sizeof(si) };
PROCESS_INFORMATION pi = {};
if (!CreateProcessW(exePath, cmdline, nullptr, nullptr, FALSE,
CREATE_SUSPENDED, nullptr, nullptr, &si, &pi)) {
DWORD err = GetLastError();
CloseHandle(job); // 启动重试时不要泄漏 Job 句柄
return HRESULT_FROM_WIN32(err);
}
if (!AssignProcessToJobObject(job, pi.hProcess)) {
DWORD err = GetLastError(); // 在被 Terminate 覆盖之前先存下来
TerminateProcess(pi.hProcess, 1); // 不让它在 Job 之外运行
// 关闭句柄,并把 err 作为错误上报
} else if (ResumeThread(pi.hThread) == (DWORD)-1) {
DWORD err = GetLastError();
TerminateProcess(pi.hProcess, 1); // 不要让它保持挂起被丢在一边
// 关闭句柄,并把 err 作为错误上报
}
CloseHandle(pi.hThread);
// 成功后,job 和 pi.hProcess 的所有权交给调用方的寿命管理对象
//(相当于第 4 章的 C# 包装类)。关闭 job = 触发 KillOnJobClose,
// pi.hProcess 则在第 6 章用来确定「是谁死了」
步骤 B:Windows 10 及以上,在已进入 Job 的状态下创建
在 STARTUPINFOEX 的属性列表里,用 PROC_THREAD_ATTRIBUTE_JOB_LIST 放上 Job 句柄。然后带着 EXTENDED_STARTUPINFO_PRESENT 标志传给 CreateProcess。没有这个标志,它就不会被当作扩展结构体解释,属性会被忽略。89
用这种方法,进程在初始线程运行之前就已经属于 Job。「已创建但尚未归属」这个竞争窗口本身就不存在,因此不需要 SUSPENDED,也不需要创建之后 Assign 并处理失败的分支。
| 方法 | 确定归属的时机 | 仍需注意的地方 |
|---|---|---|
| 步骤 A: SUSPENDED → Assign → Resume | 子进程创建之后,初始线程开始运行之前 | 如果在 Assign 之前父进程崩溃,会留下停住的子进程 |
| 步骤 B: 用 JOB_LIST 创建 | 进程创建的那一刻 | 需要 Windows 10 及以上。要指定属性列表和扩展启动标志 |
flowchart TB
accTitle: 步骤 A 与步骤 B 的竞争窗口差异
accDescr: 步骤 A 先以挂起状态创建再执行 Assign 和 Resume,因此需要失败时终止的分支;步骤 B 把 Job 放进属性列表来创建,进程诞生时就已归属,既没有竞争窗口也没有失败分支
a1["步骤 A:以挂起状态创建"] --> a2["用 Assign 加入"]
a2 --> a3["用 Resume 启动"]
a2 -.-> a4["失败分支不可缺少"]
b1["步骤 B:用属性列表创建"] --> b2["诞生时就已归属"]
b2 -.-> b3["既无竞争窗口也无失败分支"]
图 5: 步骤 A 是「放进去再让它跑」,步骤 B 是「在已归属的状态下诞生」。如果能以 Windows 10 及以上为前提,选择的理由就是竞争窗口和失败分支的有无本身。
两种步骤都要避开的 3 种实现
- 在
Process.Start()之后取得 PID 再放进去(孙进程会先跑出去) - 让子进程继承 Job 句柄(父进程死后子进程仍握着句柄,KillOnJobClose 就不会触发 —— 第 5 章)
- 把 Assign 的失败吞掉继续运行(跑在 Job 之外的设备进程,会成为下一次故障的主角)
在 .NET 里,把 Job 句柄的所有者写进代码
System.Diagnostics.Process 里没有 Job 的概念,也没有官方包装。要用 P/Invoke 或 CsWin32 写一层薄包装。
关键在于把 Job 句柄包进 SafeHandle,并让它实现 IDisposable。用 Dispose() 关闭最后一个 Job 句柄时,KillOnJobClose 就会触发。「包装对象的寿命就是子树的寿命」这一设计意图,可以用所有权表达出来。
下面是表达句柄所有权的骨架。创建处理放在步骤 A/B 的 P/Invoke 一侧,实际项目中还需要 CsWin32 的配置和 unsafe 指定等。
// C#: 只负责持有 Job 句柄的薄包装(创建通过步骤 A/B 的 P/Invoke 完成)
sealed class ChildProcessJob : IDisposable
{
private readonly SafeFileHandle _job; // 必须作为字段一直持有
public ChildProcessJob()
{
_job = PInvoke.CreateJobObject(default, null);
if (_job.IsInvalid) throw new Win32Exception();
var limits = new JOBOBJECT_EXTENDED_LIMIT_INFORMATION();
limits.BasicLimitInformation.LimitFlags =
JOB_OBJECT_LIMIT.JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
if (!PInvoke.SetInformationJobObject(_job,
JOBOBJECTINFOCLASS.JobObjectExtendedLimitInformation,
&limits, (uint)sizeof(JOBOBJECT_EXTENDED_LIMIT_INFORMATION)))
{
int err = Marshal.GetLastWin32Error(); // 在 Dispose 之前先存下来
_job.Dispose(); // 不把没有 KillOnJobClose 的 Job 交出去
throw new Win32Exception(err);
}
}
public void Dispose() => _job.Dispose(); // 这里其下的整棵树都会结束
}
反过来,在没打算关闭的时候关掉了句柄,就会把子树整个结束掉。对包装对象的引用,请在父进程的整个存活期间一直保持。如果不保持,GC 回收 SafeHandle 的那一刻,子树就会毫无理由地全灭。
5. KillOnJobClose —— 「圈养」与「一起死」
触发条件不是「父进程之死」,而是「最后一个句柄的关闭」
JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE 是在最后一个 Job 句柄关闭时,结束其下全部进程的限制标志。3
父进程因异常而结束也好,被任务管理器杀掉也好,服务被强制停止也好,内核都会关闭该进程的所有句柄。如果那正是最后一个 Job 句柄,子进程和孙进程都会结束。不以父进程的收尾处理能够运行为前提,正是这个机制的强大之处。10
不过,如果让子进程继承 Job 句柄,父进程结束之后句柄仍然留着。这时就凑不成「最后一个句柄」,子树也就不会结束。
flowchart TB
accTitle: KillOnJobClose 的时间线
accDescr: 无论父进程是正常结束、崩溃还是被强制终止,内核都会关闭父进程的全部句柄;如果那是最后一个 Job 句柄,Job 就会关闭,其下的进程树被一次性终止
die["父进程消失(含崩溃)"] --> close["内核关闭全部句柄"]
close --> last{"是最后一个 Job 句柄吗?"}
last -->|"是"| killall["批量终止其下的整棵树"]
last -->|"否(存在继承)"| stay["子进程继续存活"]
图 6: 触发条件不是「父进程之死」,而是「最后一个句柄被关闭」。所以绝不能让子进程继承句柄。
在立刻回收的方针与保留诊断素材的方针之间做选择
强制结束失去的不只是设备的占用。还会失去为被连坐结束的子孙采集崩溃转储的机会、最后的有效帧,以及把写到一半的测量文件刷盘的机会。
父进程自己的崩溃转储是另一回事。WER 会在父进程还活着的时候处理未处理异常,所以父进程的转储能在句柄关闭之前写出来。这里成问题的是被强制结束的那一侧,也就是子孙的事后分析素材。
| 方针 | 适合的现场 | 会失去的东西 |
|---|---|---|
| 加上 KillOnJobClose | 设备被重复打开是最糟结果的现场 | 事后分析的素材、最后一个采样 |
| 不加(只做监控) | 转储和日志本身就是资产的现场 | 放着不管会有孤儿进程、端口占用 |
不加 + 由监控进程执行 TerminateJobObject |
另有一个控制用服务时 | 实现会变成两套 |
作为判断素材,请先把「绝不能留下的东西」和「希望留下的东西」列出来。
绝不能留下的东西:摄像头 / 数字化仪的打开状态、串口和 USB 的独占、命名管道的服务端、授权加密狗的会话、共享内存和锁文件。
希望留下的东西:崩溃转储、最后的有效帧 / 计数器、把设备退回安全侧的命令发送机会(可能的话在杀掉之前发出)。
flowchart TB
accTitle: 是杀掉,还是只做监控
accDescr: 如果设备被重复打开是最糟结果就加上 KillOnJobClose,如果崩溃转储和最终帧才是资产就不加而只做监控,如果另有控制服务就由那一侧调用 TerminateJobObject
q{"崩溃的那一刻要守住什么?"} -->|"优先释放设备"| k["加上 KillOnJobClose"]
q -->|"转储和最终状态是资产"| m["只做监控(不杀)"]
q -->|"另有控制服务"| t["由监控方执行 TerminateJobObject"]
图 7: 不是按「是否杀掉」,而是按「崩溃的那一刻要守住什么」来选。两样都想要时,就落在第三行那种由监控方先采转储再收摊的构成上。
让监控方在父进程还活着时拿到 Job 句柄
表格第 3 行,也就是由监控进程用 TerminateJobObject 结束的构成,是需要事先准备的。监控方必须在父进程死之前拿到 Job 句柄。
按本文步骤创建的 Job 是无名的,所以父进程消失之后没有办法从外面找到它。传递方式有下面两种。
| 方法 | 要在父进程还活着时完成的事 |
|---|---|
| 复制无名 Job 的句柄 | 用 DuplicateHandle 把句柄交给监控进程 |
| 使用有名 Job | 一开始就带名字创建,监控方用 OpenJobObject 打开并持有 |
名字在全局范围内可能冲突,因此要加入专属的 GUID 之类。忘了这项准备,监控方即使发现父进程异常,也没有手段结束整棵树。
转储采集和设备的安全化,要在强制结束之前完成
被 KillOnJobClose 结束的子进程,和 TerminateProcess 一样毫无预兆。因为不会发生未处理异常,所以即使给子进程配置了 WER(LocalDumps),这种强制结束时也不会留下转储。WER 能捕获的,是子进程因自身崩溃而结束的情形。
如果需求是「既要自动回收又要转储」,就把执行结束的主体挪到监控方。顺序是趁对象还活着时采集转储,必要时把设备退回安全侧,最后用 TerminateJobObject 结束。
flowchart TB
accTitle: 让自动回收与转储两全的收摊方式
accDescr: 监控进程检测到异常后,先采集转储,必要时发送把设备退回安全侧的命令,最后用 TerminateJobObject 收掉整棵树,从而兼顾自动回收与事后分析
det["监控方检测到异常"] --> dmp["先采集转储"]
dmp --> safe["把设备退回安全侧"]
safe --> term["用 TerminateJobObject 收摊"]
图 8: 「既要自动回收又要转储」的唯一解。顺序反了,要采集的对象就已经不存在了。
在用 KillOnJobClose 随父进程消失而强制结束子进程的构成里,子进程也没有机会发送「把设备退回安全侧的命令」。对于需要这一步的设备,请选择表格第 3 行的构成,由监控方先发出安全化命令,再调用 TerminateJobObject。11
6. 用完成端口等待「变空」
只等待 Job 句柄,无法确认整棵树已经结束
即使其下的进程全部结束,Job 句柄也不会进入已发信号的状态。会发信号的只有一种情况:作业时间超出上限而让全部进程被结束。12
为了做到「子树变空之后再往下走」,要把 I/O 完成端口(IOCP)关联到 Job 上。1314 关联要趁 Job 还空着、放入进程之前完成。中途关联,有可能漏掉在关联过程中状态发生变化的进程的通知。15
用 4 种消息观测创建、结束、异常与归零
| 消息 | 能知道什么 |
|---|---|
JOB_OBJECT_MSG_NEW_PROCESS |
有进程加入了 Job。孙进程的诞生也能检测到 |
JOB_OBJECT_MSG_EXIT_PROCESS |
有进程结束了 |
JOB_OBJECT_MSG_ABNORMAL_EXIT_PROCESS |
因访问违例等异常退出码而结束。对测量应用尤其重要13 |
JOB_OBJECT_MSG_ACTIVE_PROCESS_ZERO |
活动进程数变成 0 了 |
NEW_PROCESS 的数据包里只有新的 PID。是谁生的,也就是父子关系,是无从得知的。如果需要谱系,就要另外加上 ETW 之类的手段。
sequenceDiagram
accTitle: 完成端口通知的流程
accDescr: Job 把进程的创建、结束、异常结束和归零消息投递到完成端口,专用的监控线程用 GetQueuedCompletionStatus 取出,只把结果交给 UI 线程
participant J as Job Object
participant P as 完成端口
participant W as 监控线程
participant U as UI 线程
J->>P: NEW / EXIT_PROCESS
J->>P: ABNORMAL_EXIT / ZERO
W->>P: 等待完成数据包
P-->>W: 消息与 PID
W-->>U: 只把结果通知过去
图 9: GetQueuedCompletionStatus 要放在专用线程上转。在 UI 线程里等待,子进程每出一次异常,UI 就会「无响应」。
用专用线程和专用端口等待,超时时查询会计信息
这个监控循环,要在专为 Job 通知创建的完成端口上运转。如果和既有的 I/O 共用同一个端口,那么 GetQueuedCompletionStatus 返回的那一刻数据包就已经被取走了。因为键不同就用 continue 丢掉,那个 I/O 的持有者就会永远等下去。失败数据包的分支也是同理。
如果要共用,就需要另外做一套按键分发给持有者的机制。本文把端口分开,只把结果交给 UI 线程。
另外,前提是一个 Job 对应一个启动世代,每次启动都重新创建。如果重试时复用,TotalProcesses 里会包含上一个世代,用来区分启动前空 Job 的保护判断就失效了。
// C++: 监控线程的骨架(为通知丢失做准备,用超时 + 会计信息上保险)
DWORD msg; ULONG_PTR key; LPOVERLAPPED info;
bool treeEmpty = false;
while (!treeEmpty) {
if (!GetQueuedCompletionStatus(iocp, &msg, &key, &info, 5000)) {
if (info != nullptr) continue; // 失败 I/O 的完成数据包。监控继续
if (GetLastError() != WAIT_TIMEOUT) break; // 端口被销毁等情况则结束
JOBOBJECT_BASIC_ACCOUNTING_INFORMATION acct = {};
if (QueryInformationJobObject(job, JobObjectBasicAccountingInformation,
&acct, sizeof(acct), nullptr))
treeEmpty = (acct.TotalProcesses > 0 && // 不把启动前的空 Job 误判为已完成
acct.ActiveProcesses == 0); // ZERO 通知丢失时的保险
// 前提:Job 每次启动都重新创建(1 个 Job = 1 个启动世代)。重试时
// 复用 Job 的话,TotalProcesses 会一直算着上一个世代,
// 这个保护判断就区分不出世代了
continue; // 查询失败时不要断定它已经空了
}
if ((HANDLE)key != job) continue; // 与关联时的 CompletionKey 对照。
// 前提是这个端口专为 Job 监控创建
//(见下文正文。在共用端口上这样丢弃,
// 会让其他 I/O 的持有者永远等下去)
DWORD pid = (DWORD)(UINT_PTR)info; // 有些消息里会带 PID
switch (msg) {
case JOB_OBJECT_MSG_NEW_PROCESS: /* 记录孙进程的诞生 */ break;
case JOB_OBJECT_MSG_EXIT_PROCESS: /* 记录结束 */ break;
case JOB_OBJECT_MSG_ABNORMAL_EXIT_PROCESS:/* 异常结束: 转去确认转储 */ break;
case JOB_OBJECT_MSG_ACTIVE_PROCESS_ZERO: treeEmpty = true; break;
} // 只靠 switch 的 break 结束不了等待
}
通知、会计、句柄,各自能确定的信息不同
通知原则上不保证送达。能保证的只有用 JobObjectNotificationLimitInformation 设置的上限通知。不能判断「没收到通知 = 没有发生」。15
像「是否已经变空」这类汇总状态,要同时用会计信息的轮询来确认。不过会计是汇总计数器,所以丢掉的 EXIT / ABNORMAL_EXIT 的 PID、退出码、是否异常都还原不出来。那是保持进程句柄和 ETW 的领域。
ACTIVE_PROCESS_ZERO 也不是正常结束的证据。它也可能是因为强制结束才归零的,消息本身并不区分。结束的性质要用 EXIT / ABNORMAL_EXIT 和退出码来判定。
不要只凭 PID 就认定是同一个进程
完成数据包里的 PID 会被重复使用。只要没有保持进程句柄,就无法保证那个 PID 仍然指向同一个进程。13
第 4 章保持的 pi.hProcess 能固定的,只有直接启动的子进程的 PID。至于 SDK 生出的孙进程,要在收到 NEW_PROCESS 的时点 OpenProcess 取得句柄,之后以那个句柄为基准来核对。
即便如此,从通知到 Open 之间的短暂窗口依然存在。如果这期间 PID 被重复使用,抓到的就是另一个进程。如果严格到必须确定个体,就再用 ETW 的进程启动事件等带有创建时刻的遥测数据来交叉验证。
flowchart TB
accTitle: 不要只依赖通知的监控
accDescr: 完成端口的消息以通知为目的,并不保证送达,因此要同时轮询会计信息并保持进程句柄,以应对通知丢失和 PID 重复使用
n["完成端口的通知"] --> miss["不保证送达(以通知为目的)"]
miss --> poll["同时轮询会计信息"]
n --> pid["PID 会被重复使用"]
pid --> hold["保持进程句柄"]
图 10: 通知是主路径,会计和句柄是保险。两者齐备,才谈得上「观测到了」。
顺带一提,过去曾流传过「用 TerminateThread 杀掉等待 Job 变空的线程」这类实现,但只要有完成端口,就不需要强制结束等待线程。Raymond Chen 也发过把这种老模式改写成完成端口方式的文章。16
7. 测量与设备联动中真实发生的故障
把前面的机制,套到设备现场会发生的 6 个故障上。每个例子除了原因和处置,还要确认「留下了什么」。
故障 1: 只有父进程死了,摄像头还开着
在厂商 SDK 带有帧传输辅助进程的构成里,用任务管理器结束父 UI,就只剩下辅助进程。重启后的父进程会在 SDK 初始化时遇到 device busy。也有现场必须给设备重新上电才能恢复。
留下的东西:辅助进程和摄像头的独占打开。
flowchart TB
accTitle: 只有父进程死了摄像头还开着
accDescr: 强制结束 UI 后父进程消失,但 SDK 的辅助进程仍然残留并握着摄像头的句柄,因此重启后的父进程会因 device busy 而无法重新打开
kill9["在任务管理器里结束 UI"] --> dead["父进程消失"]
dead --> helper["SDK 辅助进程残留"]
helper --> busy["一直握着摄像头"]
busy --> fail["重启后 device busy"]
图 11: 「进程明明没了,设备却打不开」的真相。凶手往往不是任务管理器里显示着名字的那个进程。
故障 2: 孙进程跑到 Job 外面
路径有两条,处置也需要分开。
(a) SDK 在用自己的 Job 时。也就是对方已经进入了另一个 Job 的情形。Windows 7 是一进程一作业,所以这边的 Assign 会失败。Windows 8 及以上可以用嵌套接住,但如果这边的 Job 带有 UI 限制,嵌套本身就做不成(第 9 章)。
(b) SDK 用 CREATE_BREAKAWAY_FROM_JOB 创建孙进程时。只有在这边的 Job 允许 BREAKAWAY_OK 时才成立,孙进程一开始就诞生在树的外面。嵌套也接不住。如果不允许,SDK 那边的创建就会失败,所以以监控为优先时,基本做法是不允许,让它作为失败被检测出来。
留下的东西:在监控之外运行的孙进程,以及被吞掉失败的 Assign。
flowchart TB
accTitle: 孙进程跑到 Job 外面的两条路径
accDescr: SDK 使用自己的 Job 这条路径在 Windows 8 及以上还有用嵌套接住的余地,但这边设了 UI 限制就会失败;SDK 用 breakaway 创建孙进程这条路径只有在这边允许时才成立,孙进程一开始就在树的外面
g["孙进程跑到 Job 外面"] --> ja["(a)SDK 使用自己的 Job"]
g --> jb["(b)用 breakaway 创建"]
ja --> nest["Win8 及以上用嵌套接住"]
nest -.-> ui["有 UI 限制就会失败"]
jb --> allow["只有允许时才成立"]
allow -.-> out["孙进程一开始就在树外"]
图 12: 同样是「跑到外面」,(a) 还留着用嵌套接住的余地,(b) 则在允许的那一刻就注定接不住。处置要从确定路径开始。
故障 3: 从服务启动的设备进程
服务停止时 SCM 只等父进程,在会话 0 里生出的子进程和孙进程不在停止处理的管辖范围内。Job 加 KillOnJobClose 能照顾到这个管辖之外的部分。至于跨服务与交互会话这种构成本身的设计,交给用户边界的文章。
留下的东西:会话 0 的残留进程,以及下次启动时双重启动判定的误报。
故障 4: 一个月后才崩溃的子进程
如果没有定期记录 Job 的会计信息(PeakJobMemoryUsed、I/O 计数器、总进程数),事后就追不出「哪一代辅助进程从什么时候开始膨胀」。17 句柄泄漏导致一个月后崩溃的结构,已经在工业相机长期故障的文章里解剖过,Job 的会计信息正是那种调查的入口。
留下的东西:不足以确定原因的日志。
故障 5: 父进程的收尾处理在等子进程结束
在 UI 线程里做 WaitForExit 或等待整棵树结束,那么子进程卡住的那天,父进程就会「无响应」。结束等待交给 IOCP 线程,UI 上只放进度和中止按钮。机制如「无响应」文章所述。
留下的东西:被连累而挂起的父进程。
flowchart TB
accTitle: 不要在父进程的 UI 线程上等待结束
accDescr: 在 UI 线程等待子进程结束,子进程的挂起就会传导为父进程的无响应,所以结束等待要交给 IOCP 的监控线程,UI 线程上只放进度显示和中止按钮
w2["在 UI 线程等待子进程结束"] --> h2["子进程卡住的那一天"]
h2 --> f2["父进程也无响应(被连累)"]
ok2["在 IOCP 线程等待"] --> u2["UI 只放进度和中止"]
图 13: 观测子进程异常的那一方,不能被子进程的异常卡住。只要把等待的地方分开,连累就消失了。
故障 6: 只在调试器下失败
开发工具或启动器,可能已经把你的父进程放进了某个 Job。Windows 8 及以上大多能靠嵌套得救,但在 7 及更早的设备 PC 上,Assign 会返回 ERROR_ACCESS_DENIED,于是出现「只在开发机上跑不了」「只在生产环境跑不了」的差别。先用 IsProcessInJob 确认自己的归属,是惯用做法。18
留下的东西:查不出环境差异原因的验证时间。
8. 该加哪些限制
限制既有目的,也有副作用
只挑对测量应用有意义的限制,把使用理由和副作用列出来。319
| 限制 | 使用理由 | 用过头会怎样 |
|---|---|---|
| KILL_ON_JOB_CLOSE | 父进程消失时释放设备 | 事后分析的素材没了 |
| ACTIVE_PROCESS | 阻止 SDK 失控式地不断生子进程 | 连正常的辅助进程也被拒绝创建 |
| JOB_MEMORY / PROCESS_MEMORY | 给长期运行的泄漏设上限 | 巨大图像缓冲区的分配开始失败 |
| DIE_ON_UNHANDLED_EXCEPTION | 无人值守设备上不弹错误对话框 | 交互式调试变得难受 |
| CPU 速率控制 | 不让图像处理的子进程饿死 UI | 赶不上帧的截止时间 |
| BREAKAWAY_OK | 给需要另一个作业的 SDK 留逃生路 | 从监控对象里消失 |
| UI 限制 | 做出沙箱式的收紧 | 嵌套会坏掉(第 9 章) |
通知上限用于观测,强制上限用于拒绝与终止
要把「用于通知的宽松上限」和「超过就停下的上限」分开。JobObjectNotificationLimitInformation 只是通知超限,进程会继续运行。15
Extended Limit 的上限会被强制执行,但强制的形式因上限而异。3
| 上限 | 超限时会发生什么 |
|---|---|
| 内存上限 | 会超限的提交操作失败。进程本身还活着 |
| ACTIVE_PROCESS | 会超限的创建和加入失败。因加入而超限的那个进程会被结束 |
| 进程时间(PROCESS_TIME) | 只有超限的那个进程会被结束 |
| 作业时间(JOB_TIME) | 这是针对汇总值的上限,默认会结束 Job 下的全部进程 |
不了解这个差别就去设置,就会把「刚创建就消失的辅助进程」误诊成别的故障。在长期运行中,先用通知上限观测,摸清趋势之后再决定强制上限,这个顺序更安全。
flowchart TB
accTitle: 用于通知的上限与强制的上限
accDescr: JobObjectNotificationLimitInformation 的上限只在超限时发出通知,进程会继续运行;Extended Limit 的上限是强制的,内存上限表现为操作失败,ACTIVE_PROCESS 表现为创建和加入失败,时间上限表现为进程被结束
lim2{"上限的目的是什么?"} -->|"想观测"| ntf["通知上限:超了也继续跑"]
lim2 -->|"想停下"| enf["强制上限:拒绝或终止"]
ntf --> log2["用会计日志锁定世代"]
enf --> die2["提交失败、拒绝创建、终止"]
图 14: 同样叫「上限」,通知和强制是两回事,而且强制的生效方式也因上限而异。没有观测就直接张开强制上限,会在正常动作的高峰上误伤。
CPU 速率控制与周期处理的关系交给软实时的文章,这里只停留在「设备进程抢占 UI」的对策程度。
9. 嵌套、Breakaway,以及已经在 Job 里的对方
把嵌套理解为「进程集合的包含」
Windows 8 及以上的嵌套规则,可以整理成 4 条。5
- 父作业是较大的集合,子作业是它的子集(按不满足这种包含关系的顺序去 Assign 就会失败)
- 主要的资源限制,在链条上最严格的那个会实际生效
- 带有 UI 限制的作业无法嵌套
- 通知也会送到链条上所有父作业的完成端口(子作业一侧没有端口也可以)
flowchart TB
accTitle: 嵌套作业的层次与实际生效的限制
accDescr: 父作业是较大的集合,子作业是它的子集,主要的资源限制以链条上最严格的值实际生效。带有 UI 限制的作业无法嵌套
pj["父作业(较大的集合)"] --> cj["子作业(子集)"]
cj --> pr["所属进程"]
pj -.-> eff["实际生效的限制=最严格的值"]
cj -.-> eff
ui["带 UI 限制的作业"] -.-> no["无法嵌套"]
图 15: 嵌套要按「集合的包含」来理解。UI 限制会破坏嵌套,所以用于寿命管理的 Job 最好别加。
Breakaway 是一开始就在 Job 外创建的路径
breakaway 是用 CreateProcess 诞生的子孙脱离树的正规路径。3
| Job 一侧的设置 | 子进程在 Job 外诞生的条件 |
|---|---|
JOB_OBJECT_LIMIT_BREAKAWAY_OK |
指定 CREATE_BREAKAWAY_FROM_JOB 来创建 |
SILENT_BREAKAWAY_OK |
不需要指定标志。所有子进程都在外面诞生 |
有时 SDK 为了自己使用 Job 而确实需要它。不过,经由那条路径诞生的进程,会同时脱离批量终止和监控。如果要允许,就得连「脱出去之后由谁管理」一起定下来。
flowchart TB
accTitle: 用 Breakaway 脱离树的路径
accDescr: 当 Job 上带有 BREAKAWAY_OK 时,用 CREATE_BREAKAWAY_FROM_JOB 创建的孙进程会诞生在 Job 之外,从批量终止和监控的对象里消失
j2["Job(带 BREAKAWAY_OK)"] --> c2["子进程"]
c2 -->|"普通创建"| in3["孙进程也在 Job 里"]
c2 -->|"指定 BREAKAWAY 的创建"| out3["孙进程跑到 Job 外"]
out3 --> lost["不在监控和批量终止的对象内"]
图 16: breakaway 同时有「给必需 SDK 的逃生路」和「监控的窟窿」两副面孔。要加就得连「脱出去之后由谁送终」一起定下来。
WMI 的代理启动,禁止 breakaway 也防不住
像 WMI 的 Win32_Process.Create 这样,由第三方进程代为启动的路径是另一个问题。实际的父进程是 WMI 提供程序,所以诞生的进程一开始就在 Job 之外。禁止 breakaway 也堵不上这个窟窿。1
SDK 有没有用这条路径,不要看 NEW_PROCESS 的日志,而要用 Process Explorer 的父子关系来确认。
先确认对已有 Job 的归属,以及 Windows 7 及更早版本的约束
当对方已经在 Job 里(故障 6)时,步骤是:用 IsProcessInJob 确认 → 如果能组成嵌套就直接 Assign → 组不成(Windows 7,或者有 UI 限制)就改设计。18
在 Windows 7 及更早的版本里,无法对已经属于其他 Job 的对方做二次 Assign。BREAKAWAY_OK 不是把已归属的进程事后移出去的标志。只有当 SDK 一方在创建子进程时主动要求 breakaway,那条逃生路才成立。如果指望不上,就在启动之前按「一进程一作业」的前提改设计。12
是否把父进程自己放进 Job,留到最后再判断
最后也说说「把自己的进程放进自己的 Job」这种设计。如果把父进程也放进去,父进程崩溃时它自己也在 KillOnJobClose 的作用对象之内,整棵树的寿命就完全一致了。不过一旦搞错 Job 句柄的持有方式,就会变成意料之外的集体终止,是把双刃剑,先从「父进程在外,只有子树在内」开始更安全。
10. 调查方法
调查按归属 → 会计与通知日志 → 设备占用的顺序推进。这是为了不让人只靠肉眼看进程名就断定没有残留而设的确认步骤。
- Process Explorer: 进程属性里有 Job 标签页,可以看到所属 Job 和限制。它能最快确认「这个辅助进程在哪个 Job 里」
IsProcessInJob: 从代码里确认自己或对方归属的入口18QueryInformationJobObject: 定期记录 Basic Accounting(总进程数、CPU 时间)和 Extended Limit(PeakJobMemoryUsed等)417- 把完成端口的日志写到文件里: NEW_PROCESS / EXIT / ABNORMAL_EXIT 的时间序列,在一个月后的调查里会是唯一的证据
- 确认残留不要用 PID: 该看的是设备句柄、管道名和锁文件。「任务管理器里看不到进程」并不等于「设备已经释放」
flowchart TB
accTitle: 排查残留的步骤
accDescr: 先用 IsProcessInJob 和 Process Explorer 的 Job 标签页确认归属,再用 QueryInformationJobObject 读取会计信息,最后不看进程是否存在,而用设备句柄、管道名和锁文件来判定残留
s1["用 IsProcessInJob 确认归属"] --> s2["Process Explorer 的 Job 标签页"]
s2 --> s3["用 QueryInformationJobObject 读会计"]
s3 --> s4["用设备句柄、管道名判定残留"]
图 17: 调查按「归属 → 会计 → 占用」的顺序。不要只靠肉眼看进程名就断定「没有残留」。
11. 粗略的取舍(判断表)
把前面的选择按场景归纳一下。终止方针可回到第 5 章、监控的前提回到第 6 章、与已有 Job 的关系回到第 9 章确认。
| 情况 | 推荐做法 |
|---|---|
| UI 本体 + 设备 SDK 拆成了不同进程 | 放进 Job,用完成端口监控 |
| 父进程消失导致设备被握着不放是最糟结果 | 加上 KillOnJobClose |
| 崩溃那一刻的帧和转储是资产 | 不加 KillOnJobClose,由监控方安全化之后执行 TerminateJobObject |
| 厂商 SDK 会生出辅助进程 | 创建时用 SUSPENDED 或 JOB_LIST,并记录 NEW_PROCESS |
Assign 返回 ERROR_ACCESS_DENIED |
先看已有作业和能否嵌套。怀疑 UI 限制 |
| 从服务里启动交互会话的子进程 | 不要加 UI 限制。回到用户边界文章的设计 |
| 在 UI 线程里等待孙进程结束 | 别这么做。挪到 IOCP 线程上 |
12. 总结
子进程的寿命,不是父进程 Process 对象的寿命。父进程的结束,也不会自动传给子进程。Job Object 就是把这棵进程树变成一个单位,来处理限制、通知和批量终止的机制。
要连孙进程一起管住,就在让子进程跑起来之前把归属定下来。步骤 A 是 SUSPENDED 加 Assign,Windows 10 及以上的步骤 B 是 JOB_LIST。KillOnJobClose 不管父进程因何结束,只要最后一个 Job 句柄被关闭就会生效,但同时也会失去被连坐结束的子孙的事后分析素材。
正因如此,先把「崩溃之后绝不能留下的东西」和「希望留下的东西」列出来,再选择是立刻回收,还是由监控方采集、安全化之后再终止。这就是本文想传达的设计步骤。
下次要写的话,会是跨会话 0 与交互会话的进程启动细节,或者用 overlapped I/O 等待设备的话题。把进程的「外侧寿命」拿下之后,等着你的就是 I/O 的寿命。
相关文章
- Windows 应用安全处理子进程的检查清单 —— Job Object、结束传播、标准输入输出、watchdog 的最佳实践
- Windows 应用的「无响应」是怎么发生的 —— 消息循环与挂起的机制
- 命名管道实务 —— 从设计到安全,讲透 Windows 进程间通信的经典手段
- 句柄泄漏导致一个月后崩溃 —— 工业相机应用长期运行故障解剖(上)
- Windows 上的软实时能做到什么程度 —— 实务指南
- 「同一台 PC」并不是同一个执行环境 —— 隔开 AppData、HKCU、DPAPI 与凭据的用户边界
相关咨询领域
小村软件有限公司承接与摄像头、测量仪器、串口 / USB 设备联动的 Windows 应用的进程隔离设计,SDK 辅助进程残留、device busy 之类设备占用问题的调查,以及长期运行应用的监控与自动恢复机制的搭建。哪怕只是「重启父进程后设备打不开」这一件事,也欢迎来谈。
参考链接
-
Microsoft Learn, Job Objects. 关于 Job Object 是把一组进程当作一个单位来管理的内核对象、所属进程创建的子进程默认关联到同一个 Job(经由 Win32_Process.Create 的除外)、breakaway 用的两个限制标志、用 TerminateJobObject 批量终止,以及在无法使用嵌套的环境里管理进程树的方法。 ↩ ↩2 ↩3 ↩4 ↩5
-
Microsoft Learn, AssignProcessToJobObject function (jobapi2.h). 关于进程与 Job 的关联无法解除、Windows 7 及更早版本是一进程一作业而 Windows 8 开始可以同时属于多个(嵌套),以及嵌套时实际生效的限制与 breakaway 的传播。 ↩ ↩2
-
Microsoft Learn, JOBOBJECT_BASIC_LIMIT_INFORMATION structure (winnt.h). 关于 JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE(最后一个 Job 句柄关闭时结束全部进程)、ACTIVE_PROCESS(同时活动进程数的上限)、JOB_MEMORY(整个作业的提交上限)、DIE_ON_UNHANDLED_EXCEPTION、BREAKAWAY_OK / SILENT_BREAKAWAY_OK 等各个限制标志。 ↩ ↩2 ↩3 ↩4 ↩5
-
Microsoft Learn, JOBOBJECT_BASIC_ACCOUNTING_INFORMATION structure (winnt.h). 关于 Job 会连同已结束进程的那一份一起保持总进程数、CPU 时间、页面错误数等会计信息,并可用 QueryInformationJobObject 取得。 ↩ ↩2
-
Microsoft Learn, Nested Jobs. 关于嵌套作业形成父子层次(子作业是父作业进程的子集)、设置了 UI 限制的作业无法嵌套、实际生效的限制取链条上最严格的值、通知会送往父作业链条上的全部完成端口,以及层次的终止从最下层开始。 ↩ ↩2
-
Microsoft Learn, Process Creation Flags. 关于 CREATE_SUSPENDED(以挂起状态创建初始线程,在 ResumeThread 之前不执行)和 CREATE_BREAKAWAY_FROM_JOB(需要调用方所在的 Job 带有 JOB_OBJECT_LIMIT_BREAKAWAY_OK)。 ↩
-
Raymond Chen, Closing the race window between creating a suspended process and putting it in a job (The Old New Thing). 关于以 CREATE_SUSPENDED 创建之后再放进 Job 的经典步骤,以及如何关闭其中的竞争窗口。 ↩
-
Microsoft Learn, UpdateProcThreadAttribute function (processthreadsapi.h). 关于可以用 PROC_THREAD_ATTRIBUTE_JOB_LIST 按指定顺序把 Job 句柄分配给将要创建的子进程,以及它从 Windows 10 / Windows Server 2016 起受支持。 ↩
-
Raymond Chen, A more direct and mistake-free way of creating a process in a job object (The Old New Thing). 关于使用 PROC_THREAD_ATTRIBUTE_JOB_LIST,让进程从创建那一刻起就属于 Job 的方法。 ↩
-
Raymond Chen, Destroying all child processes (and grandchildren) when the parent exits (The Old New Thing). 关于用带 KILL_ON_JOB_CLOSE 的 Job 在父进程消失时一并结束子孙的构成,以及不让 Job 句柄被继承的重要性。 ↩
-
Microsoft Learn, TerminateJobObject function (jobapi2.h). 关于像逐个调用 TerminateProcess 那样,把关联到 Job 的全部进程强制结束。 ↩
-
Microsoft Learn, Job Objects - Managing Job Objects. 关于 Job 对象只有在作业时间上限超限导致全部进程被结束时才会进入已发信号状态、最后一个句柄关闭时 Job 被销毁,以及指定 KILL_ON_JOB_CLOSE 时该关闭会引发所有归属进程的结束。 ↩
-
Microsoft Learn, JOBOBJECT_ASSOCIATE_COMPLETION_PORT structure (winnt.h). 关于 JOB_OBJECT_MSG_NEW_PROCESS / EXIT_PROCESS / ABNORMAL_EXIT_PROCESS / ACTIVE_PROCESS_ZERO 等送往完成端口的消息一览、被判定为异常结束的退出码、在返回 PID 的消息里只要没有保持进程句柄就无法排除 PID 被重复使用,以及通知的投递不受保证。 ↩ ↩2 ↩3
-
Raymond Chen, How do I wait until all processes in a job have exited? (The Old New Thing). 关于等待 Job 句柄无法检测到「已经变空」,必须用完成端口的 JOB_OBJECT_MSG_ACTIVE_PROCESS_ZERO 来等待。 ↩
-
Microsoft Learn, Job Objects - Job Limits and Notifications. 关于完成端口的关联最好趁 Job 尚未活动时进行(以降低漏掉关联过程中状态发生变化的进程通知的可能)、除了用 JobObjectNotificationLimitInformation 设置的上限之外消息投递不受保证,以及通知上限在超限之后进程仍会继续运行。 ↩ ↩2 ↩3
-
Raymond Chen, Removing the TerminateThread from code that waits for a job object to empty (The Old New Thing). 关于把用 TerminateThread 杀掉等待线程的老模式,改写成基于完成端口的等待的方法。 ↩
-
Microsoft Learn, JOBOBJECT_EXTENDED_LIMIT_INFORMATION structure (winnt.h). 关于按进程和按作业设置内存上限,以及用 PeakProcessMemoryUsed / PeakJobMemoryUsed 取得峰值内存。 ↩ ↩2
-
Microsoft Learn, IsProcessInJob function (jobapi.h). 关于可以判定某个进程是否在指定的 Job(或任意一个 Job)中运行。 ↩ ↩2 ↩3
-
Microsoft Learn, JOBOBJECT_CPU_RATE_CONTROL_INFORMATION structure (winnt.h). 关于可以按 Job 为单位控制 CPU 速率(周期的占比或权重)。 ↩
相关文章
共享相同标签的最新文章。可以围绕相近的主题进一步加深理解。
参数为什么会坏掉 ── Windows 命令行参数的规则
在 Windows 上并不存在参数的数组,传给 CreateProcess 的是一条字符串,切分由接收方完成。本文讲解 CommandLineToArgvW・CRT・.NET 的切分规则,以及 .NET 的 ArgumentList 与 C++ 中正确的组装方法。
命名管道实务 ── 从设计到安全,吃透 Windows 进程间通信的标准方案
以实务视角讲解 Windows 进程间通信的标准方案——命名管道。依据一手资料梳理字节模式与消息模式的取舍、同时接纳多个客户端的服务器结构、ACL 与模拟的安全设计,以及 .NET 的命名管道流。
Win32 线程池 API ── 用 CreateThreadpoolWork 实现「不自建线程」的并发
原生代码里是不是到处都在 CreateThread?本文依据一手资料讲解 Vista 全面重新设计的 Win32 线程池 API:work、timer、wait、io 四种对象,清理组,以及回调中禁止做的事。
DllMain 与加载器锁 ── 「DLL 初始化里什么都别做」的真正原因
为何不得从 DllMain 调用 LoadLibrary 或与其他线程同步。本文根据一手资料说明加载器锁如何串行化每一条 DLL 通知、结构上必然死锁的经典场景、推迟初始化的正确设计,以及如何调查挂起。
虚假唤醒 ── 条件变量为何会「未被通知就醒来」,以及在 Windows 上正确等待的方法
条件变量的等待即使没有通知到达也可能返回(虚假唤醒)。本文从 Windows 实现说明规格为何允许这一点,并给出用 while 循环和谓词在 Win32、C++ 和 C# 中正确等待的写法。
相关主题
与本文相近的主题页面。以本文为起点,可以进一步了解相关服务和其他文章。
Windows 技术主题
汇整 KomuraSoft LLC 关于 Windows 开发、故障调查与既有资产活用文章的主题中心。
常见问题
汇总了咨询这一主题时常见的问题。
- Job Object 是容器或沙箱吗?
- 不是。Job Object 是给一组进程附加「限制」「通知」「批量终止」的内核对象。它可以对内存、CPU、进程数设置上限,但无法限制网络访问,访问令牌(权限)也不会改变。它确实带有 UI 限制,但仅凭这一点并不构成安全边界。如果目标是隔离或安全,就需要和 AppContainer、容器等其他机制组合使用。本文讨论的是「把进程树的寿命和资源当作一个单位来处理」这一用途。
- 用 Process.Kill() 不行吗?
- Process.Kill() 只结束它自己那一个进程,够不到孙进程。.NET Core 3.0 之后的 Kill(entireProcessTree: true) 会沿着子孙关系逐个结束,但它是按调用那一刻的父子关系枚举进程树的,所以枚举过程中新诞生的进程,以及因为父进程先死而断了谱系的进程,都有漏掉的余地。而且这两种做法在「父进程自己崩溃时」都不会被调用。如果希望不管父进程死活都能回收子孙,把寿命交给内核的 Job Object 加 KillOnJobClose 才可靠。
- 父进程在 Job 里,子进程也会自动进入 Job 吗?
- 默认会。属于某个 Job 的进程用 CreateProcess 创建的子进程,会自动属于同一个 Job。例外是 breakaway。当 Job 上设置了 JOB_OBJECT_LIMIT_BREAKAWAY_OK 且子进程带着 CREATE_BREAKAWAY_FROM_JOB 标志创建时,或者设置了 JOB_OBJECT_LIMIT_SILENT_BREAKAWAY_OK 时,子进程就会诞生在 Job 之外。另外,经由 WMI 的 Win32_Process.Create 创建的进程不会与 Job 关联。
- 已经放进去的进程还能从 Job 里移出来吗?
- 移不出来。AssignProcessToJobObject 建立的关联不可逆,归属会一直持续到进程结束。因此设计上的选择只有三个:不放进去、用 breakaway 一开始就在外面创建、或者嵌套另一个 Job,没有「事后移出」这一项。这种不可逆性,也正是应当在创建之前就把 Job 准备好的理由。
- 父进程崩溃时 KillOnJobClose 也有效吗?
- 有效。JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE 的机制是「最后一个 Job 句柄关闭时」结束其下的所有进程。无论父进程是正常退出、因未处理异常而崩溃,还是被任务管理器杀掉,内核都会作为进程收尾工作关闭句柄,所以它都会触发。不过,如果让子进程继承了 Job 句柄,父进程死后子进程手里的句柄仍然存在,就凑不成「最后一个句柄」,也就不会触发。请不要让 Job 句柄被继承。
- 完成端口的通知一定会送达吗?
- 有时送不到。官方文档明确写道:除了用 JobObjectNotificationLimitInformation 设置的上限通知之外,向完成端口投递消息并不保证送达。没有收到通知,并不意味着事件没有发生。在需要确定性的监控里,请同时用 QueryInformationJobObject 轮询会计信息,并自己持有进程句柄来确定生死。
- .NET 有官方的 Job Object API 吗?
- 没有。System.Diagnostics.Process 里没有 Job 的概念,BCL 中也没有包装类。实务上的做法是用 P/Invoke 调用 CreateJobObject / SetInformationJobObject / AssignProcessToJobObject,或者用微软的源生成器 CsWin32 生成签名后写一层薄包装。如果把 Job 句柄包进 SafeHandle,并在 IDisposable 的 Dispose 里关闭,那么 KillOnJobClose 所表达的「包装对象的寿命 = 子进程树的寿命」就会直接体现在代码里。
- Windows 7 的设备 PC 该怎么设计?
- Windows 7 及更早的版本里,一个进程只能进入一个 Job,也不能嵌套。如果对方的 SDK 自己在用 Job,你这边的 AssignProcessToJobObject 就会失败。JOB_OBJECT_LIMIT_BREAKAWAY_OK 是允许「已经在自己 Job 里的进程,用 CREATE_BREAKAWAY_FROM_JOB 把子进程创建到 Job 之外」的标志,并不是让进入之后还能二次 Assign 的魔法。也就是说,只有当 SDK 一方在创建时主动要求 breakaway,这条逃生路才成立。如果指望不上,就只能在启动之前按「自己的 Job 只有一个」这个前提去改设计。微软的文档也给出了在无法使用嵌套的环境里,用两个 breakaway 限制标志管理进程树的方法。不过这毕竟是已经停止支持的操作系统,可能的话还是先迁移为好。