更新紀錄(2 筆,最後更新 2026年09月04日)
本文的修改紀錄。已保存的更新前版本,可透過附有 DOI 的永久連結閱讀。
引用本文(DOI: 10.5281/zenodo.21616342)
本文保存於 Zenodo。以下同時提供一律指向最新版本的 DOI,以及固定於您正在閱讀版本的 DOI。
Go Komura(2026)。〈Windows 應用程式安全處理子處理程序的檢查清單〉。小村軟體有限公司。https://doi.org/10.5281/zenodo.21616342 https://comcomponent.com/zh-TW/blog/2026/03/20/001-windows-app-safe-child-process-handling-job-object-exit-propagation-stdio-watchdog/
- DOI(最新版本)
- 10.5281/zenodo.21616342
- DOI(此版本)
- 10.5281/zenodo.22297184
轉檔工具、更新程式、分析 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 | 訊息迴圈。持有視窗的執行緒不斷從 OS 取出訊息並處理下去的機制。這裡一停,畫面就會凍住 |
| 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
- 向主控台發出結束請求 和 回收處理程序樹 是兩回事
- 前者靠 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. 危險的地方在哪裡
啟動子處理程序的實作,一開始大概十行上下就寫得出來。 但出事的地方,在這十行的外面。
- 父處理程序當掉之後,子處理程序和孫處理程序一直留著
- helper 又啟動了另一個 helper,程式卻只等直接的子處理程序就以為結束了
stdout/stderr其中一邊塞住,父處理程序和子處理程序互相等待- 在 UI thread 上等待,畫面和 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 | 把 Ctrl+Break 傳播給主控台 | console child 的協調結束 | 父處理程序當掉時的 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 |
| 對主控台子處理程序送 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 的設計也可以,但 想把 cleanup 集中到父處理程序的生命週期上,連父處理程序異常結束也一併涵蓋 的話,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 複製到別的處理程序,或是無意間讓它被繼承,父處理程序死了也不會照預期 cleanup。誰是 job handle 的最終擁有者,應該先決定好。
flowchart TB
accTitle: 不要讓 job handle 的擁有者含糊不清
accDescr: 說明 KILL_ON_JOB_CLOSE 是在最後一個 handle 關閉時才生效,因此把 job handle 複製到別的處理程序或無意間讓它被繼承時,父處理程序死了也不會照預期 cleanup,應該先決定誰是最終擁有者的圖。
e1["讓 job handle 被複製或繼承"] --> e2["最後一個 handle 關不掉"]
e2 --> e3["父處理程序死了也不會 cleanup"]
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++ 這邊是 建立 Job → 加上 KILL_ON_JOB_CLOSE → 在啟動時指定 Job 這 3 步。
// 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 組出路徑,不是寫法上的偏好,而是為了確定到底會執行哪一個執行檔。
把 nullptr 傳給 lpApplicationName 時,命令列開頭的那個字會被當成模組名稱。如果那裡沒有含路徑,Windows 會依下列順序搜尋。
- 載入應用程式的目錄
- 父處理程序目前的工作目錄
- 32 位元的系統目錄
- 16 位元的系統目錄
- Windows 目錄
PATH環境變數中排列的目錄
問題出在 2 和 6。當 helper.exe 不在 1 裡面時 ── 部署時漏放、以別的組態建置、解除安裝後的殘留 ── 搜尋就會前進到 2。如果目前的工作目錄是可寫入的位置(直接從使用者的下載資料夾啟動、把共用資料夾當成工作目錄),放在那裡的 helper.exe 就會以和父處理程序相同的權限執行。在 PATH 可以被改寫的環境裡,6 也是一樣的情況。
Microsoft 的文件也為這一點另闢了「安全性備註」的獨立章節,明確寫著「若要避免這個問題,請不要把 NULL 傳給 lpApplicationName」。含有空白的路徑若不用引號括起來,可能會啟動 C:\Program.exe 這個有名的例子,也在同一節裡。所以命令列這一邊也用 "..." 括了起來。
C# 的 ProcessStartInfo 也一樣。UseShellExecute = false 時,.NET 會把 FileName 與引數拼成一條命令列,並把 null 傳給 lpApplicationName,因此只傳檔名的話,上面那套搜尋就會照樣發生。請傳入從 AppContext.BaseDirectory 組出來的絕對路徑。
即使在覺得「不可能發生這種部署失誤」的環境裡,寫下絕對路徑的成本也幾乎是零。在啟動子處理程序的程式碼裡,基本上沒有理由用相對名稱來寫執行檔。
flowchart TB
accTitle: 用相對名稱啟動時的搜尋所招致的事故
accDescr: 說明把 NULL 傳給 lpApplicationName 而只用檔名啟動時,搜尋對象會包含父處理程序目前的工作目錄與 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。
// 如果呼叫端的作法是捕捉初始化錯誤後重試,
// 每試一次就會漏掉一個核心 handle(上面的 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 的
// 包裝層,OS 的處理程序不會結束,而且 Job 是空的,
// 用 job.Dispose() 也清不掉。在這裡自己停掉它,並等到它結束
try
{
if (!child.HasExited)
{
child.Kill(entireProcessTree: true);
}
// Kill 只是要求結束就立刻返回。不等待就 throw 的話,
// 可能會和重做初始化後啟動的第 2 個 helper 同時執行
child.WaitForExit();
}
catch (Exception killFailed)
{
// 沒能停下來這件事,比 Add 失敗更嚴重。把它吞掉的話,
// 就會留著「既沒進 Job 也沒停下來的子處理程序」繼續往下走
throw new AggregateException(
"指派到 Job 失敗,helper.exe 也無法停止。",
assignFailed, killFailed);
}
throw;
}
}
catch
{
// 啟動和放進 Job 都失敗的話,這個 Job 就不再使用。
// 不關閉就離開的話,每重做一次初始化就會殘留一個核心 handle。
// 此時 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: 3 個階段的結束流程
accDescr: 說明子處理程序的結束不是一發 kill API 就解決的事,先請求協調結束、用較短的 timeout 等待、最後連整個 Job 一起強制結束這 3 個階段走完,就能保留正常的結束路徑並在停止回應時收得回來的圖。
i1["1. 請求協調結束"] --> i2["2. 用較短的 timeout 等待"]
i2 --> i3["3. 最後連整個 Job 一起強制結束"]
i2 -.-> i4["保留正常路徑,停止回應時收得回來"]
圖 10: 結束要按請求、等待、強制這 3 個階段來設計。
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- 能收到 signal 的,只有共用同一個主控台的處理程序
- 用了
CREATE_NEW_PROCESS_GROUP之後,CTRL+C的意義也會改變
這幾點。
5.3 Worker / headless child
Worker 或 headless child 多半既不是 GUI 也不是主控台。 這種情況下,備有 子處理程序專用的結束 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,對主控台子處理程序用 CREATE_NEW_PROCESS_GROUP 與 CTRL_BREAK_EVENT,對 worker 用透過 stdin 或 pipe 的結束 protocol,像這樣依子處理程序的種類區分請求協調結束的手段的圖。
j0["請求協調結束"] --> j1["GUI 子處理程序: close message"]
j0 --> j2["主控台子處理程序: 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, // 要用重新導向的話這是必要的
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 沒有關閉,EOF 就傳不過去,結束條件也會失效。 這件事很單純,但在實務上是相當常見的事故。
6.4 不要含糊處理 UseShellExecute=false 與 handle 繼承
要使用標準輸入輸出的重新導向,在 .NET 上就以 UseShellExecute=false 為前提。
在 Win32 上也一樣,要讓什麼被繼承 盡量收斂比較安全。維持 bInheritHandles=TRUE 全部繼承的話,會成為意想不到的 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 監控本來就不需要用輪詢迴圈每 100ms 去看一次 HasExited。
在 Win32 上,
WaitForSingleObjectWaitForMultipleObjectsRegisterWaitForSingleObjectSetThreadpoolWait
才是正統做法。要處理多個 child 時,比起 timer polling,以 wait handle 為基礎更自然。
7.2 不要在 UI thread 無限等待
WaitForSingleObject(INFINITE) 很方便,但在持有 window 的 thread 上使用,很容易讓 message pump 停下來。
在 UI thread、COM apartment thread、持有 message pump 的 thread 上,先想清楚 等待要放在哪裡 比較安全。
flowchart TB
accTitle: exit 監控要以 wait handle 為基礎
accDescr: 說明處理程序結束後會進入 signaled 狀態,因此 exit 監控不要用定期查看 HasExited 的輪詢,而要以 wait handle 為基礎進行,並且在 UI 執行緒上的無限等待會讓畫面凍住所以要避開的圖。
p1["定期輪詢 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% 的狀態卡住
- 發生死結
- event loop 還活著但沒有進度
- 停在等待輸入
這類狀態,光靠「處理程序還活著嗎」判斷不出來。所以想連 hang 都看得到的話,
- heartbeat
- progress sequence
- last successful work timestamp
- health probe
這類 應用程式層的存活確認 就是必要的。
flowchart TB
accTitle: 偵測 hang 需要 heartbeat
accDescr: 說明在 CPU 100% 的狀態卡住、發生死結、沒有進度這類狀態,光靠處理程序還活著與否判斷不出來,因此想看到 hang 就需要 heartbeat 或進度等應用程式層的存活確認的圖。
q1["處理程序還活著"] --> q2["但可能沒有在往前進"]
q2 --> q3["光靠 exit 監控判斷不出來"]
q3 -.->|"所以"| q4["heartbeat 或進度等應用程式層的確認"]
圖 17: 「是否還活著」和「是否還在前進」是兩種不同的監控。
7.4 負責重新啟動的角色要放在被監控對象之外
實務上常見的是這 2 種模式。
- 父應用程式只是臨時啟動 helper
- 由父應用程式持有 Job,父應用程式結束時回收 helper tree
- 要讓 worker 長時間常駐,當掉時想重新啟動
- 由外部的 watchdog process / service 為每個 worker generation 建立一個 Job
後者要 把 worker tree 與 restart authority 分開,設計才會穩定。
7.5 restart policy 要用 budget 來管
導入 watchdog 之後,接下來就會開始出現 crash loop。
- 立刻重新啟動
- 又立刻當掉
- 只留下大量的日誌
要避開這種情況,
- backoff
- 一定時間內的 restart 次數上限
- 連續失敗時停止並通知
備有這樣的 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 generation 建立一個 Job,用 exit handle + heartbeat 監控 |
| 想妥善地停掉主控台工具 | 用 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 leak 與 EOF 傳不到的原因 | 6.4 |
先把 stdout 全部讀完再讀 stderr |
另一邊的 pipe 會被塞滿,父處理程序停在等讀、子處理程序停在等寫 | 6.1 |
| 不關閉 pipe 用不到的 end | EOF 傳不過去,讀取端的結束條件不成立 | 6.3 |
在 UI thread 呼叫 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 來看
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 的所有處理程序都會結束,因此連父處理程序異常結束在內的 cleanup,都能集中到父處理程序的生命週期上。
- 為什麼 WaitForExit 一直不回來?
- 很可能是標準輸出或標準錯誤的 pipe 塞住了。Windows 的 pipe 不是無限緩衝區,子處理程序大量往 stderr 輸出,父處理程序卻只讀 stdout 時,子處理程序會停在 write,父處理程序則停在等待結束。stdout 與 stderr 基本上要平行抽乾,先把一邊全部讀完再讀另一邊的實作很容易塞住。另外,沒有關閉 pipe 用不到的一端,EOF 就傳不過去,結束條件也會跟著失效。
- 只靠 .NET 的 Kill(entireProcessTree: true) 還不夠嗎?
- 不夠。它作為明確停止用的 API 很方便,但無法取代涵蓋父處理程序當掉時自動回收與 graceful shutdown 的整體設計。最不容易出事的是三個階段:先請求協調結束,用較短的 timeout 等待,最後再連同整個 Job 強制結束。協調結束的手段要依子處理程序的種類分開:GUI 子處理程序用 CloseMainWindow,主控台子處理程序用 CREATE_NEW_PROCESS_GROUP 與 CTRL_BREAK_EVENT,worker 則用透過 stdin 或 pipe 的結束 protocol。
- watchdog 處理程序應該放在哪裡?
- 最重要的是不要把它放進和被監控對象相同的 Job。因為 worker 當掉時想重新啟動,如果連負責重新啟動的角色也一起死掉就沒有意義了。要讓 worker 長時間常駐時,由外部的 watchdog 處理程序或服務為每個 worker 世代建立一個 Job,這樣的架構比較穩定。exit 監控不要用輪詢,而要以 wait handle 為基礎;如果連停止回應都要偵測,就再搭配 heartbeat 之類的應用程式層存活確認。