父處理程序消失之後還剩下什麼 —— 用 Job Object 圈養子處理程序

· 更新日期: · · Windows, Windows 開發, C#, C++, Win32 API, 量測控制

更新紀錄(僅初版,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() 本身,也不會照顧到孫處理程序以及孫處理程序抓著的裝置控制代碼。被結束的那個處理程序自己的控制代碼會被釋放,但問題在於活下來的子孫抓著的控制代碼

.NETKill(entireProcessTree: true) 會沿著子孫關係逐一結束,但存在列舉過程中新建處理程序和父處理程序先死時的遺漏,而且父處理程序自己當機之後它根本不會被呼叫。我們需要的是不依賴父處理程序收尾程式碼的機制。

下面依父處理程序死亡的主要原因,列出現場會留下什麼。

父處理程序死亡的原因 子處理程序會發生什麼 現場留下的東西
按 × 關閉 UI,收尾處理不完整 什麼也不會發生(只是 Process 物件被釋放) 輔助處理程序、攝影機的鎖定
在工作管理員裡只 Kill 掉父處理程序 子處理程序繼續存活 COM 連接埠、USB、共用記憶體
因未處理例外而當機 無法保證父處理程序的 finally 會執行 暫存檔、獨佔鎖定
服務停止逾時 SCM 只照顧父處理程序 工作階段 0 裡殘留子處理程序
父處理程序的壽命與裝置佔用壽命之間的錯位父處理程序結束後,父處理程序的等待和 Process 物件會消失,但子處理程序和孫處理程序仍在執行,裝置控制代碼、具名管道、鎖定檔案的佔用也會一直殘留父處理程序結束消失:父處理程序的等待、Process 物件殘留:子處理程序、孫處理程序裝置控制代碼的佔用具名管道的伺服器端鎖定檔案、共用記憶體

圖 1: 父處理程序的壽命和裝置佔用的壽命是錯開的。父處理程序那一側的收尾程式碼,偏偏在父處理程序死得最反常的時候不會執行。

處理的對象是「作業系統還在跑,只有父處理程序結束了」的情況

關機和睡眠讓作業系統整體停下來的流程,屬於關機文章睡眠喚醒文章的範圍。本文處理的是作業系統依然健在,只有父處理程序結束的情況。在量測現場,這一類的發生頻率更高,而且殘留也更不容易被察覺。

3. Job Object 是什麼

基本操作是建立、加入、設定、查詢

Job Object 是把一組處理程序當成一個單位來管理的核心物件。依職責區分,基本操作有下面 4 個。14

API 職責
CreateJobObject 建立一個還沒有處理程序加入的 Job
AssignProcessToJobObject 讓處理程序加入 Job
SetInformationJobObject 設定限制等項目
QueryInformationJobObject 讀取 CPU 時間、分頁錯誤、處理程序數等會計資訊

歸屬不可逆,處理程序結束之前都無法脫離。另外,會計資訊裡也包含已經結束的處理程序那一份

已加入的處理程序用 CreateProcess 建立的子處理程序,預設屬於同一個 Job。也就是說,孫處理程序、曾孫處理程序都會自動進入,這正是 Job 價值的核心。1 不過,像 breakaway 和 WMI 代理啟動這類脫離歸屬的路徑,會在第 9 章單獨確認。

Job Object 的基本結構父處理程序建立的 Job Object 裡加入了子處理程序和孫處理程序,Job 以處理程序樹為單位強制施加限制、往完成埠發出通知並執行批次終止父處理程序Job Object子處理程序(裝置 SDK 主機)孫處理程序(廠商輔助程式)限制(記憶體、CPU)通知(完成埠)批次終止

圖 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 外面去了

跑起來之後才加入就會漏掉孫處理程序Process.Start 之後子處理程序立刻開始執行,在呼叫 AssignProcessToJobObject 之前的競爭視窗裡,子處理程序生出的孫處理程序會跑到 Job 之外Process.Start 讓子處理程序跑起來直到 Assign 為止的競爭視窗這段期間誕生的孫處理程序在 Job 之外繼續執行AssignProcessToJobObject只有之後誕生的孫處理程序會進來

圖 3: 競爭視窗就算只有幾毫秒,SDK 輔助程式的啟動剛好就發生在那裡。

步驟 A:以暫停狀態建立,加入之後再執行

相容性最廣的經典做法,是使用 CREATE_SUSPENDED 的步驟。子處理程序搶先執行並生出孫處理程序的視窗,依下面的順序關閉。67

  1. CreateJobObject 建立 Job
  2. SetInformationJobObject 設好限制
  3. 加上 CREATE_SUSPENDED 呼叫 CreateProcess(初始執行緒不會執行)
  4. AssignProcessToJobObject 把它放進去
  5. 失敗就不要 Resume,當場 TerminateProcess(不讓它在 Job 之外執行哪怕一道指令)
  6. ResumeThread 讓它跑起來

步驟 A 關閉的是與孫處理程序建立之間的競爭視窗。針對父處理程序自身當機的視窗依然存在。如果在步驟 3 和 4 之間父處理程序當機,就會留下還沒進入 Job 的暫停狀態子處理程序。它不會執行,但也不會自動消失。

如果想連父處理程序當機的耐受度一起把這個視窗關上,就使用下面的步驟 B。

以 SUSPENDED 啟動後再放進 Job 的步驟建立 Job 並設定限制,用 CREATE_SUSPENDED 啟動子處理程序後用 AssignProcessToJobObject 讓它加入,失敗時不 Resume 而用 TerminateProcess 停掉,成功時用 ResumeThread 讓它執行成功失敗用 CreateJobObject 建立 Job用 SetInformationJobObject 設限制用 CREATE_SUSPENDED 啟動子處理程序AssignProcessToJobObject用 ResumeThread 讓它執行不 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 以上。要指定屬性清單和擴充啟動旗標
步驟 A 與步驟 B 的競爭視窗差異步驟 A 先以暫停狀態建立再執行 Assign 和 Resume,因此需要失敗時終止的分支;步驟 B 把 Job 放進屬性清單來建立,處理程序誕生時就已歸屬,既沒有競爭視窗也沒有失敗分支步驟 A:以暫停狀態建立用 Assign 加入用 Resume 啟動失敗分支不可或缺步驟 B:用屬性清單建立誕生時就已歸屬既無競爭視窗也無失敗分支

圖 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 控制代碼,父處理程序結束之後控制代碼仍然留著。這時就湊不成「最後一個控制代碼」,子樹也就不會結束。

KillOnJobClose 的時間軸無論父處理程序是正常結束、當機還是被強制終止,核心都會關閉父處理程序的全部控制代碼;如果那是最後一個 Job 控制代碼,Job 就會關閉,其下的處理程序樹被一次終止否(存在繼承)父處理程序消失(含當機)核心關閉全部控制代碼是最後一個 Job 控制代碼嗎?批次終止其下的整棵樹子處理程序繼續存活

圖 6: 觸發條件不是「父處理程序之死」,而是「最後一個控制代碼被關閉」。所以絕對不能讓子處理程序繼承控制代碼。

在立刻回收的方針與保留診斷素材的方針之間做選擇

強制結束失去的不只是裝置的佔用。還會失去為被牽連結束的子孫採集當機傾印的機會、最後的有效影格,以及把寫到一半的量測檔案排清的機會

父處理程序自己的當機傾印是另一回事。WER 會在父處理程序還活著的時候處理未處理例外,所以父處理程序的傾印能在控制代碼關閉之前寫出來。這裡的問題在於被強制結束的那一側,也就是子孫的事後分析素材

方針 適合的現場 會失去的東西
加上 KillOnJobClose 裝置被重複開啟是最糟結果的現場 事後分析的素材、最後一個取樣
不加(只做監控) 傾印和記錄檔本身就是資產的現場 放著不管會有孤兒處理程序、連接埠佔用
不加 + 由監控處理程序執行 TerminateJobObject 另有一個控制用服務時 實作會變成兩套

作為判斷素材,請先把「絕對不能留下的東西」和「希望留下的東西」列出來。

絕對不能留下的東西:攝影機 / 數位化儀的開啟狀態、序列埠和 USB 的獨佔、具名管道的伺服器端、授權硬體鎖的工作階段、共用記憶體和鎖定檔案。

希望留下的東西:當機傾印、最後的有效影格 / 計數器、把裝置退回安全側的命令傳送機會(可以的話在殺掉之前送出)。

是殺掉,還是只做監控如果裝置被重複開啟是最糟結果就加上 KillOnJobClose,如果當機傾印和最終影格才是資產就不加而只做監控,如果另有控制服務就由那一側呼叫 TerminateJobObject優先釋放裝置傾印和最終狀態是資產另有控制服務當掉的那一刻要守住什麼?加上 KillOnJobClose只做監控(不殺)由監控方執行 TerminateJobObject

圖 7: 不是依「是否殺掉」,而是依「當掉的那一刻要守住什麼」來選。兩樣都想要時,就落在第三列那種由監控方先採傾印再收攤的組合上。

讓監控方在父處理程序還活著時拿到 Job 控制代碼

表格第 3 列,也就是由監控處理程序用 TerminateJobObject 結束的組合,是需要事先準備的。監控方必須在父處理程序死之前拿到 Job 控制代碼

依本文步驟建立的 Job 是無名的,所以父處理程序消失之後沒有辦法從外面找到它。傳遞方式有下面兩種。

方法 要在父處理程序還活著時完成的事
複製無名 Job 的控制代碼 DuplicateHandle 把控制代碼交給監控處理程序
使用具名 Job 一開始就帶名稱建立,監控方用 OpenJobObject 開啟並持有

名稱在全域範圍內可能衝突,因此要加入專屬的 GUID 之類。忘了這項準備,監控方即使發現父處理程序異常,也沒有手段結束整棵樹。

傾印採集和裝置的安全化,要在強制結束之前完成

被 KillOnJobClose 結束的子處理程序,和 TerminateProcess 一樣毫無預兆。因為不會發生未處理例外,所以即使為子處理程序設定了 WER(LocalDumps),這種強制結束時也不會留下傾印。WER 能撿到的,是子處理程序因自身當機而結束的情況。

如果需求是「既要自動回收又要傾印」,就把執行結束的主體挪到監控方。順序是趁對象還活著時採集傾印,必要時把裝置退回安全側,最後用 TerminateJobObject 結束

讓自動回收與傾印兩全的收攤方式監控處理程序偵測到異常後,先採集傾印,必要時傳送把裝置退回安全側的命令,最後用 TerminateJobObject 收掉整棵樹,藉此兼顧自動回收與事後分析監控方偵測到異常先採集傾印把裝置退回安全側用 TerminateJobObject 收攤

圖 8: 「既要自動回收又要傾印」的唯一解。順序反了,要採集的對象就已經不存在了。

在用 KillOnJobClose 隨父處理程序消失而強制結束子處理程序的組合裡,子處理程序也沒有機會傳送「把裝置退回安全側的命令」。對於需要這一步的裝置,請選擇表格第 3 列的組合,由監控方先送出安全化命令,再呼叫 TerminateJobObject11

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 之類的手段。

完成埠通知的流程Job 把處理程序的產生、結束、異常結束和歸零訊息投遞到完成埠,專用的監控執行緒用 GetQueuedCompletionStatus 取出,只把結果交給 UI 執行緒UI 執行緒監控執行緒完成埠Job ObjectUI 執行緒監控執行緒完成埠Job ObjectNEW / EXIT_PROCESSABNORMAL_EXIT / ZERO等待完成封包訊息與 PID只把結果通知過去

圖 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 的處理程序啟動事件等帶有建立時刻的遙測資料來交叉驗證

不要只依賴通知的監控完成埠的訊息以通知為目的,並不保證送達,因此要同時輪詢會計資訊並保持處理程序控制代碼,以因應通知漏失和 PID 重複使用完成埠的通知不保證送達(以通知為目的)同時輪詢會計資訊PID 會被重複使用保持處理程序控制代碼

圖 10: 通知是主路徑,會計和控制代碼是保險。兩者齊備,才談得上「觀測到了」。

順帶一提,過去曾流傳過「用 TerminateThread 殺掉等待 Job 變空的執行緒」這類實作,但只要有完成埠,就不需要強制結束等待執行緒。Raymond Chen 也發過把這種舊模式改寫成完成埠方式的文章。16

7. 量測與裝置整合中真實發生的故障

把前面的機制,套到裝置現場會發生的 6 個故障上。每個例子除了原因和處理方式,還要確認「留下了什麼」。

故障 1: 只有父處理程序死了,攝影機還開著

在廠商 SDK 帶有影格傳輸輔助程式的組合裡,用工作管理員結束父 UI,就只剩下輔助處理程序。重新啟動後的父處理程序會在 SDK 初始化時遇到 device busy。也有現場必須為裝置重新供電才能復原。

留下的東西:輔助處理程序和攝影機的獨佔開啟

只有父處理程序死了攝影機還開著強制結束 UI 後父處理程序消失,但 SDK 的輔助處理程序仍然殘留並抓著攝影機的控制代碼,因此重新啟動後的父處理程序會因 device busy 而無法重新開啟在工作管理員裡結束 UI父處理程序消失SDK 輔助程式殘留一直抓著攝影機重新啟動後 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

孫處理程序跑到 Job 外面的兩條路徑SDK 使用自己的 Job 這條路徑在 Windows 8 以上還有用巢狀接住的餘地,但這邊設了 UI 限制就會失敗;SDK 用 breakaway 建立孫處理程序這條路徑只有在這邊允許時才成立,孫處理程序一開始就在樹的外面孫處理程序跑到 Job 外面(a)SDK 使用自己的 Job(b)用 breakaway 建立Win8 以上用巢狀接住有 UI 限制就會失敗只有允許時才成立孫處理程序一開始就在樹外

圖 12: 同樣是「跑到外面」,(a) 還留著用巢狀接住的餘地,(b) 則在允許的那一刻就注定接不住。處理方式要從確定路徑開始。

故障 3: 從服務啟動的裝置處理程序

服務停止時 SCM 只等父處理程序,在工作階段 0 裡生出的子處理程序和孫處理程序不在停止處理的管轄範圍內。Job 加上 KillOnJobClose 能照顧到這個管轄之外的部分。至於跨服務與互動工作階段這種組合本身的設計,交給使用者邊界的文章

留下的東西:工作階段 0 的殘留處理程序,以及下次啟動時重複啟動判定的誤判

故障 4: 一個月後才當掉的子處理程序

如果沒有定期記錄 Job 的會計資訊(PeakJobMemoryUsed、I/O 計數器、總處理程序數),事後就追不出「哪一代輔助程式從什麼時候開始膨脹」。17 控制代碼洩漏導致一個月後當掉的結構,已經在工業攝影機長期故障的文章裡解剖過,Job 的會計資訊正是那種調查的入口。

留下的東西:不足以確定原因的記錄檔

故障 5: 父處理程序的收尾處理在等子處理程序結束

在 UI 執行緒裡做 WaitForExit 或等待整棵樹結束,那麼子處理程序卡住的那天,父處理程序就會「沒有回應」。結束等待交給 IOCP 執行緒,UI 上只放進度和中止按鈕。機制如「沒有回應」文章所述。

留下的東西:被牽連而卡住的父處理程序

不要在父處理程序的 UI 執行緒上等待結束在 UI 執行緒等待子處理程序結束,子處理程序的卡住就會傳導成父處理程序的沒有回應,所以結束等待要交給 IOCP 的監控執行緒,UI 執行緒上只放進度顯示和中止按鈕在 UI 執行緒等待子處理程序結束子處理程序卡住的那一天父處理程序也沒有回應(被牽連)在 IOCP 執行緒等待UI 只放進度和中止

圖 13: 觀測子處理程序異常的那一方,不能被子處理程序的異常卡住。只要把等待的地方分開,牽連就消失了。

故障 6: 只在偵錯工具底下失敗

開發工具或啟動器,可能已經把你的父處理程序放進了某個 Job。Windows 8 以上大多能靠巢狀得救,但在 7 以前的裝置電腦上,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 底下的全部處理程序

不了解這個差別就去設定,就會把「剛建立就消失的輔助程式」誤判成別的故障。在長期運轉中,先用通知上限觀測,摸清趨勢之後再決定強制上限,這個順序更安全。

用於通知的上限與強制的上限JobObjectNotificationLimitInformation 的上限只在超限時發出通知,處理程序會繼續執行;Extended Limit 的上限是強制的,記憶體上限表現為操作失敗,ACTIVE_PROCESS 表現為建立和加入失敗,時間上限表現為處理程序被結束想觀測想停下上限的目的是什麼?通知上限:超了也繼續跑強制上限:拒絕或終止用會計記錄鎖定世代認可失敗、拒絕建立、終止

圖 14: 同樣叫「上限」,通知和強制是兩回事,而且強制的生效方式也因上限而異。沒有觀測就直接張開強制上限,會在正常動作的高峰上誤傷。

CPU 速率控制與週期處理的關係交給軟即時的文章,這裡只停留在「裝置處理程序搶走 UI」的對策程度。

9. 巢狀、Breakaway,以及已經在 Job 裡的對方

把巢狀理解成「處理程序集合的包含」

Windows 8 以上的巢狀規則,可以整理成 4 條。5

  • 父作業是較大的集合,子作業是它的子集(依不滿足這種包含關係的順序去 Assign 就會失敗)
  • 主要的資源限制,在鏈上最嚴格的那個會實際生效
  • 帶有 UI 限制的作業無法巢狀
  • 通知也會送到鏈上所有父作業的完成埠(子作業那一側沒有埠也可以)
巢狀作業的階層與實際生效的限制父作業是較大的集合,子作業是它的子集,主要的資源限制以鏈上最嚴格的值實際生效。帶有 UI 限制的作業無法巢狀父作業(較大的集合)子作業(子集)所屬處理程序實際生效的限制=最嚴格的值帶 UI 限制的作業無法巢狀

圖 15: 巢狀要依「集合的包含」來理解。UI 限制會破壞巢狀,所以用於壽命管理的 Job 最好別加。

Breakaway 是一開始就在 Job 外建立的路徑

breakaway 是用 CreateProcess 誕生的子孫脫離樹的正規路徑。3

Job 那一側的設定 子處理程序在 Job 外誕生的條件
JOB_OBJECT_LIMIT_BREAKAWAY_OK 指定 CREATE_BREAKAWAY_FROM_JOB 來建立
SILENT_BREAKAWAY_OK 不需要指定旗標。所有子處理程序都在外面誕生

有時 SDK 為了自己使用 Job 而確實需要它。不過,經由那條路徑誕生的處理程序,會同時脫離批次終止和監控。如果要允許,就得連「脫出去之後由誰管理」一起定下來。

用 Breakaway 脫離樹的路徑當 Job 上帶有 BREAKAWAY_OK 時,用 CREATE_BREAKAWAY_FROM_JOB 建立的孫處理程序會誕生在 Job 之外,從批次終止和監控的對象裡消失一般建立指定 BREAKAWAY 的建立Job(帶 BREAKAWAY_OK)子處理程序孫處理程序也在 Job 裡孫處理程序跑到 Job 外不在監控和批次終止的對象內

圖 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: 從程式碼裡確認自己或對方歸屬的入口18
  • QueryInformationJobObject: 定期記錄 Basic Accounting(總處理程序數、CPU 時間)和 Extended Limit(PeakJobMemoryUsed 等)417
  • 把完成埠的記錄寫到檔案裡: NEW_PROCESS / EXIT / ABNORMAL_EXIT 的時間序列,在一個月後的調查裡會是唯一的證據
  • 確認殘留不要用 PID: 該看的是裝置控制代碼、管道名稱和鎖定檔案。「工作管理員裡看不到處理程序」並不等於「裝置已經釋放」
排查殘留的步驟先用 IsProcessInJob 和 Process Explorer 的 Job 索引標籤確認歸屬,再用 QueryInformationJobObject 讀取會計資訊,最後不看處理程序是否存在,而用裝置控制代碼、管道名稱和鎖定檔案來判定殘留用 IsProcessInJob 確認歸屬Process Explorer 的 Job 索引標籤用 QueryInformationJobObject 讀會計用裝置控制代碼、管道名稱判定殘留

圖 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 的壽命。

相關文章

相關諮詢領域

小村軟體有限公司承接與攝影機、量測儀器、序列埠 / USB 裝置整合的 Windows 應用的處理程序隔離設計,SDK 輔助程式殘留、device busy 之類裝置佔用問題的調查,以及長期運轉應用的監控與自動復原機制的建置。哪怕只是「重新啟動父處理程序後裝置打不開」這一件事,也歡迎來談。

參考連結

  1. Microsoft Learn, Job Objects. 關於 Job Object 是把一組處理程序當成一個單位來管理的核心物件、所屬處理程序建立的子處理程序預設關聯到同一個 Job(透過 Win32_Process.Create 的除外)、breakaway 用的兩個限制旗標、用 TerminateJobObject 批次終止,以及在無法使用巢狀的環境裡管理處理程序樹的方法。  2 3 4 5

  2. Microsoft Learn, AssignProcessToJobObject function (jobapi2.h). 關於處理程序與 Job 的關聯無法解除、Windows 7 以前是一個處理程序一個作業而 Windows 8 開始可以同時屬於多個(巢狀),以及巢狀時實際生效的限制與 breakaway 的傳播。  2

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

  4. Microsoft Learn, JOBOBJECT_BASIC_ACCOUNTING_INFORMATION structure (winnt.h). 關於 Job 會連同已結束處理程序那一份一起保持總處理程序數、CPU 時間、分頁錯誤數等會計資訊,並可用 QueryInformationJobObject 取得。  2

  5. Microsoft Learn, Nested Jobs. 關於巢狀作業形成父子階層(子作業是父作業處理程序的子集)、設定了 UI 限制的作業無法巢狀、實際生效的限制取鏈上最嚴格的值、通知會送往父作業鏈上的全部完成埠,以及階層的終止從最底層開始。  2

  6. Microsoft Learn, Process Creation Flags. 關於 CREATE_SUSPENDED(以暫停狀態建立初始執行緒,在 ResumeThread 之前不執行)和 CREATE_BREAKAWAY_FROM_JOB(需要呼叫端所在的 Job 帶有 JOB_OBJECT_LIMIT_BREAKAWAY_OK)。 

  7. Raymond Chen, Closing the race window between creating a suspended process and putting it in a job (The Old New Thing). 關於以 CREATE_SUSPENDED 建立之後再放進 Job 的經典步驟,以及如何關閉其中的競爭視窗。 

  8. Microsoft Learn, UpdateProcThreadAttribute function (processthreadsapi.h). 關於可以用 PROC_THREAD_ATTRIBUTE_JOB_LIST 依指定順序把 Job 控制代碼配置給即將建立的子處理程序,以及它從 Windows 10 / Windows Server 2016 起受支援。 

  9. 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 的方法。 

  10. Raymond Chen, Destroying all child processes (and grandchildren) when the parent exits (The Old New Thing). 關於用帶 KILL_ON_JOB_CLOSE 的 Job 在父處理程序消失時一併結束子孫的組合,以及不讓 Job 控制代碼被繼承的重要性。 

  11. Microsoft Learn, TerminateJobObject function (jobapi2.h). 關於像逐一呼叫 TerminateProcess 那樣,把關聯到 Job 的全部處理程序強制結束。 

  12. Microsoft Learn, Job Objects - Managing Job Objects. 關於 Job 物件只有在作業時間上限超限導致全部處理程序被結束時才會進入已發出信號的狀態、最後一個控制代碼關閉時 Job 被銷毀,以及指定 KILL_ON_JOB_CLOSE 時該關閉會引發所有歸屬處理程序的結束。 

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

  14. 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 來等待。 

  15. Microsoft Learn, Job Objects - Job Limits and Notifications. 關於完成埠的關聯最好趁 Job 尚未作用時進行(以降低漏掉關聯過程中狀態發生變化的處理程序通知的可能)、除了用 JobObjectNotificationLimitInformation 設定的上限之外訊息投遞不受保證,以及通知上限在超限之後處理程序仍會繼續執行。  2 3

  16. Raymond Chen, Removing the TerminateThread from code that waits for a job object to empty (The Old New Thing). 關於把用 TerminateThread 殺掉等待執行緒的舊模式,改寫成以完成埠為基礎的等待的方法。 

  17. Microsoft Learn, JOBOBJECT_EXTENDED_LIMIT_INFORMATION structure (winnt.h). 關於依處理程序和依作業設定記憶體上限,以及用 PeakProcessMemoryUsed / PeakJobMemoryUsed 取得尖峰記憶體。  2

  18. Microsoft Learn, IsProcessInJob function (jobapi.h). 關於可以判定某個處理程序是否在指定的 Job(或任何一個 Job)中執行。  2 3

  19. Microsoft Learn, JOBOBJECT_CPU_RATE_CONTROL_INFORMATION structure (winnt.h). 關於可以依 Job 為單位控制 CPU 速率(週期的佔比或權重)。 

共用相同標籤的最新文章。能以相近的主題延伸理解。

與本文相近的主題頁面。以本文為起點,可進一步連到相關服務與其他文章。

本文連結到以下服務頁面,歡迎從最接近的入口查看。

常見問題

整理諮詢這個主題時常見的問題。

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 的裝置電腦該怎麼設計?
Windows 7 以前的版本裡,一個處理程序只能進入一個 Job,也不能巢狀。如果對方的 SDK 自己在用 Job,你這邊的 AssignProcessToJobObject 就會失敗。JOB_OBJECT_LIMIT_BREAKAWAY_OK 是允許「已經在自己 Job 裡的處理程序,用 CREATE_BREAKAWAY_FROM_JOB 把子處理程序建立到 Job 之外」的旗標,並不是讓進去之後還能二次 Assign 的魔法。也就是說,只有當 SDK 那一方在建立時主動要求 breakaway,這條逃生路才成立。如果指望不上,就只能在啟動之前依「自己的 Job 只有一個」這個前提去改設計。微軟的文件也提出了在無法使用巢狀的環境裡,用兩個 breakaway 限制旗標管理處理程序樹的方法。不過這畢竟是已經停止支援的作業系統,可以的話還是先移轉為上策。

作者檔案

本文作者的個人檔案頁面。

Go Komura

小村軟體有限公司 代表

以 Windows 軟體開發、技術諮詢與故障調查為中心,在難以重現的故障調查與既有資產仍在運作的專案上具有優勢。

回到部落格一覽