Windows 應用程式安全處理子處理程序的檢查清單

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

更新紀錄(2 筆,最後更新 2026年09月04日)

本文的修改紀錄。已保存的更新前版本,可透過附有 DOI 的永久連結閱讀。

已將繁體中文版改寫為日文原文的完整翻譯。先前的繁體中文版只譯出日文原文的一部分,遺漏了章節、表格、Mermaid 圖、圖說與 FAQ。本次依日文原文將這些內容全部補回,並新增本文的知識地圖章節。技術主張與日文版一致。 查看更新前的版本 (DOI: 10.5281/zenodo.22279487)
補上了日文原文中已有的諮詢引導(consultation_services)。內文沒有改動。 查看更新前的版本 (DOI: 10.5281/zenodo.21616343)
初次發布
引用本文(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

下載附日英雙語工作表的 Excel 檢查清單

轉檔工具、更新程式、分析 worker、外部 CLI、PowerShell、ffmpeg、公司內部的小工具。 Windows 應用程式對子處理程序產生依賴,比想像中容易得多。

不過,真正出事的地方,不是「能不能啟動」。

  • 父處理程序當掉了,卻只剩子處理程序還活著
  • 只有孫處理程序活了下來
  • stdout / stderr 塞住,WaitForExit 一直不回來
  • watchdog 跟著被監控對象一起死掉
  • 以為用 Kill(entireProcessTree: true) 就結束了,其實只有觀測先結束

在 Windows 上安全處理子處理程序的訣竅,不是 挑選啟動 API,而是 決定處理程序樹的擁有者,並設計結束流程與 I/O。

本文把 Job Object、結束傳播、標準輸入輸出與 watchdog 整理成一張設計圖。

出事的地方在啟動之外說明子處理程序的事故不在於能不能啟動,而是以父處理程序當掉卻只剩子處理程序、stdout 塞住等形式出現,訣竅不是挑選啟動 API,而是決定處理程序樹的擁有者並設計結束流程與 I/O 的圖。挑選啟動 API這裡不是事故的主體決定處理程序樹的擁有者安全地處理子處理程序設計結束流程設計 I/O

圖 1: 子處理程序的安全,取決於所有權、結束與 I/O 的設計,而不是啟動 API。

本文使用的術語

先把以英文原樣出現的詞,一行一行整理清楚。

術語 一句話的意思
process tree 處理程序樹。指從父處理程序啟動的子處理程序,以及子處理程序再啟動的孫處理程序,整個家族都算在內
graceful shutdown 協調結束。先請對方「請結束」,讓對方做完善後處理再自己結束的做法。是強制結束的相對詞
I/O completion port Windows 的非同步 I/O 完成通知機制。和 Job Object 關聯起來後,就能收到處理程序啟動或結束的通知
message pump 訊息迴圈。持有視窗的執行緒不斷從 OS 取出訊息並處理下去的機制。這裡一停,畫面就會凍住
heartbeat 為了確認存活,由子處理程序定期送出的訊號。用來偵測處理程序「還活著卻沒有往前進」的狀態
restart budget 重新啟動的預算。一定時間內最多可以重新啟動幾次的上限。為了擋下 crash loop 而準備
drain 抽乾。把 pipe 裡累積的輸出讀完,讓寫入端不會塞住

整體樣貌

先用一張圖把登場角色的關係整理起來。

加上 JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE 的 Job Object用 exit handle 偵測結束用 heartbeat 偵測停止回應在 restart budget 的範圍內重建父應用程式 / worker 本體job handle 的最終擁有者子 helper.exe孫 converter.exe孫 ffmpeg.exewatchdog放在 Job 外面

圖 2: 整體樣貌。Job 的邊界就是處理程序樹的邊界,只有 watchdog 在外面。

看點有兩個。

  • Job 的邊界就是處理程序樹的邊界。綁的依據不是父處理程序的生死,而是屬於哪個 Job,所以孫處理程序再多也不會漏掉回收
  • 只有 watchdog 在 Job 外面。放進去的話,就會和被監控對象一起被清掉

1. 先講結論

先把實務上最有用的部分列出來。

  • 若要把父處理程序的生死與子處理程序樹的生命週期綁在一起,基準點就是 Job Object
  • 向主控台發出結束請求 和 回收處理程序樹 是兩回事
    • 前者靠 process group 與 GenerateConsoleCtrlEvent
    • 後者靠 Job Object
  • 想從啟動當下就進入 Job,用 STARTUPINFOEX 與 PROC_THREAD_ATTRIBUTE_JOB_LIST 的設計最直接了當
  • 標準輸出 / 標準錯誤要平行抽乾,這是基本
  • 要用 stdin,就要設計到寫完後 close 以傳達 EOF 為止
  • watchdog 放在被監控對象的 Job 外面 比較安全
  • .NET 的 Kill(entireProcessTree: true) 作為明確停止用的 API 很方便,但無法取代涵蓋父處理程序當掉時自動回收與 graceful shutdown 的整體設計

圖中實線表示始終成立的關係,虛線表示附帶條件的關係(成立條件寫在詳細頁面中各關係的說明中)。關係的完整清單(共 17 條,附依據與可信度)以及主要概念的定義,彙整在知識地圖詳細頁面(日文)。資料:JSON-LD / Turtle

2. 危險的地方在哪裡

啟動子處理程序的實作,一開始大概十行上下就寫得出來。 但出事的地方,在這十行的外面。

  • 父處理程序當掉之後,子處理程序和孫處理程序一直留著
  • helper 又啟動了另一個 helper,程式卻只等直接的子處理程序就以為結束了
  • stdout / stderr 其中一邊塞住,父處理程序和子處理程序互相等待
  • 在 UI thread 上等待,畫面和 COM 都凍住
  • watchdog 和被監控對象成了命運共同體,出狀況時一起當掉

這裡的重點是:「子處理程序管理」不是單一 API 的問題。

至少把下面這 4 件事分開來想,才更容易看清全貌。

  1. 處理程序樹由誰擁有
  2. 要怎麼請求協調結束
  3. 標準輸入輸出要怎麼流
  4. 異常結束與停止回應要怎麼監控
要分開思考的 4 個問題說明子處理程序管理不是單一 API 的問題,把處理程序樹由誰擁有、要怎麼請求協調結束、標準輸入輸出要怎麼流、異常結束與停止回應要怎麼監控這 4 件事分開來想,就更容易看清全貌的圖。1. 處理程序樹的擁有者2. 請求協調結束的方式3. 標準輸入輸出的流法4. 異常結束與停止回應的監控不是單一 API 的問題

圖 3: 子處理程序管理,要拆成這 4 個問題來設計。

3. 不要混淆各機制的角色

process handle / process group / Job Object 看起來相似,角色其實不同。

機制 主要角色 適合的場合 只靠它還不夠的部分
process handle 等待單一處理程序結束、取得 exit code 等待單次執行的工具完成 孫處理程序的回收
process group 把 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 這邊備好的,終究只到單一處理程序層級的操作。

.NET 的守備範圍與 Job 的邊界說明 .NET 標準函式庫備好的只到啟動與等待結束等單一處理程序層級的操作,Job Object 相關的操作沒有包裝層,必須用 P/Invoke 直接呼叫 Win32 API 的圖。單一處理程序層級的操作用 .NET 標準的 Process 就夠Job Object 相關的操作沒有包裝層用 P/Invoke 呼叫 Win32 API

圖 4: 即使用 .NET,綁住處理程序樹的那部分還是要直接呼叫 Win32 API。

4. 以 Job Object 為基準

Job Object 最強的一點,是能用 「屬於哪個 Job」而不是「是誰的子處理程序」 來綁住 process tree。已經在 Job 裡的處理程序用 CreateProcess 建立的子處理程序,預設也會進入同一個 Job。

再加上 JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,最後一個 job handle 被關閉時,和這個 Job 關聯的所有處理程序都會結束。

用 Job 綁起來就不會漏掉回收說明 Job Object 是用屬於哪個 Job 而不是誰的子處理程序來綁住處理程序樹,進入 Job 的處理程序所建立的子處理程序預設也會進入同一個 Job,加上 KILL_ON_JOB_CLOSE 之後最後一個 job handle 關閉時所有處理程序都會結束的圖。用「是誰的子處理程序」來追孫處理程序一多就會漏掉用「屬於哪個 Job」來綁子處理程序建立的子處理程序也進同一個 JobKILL_ON_JOB_CLOSE最後一個 handle 關閉就全部結束

圖 5: 綁的依據是 Job 歸屬而不是父子關係,所以連孫處理程序都收得回來。

4.1 先掌握的 4 件事

1. 想在父處理程序結束時連整棵樹一起清掉,就用 KILL_ON_JOB_CLOSE

這是 Windows 應用程式處理 helper / worker 時的基礎。明確呼叫 TerminateJobObject 的設計也可以,但 想把 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 的最終擁有者,應該先決定好。

不要讓 job handle 的擁有者含糊不清說明 KILL_ON_JOB_CLOSE 是在最後一個 handle 關閉時才生效,因此把 job handle 複製到別的處理程序或無意間讓它被繼承時,父處理程序死了也不會照預期 cleanup,應該先決定誰是最終擁有者的圖。所以讓 job handle 被複製或繼承最後一個 handle 關不掉父處理程序死了也不會 cleanup先決定最終擁有者

圖 6: KILL_ON_JOB_CLOSE 要等「最後一個 handle」關閉才會生效。

4.2 Job Object 也能用於 observability,但通知不是萬能

Job Object 有把 I/O completion port 關聯上去以接收通知的機制。不過 completion port 的通知,最好不要當成在所有情況下都完全保證送達的通知。

所以 completion port 用在

  • 監控
  • 彙總
  • 日誌
  • 指標

上面很方便,但 不要只靠它來建立 correctness。

completion port 通知的用武之地說明 Job Object 有關聯 I/O completion port 接收通知的機制,但不要視為在所有情況下都完全保證送達的通知,適合用於監控、彙總、日誌與指標,不要只靠它建立 correctness 的圖。Job 的 completion port 通知用於監控、彙總、日誌很方便不要當成完全保證的通知不要只靠它建立 correctness

圖 7: 通知用來觀測,不要拿來當正確性的依據。

4.3 用最小程式碼來看

程式碼比文字說明還短,所以兩種語言的最小形式都放上來。

C++ 這邊是 建立 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 會依下列順序搜尋。

  1. 載入應用程式的目錄
  2. 父處理程序目前的工作目錄
  3. 32 位元的系統目錄
  4. 16 位元的系統目錄
  5. Windows 目錄
  6. 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 組出來的絕對路徑。

即使在覺得「不可能發生這種部署失誤」的環境裡,寫下絕對路徑的成本也幾乎是零。在啟動子處理程序的程式碼裡,基本上沒有理由用相對名稱來寫執行檔。

用相對名稱啟動時的搜尋所招致的事故說明把 NULL 傳給 lpApplicationName 而只用檔名啟動時,搜尋對象會包含父處理程序目前的工作目錄與 PATH,當原本的 helper.exe 不存在時,放在可寫入位置的同名執行檔就會以父處理程序的權限執行,因此一定要用絕對路徑指定的圖。要防止的話只用檔名啟動搜尋範圍包含目前的工作目錄與 PATH原本的位置沒有 helper.exe 時被放上去的同名 EXE 以父處理程序的權限執行用絕對路徑指定,並用引號括起來

圖 8: 要啟動的檔案不要交給搜尋,用絕對路徑確定下來。

.NET 沒有 Job Object 的包裝層,所以要用 P/Invoke。結構的定義看起來很長,但實際呼叫的只有 2 個函式。

// .NET 8 / C# 12。建立 Job 並加上 KILL_ON_JOB_CLOSE,把已啟動的處理程序放進去
using System;
using System.Diagnostics;
using System.Runtime.InteropServices;
using Microsoft.Win32.SafeHandles;

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

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

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

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

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

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

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

    /// <summary>建立 Job。傳回的 handle 在應用程式的生命週期內要一直保持開啟。</summary>
    public static SafeJobHandle Create()
    {
        var job = CreateJobObjectW(IntPtr.Zero, IntPtr.Zero);
        if (job.IsInvalid)
        {
            throw new InvalidOperationException($"CreateJobObject 失敗。code={Marshal.GetLastWin32Error()}");
        }

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

        var size = (uint)Marshal.SizeOf<JOBOBJECT_EXTENDED_LIMIT_INFORMATION>();
        if (!SetInformationJobObject(job, JobObjectExtendedLimitInformation, ref info, size))
        {
            // 不要就這樣丟掉「建立成功但設定失敗」這種半吊子的 Job。
            // 如果呼叫端的作法是捕捉初始化錯誤後重試,
            // 每試一次就會漏掉一個核心 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。

從啟動到 Assign 之間的空隙說明從啟動到用 AssignProcessToJobObject 放進 Job 之間,子處理程序若又建立了孫處理程序,孫處理程序就會誕生在 Job 之外,因此面對會建立孫處理程序的 helper 時,值得用在建立時就指定 Job 的 PROC_THREAD_ATTRIBUTE_JOB_LIST 來消除空隙的圖。要消除空隙的話先啟動再事後放進 Job啟動與 Assign 之間有空隙這段期間誕生的孫處理程序在 Job 之外在建立時就指定 Job 再啟動

圖 9: 事後才放進 Job 的做法,有讓孫處理程序溜過去的空隙。

5. 用 protocol 與 timeout 設計結束傳播

子處理程序的結束,不是一發 kill API 就解決的事。 最不容易出事的,是走完下面這 3 個階段的形式。

  1. 請求協調結束
  2. 用較短的 timeout 等待
  3. 最後連整個 Job 一起強制結束

照這個順序做,就能保留正常的結束路徑,同時在停止回應時把它收回來。

3 個階段的結束流程說明子處理程序的結束不是一發 kill API 就解決的事,先請求協調結束、用較短的 timeout 等待、最後連整個 Job 一起強制結束這 3 個階段走完,就能保留正常的結束路徑並在停止回應時收得回來的圖。1. 請求協調結束2. 用較短的 timeout 等待3. 最後連整個 Job 一起強制結束保留正常路徑,停止回應時收得回來

圖 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,這樣的分工最不容易出事。

依子處理程序的種類區分協調結束說明對 GUI 子處理程序用 CloseMainWindow 之類的 close message,對主控台子處理程序用 CREATE_NEW_PROCESS_GROUP 與 CTRL_BREAK_EVENT,對 worker 用透過 stdin 或 pipe 的結束 protocol,像這樣依子處理程序的種類區分請求協調結束的手段的圖。請求協調結束GUI 子處理程序: close message主控台子處理程序: CTRL_BREAK_EVENTworker: stdin 或 pipe 的結束 protocoltree cleanup 由 Job Object 負責

圖 11: 協調結束的手段依子處理程序的種類挑選,回收交給 Job。

6. 不要讓標準輸入輸出塞住

6.1 stdout / stderr 要平行 drain

第一個基本原則就是這個。 stdout 與 stderr 要平行抽乾。先把一邊全部讀完再讀另一邊,很容易塞住。

Windows 的 pipe 不是無限緩衝區。子處理程序大量往 stderr 輸出,父處理程序卻只讀 stdout 的話,子處理程序停在 write、父處理程序停在等待結束,這種情況很常發生。

畫成圖就是這個樣子。

子處理程序stderr 的 pipestdout 的 pipe父處理程序子處理程序stderr 的 pipestdout 的 pipe父處理程序pipe 的緩衝區被塞滿write 不回來。子處理程序停在這裡子處理程序停住了,所以什麼都不會來父處理程序在等讀、子處理程序在等寫。WaitForExit 也不會回來只持續讀 stdout只寫一點點讀到了大量寫出警告想再繼續寫想繼續往下讀

圖 12: 只讀 stdout 的話,stderr 的 pipe 會被塞滿,父子互相等待。

卡住的地方 既不是父處理程序也不是子處理程序,而是 pipe,所以看哪一邊的日誌都照不出原因。少了「讀取 stderr」這一行,就直接變成停止回應。

把 stdout 與 stderr 交給不同的處理常式接收,各自獨立往下讀,這個循環就不會成立。用 .NET 的話寫法如下。

// .NET 8 / C# 12。平行 drain stdout 與 stderr,並等到輸出讀完為止
using System;
using System.ComponentModel;   // Win32Exception
using System.Diagnostics;
using System.IO;
using System.Text;

// 要啟動的檔案用絕對路徑傳入(理由請見 Job Object 那一節)
string helperPath = Path.Combine(AppContext.BaseDirectory, "helper.exe");

var startInfo = new ProcessStartInfo(helperPath, "--input data.bin")
{
    UseShellExecute = false,        // 要用重新導向的話這是必要的
    RedirectStandardOutput = true,
    RedirectStandardError = true,
    CreateNoWindow = true,
};

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

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

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

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

process.Start();
process.BeginOutputReadLine();   // 只註冊事件不會開始讀。兩個都一定要呼叫
process.BeginErrorReadLine();

if (!process.WaitForExit(30_000))
{
    // 這裡是「不再等下去」的判斷,不是 cleanup 的替代品
    try
    {
        process.Kill(entireProcessTree: true);
    }
    catch (Exception ex) when (ex is Win32Exception or InvalidOperationException)
    {
        // 有一種競爭:30 秒的等待切斷的「下一瞬間」,子處理程序自己結束了。
        // 在 .NET 上,對結束處理中的處理程序呼叫 Kill 會得到 Win32Exception("The process is
        // terminating.");在 .NET Framework 上,對已結束的處理程序呼叫 Kill 會得到
        // InvalidOperationException。
        // 如果已經結束,這就不是失敗,所以吞掉它並往下走到
        // TimeoutException。如果還活著,就表示真的沒能停下來,
        // 所以原樣重新拋出
        if (!process.HasExited)
        {
            throw;
        }
    }
    // AggregateException(有部分子孫沒能停下來)不要攔下。
    // 那正是「整棵樹沒清乾淨」本身,所以要讓它往外傳

    // Kill 只是要求結束就立刻返回。在這裡不等待就 throw 的話,
    // using 的 Dispose 執行時子處理程序可能還活著,
    // 「拋出逾時例外=整棵樹已清乾淨」就不成立
    process.WaitForExit();

    throw new TimeoutException("helper.exe 沒有在 30 秒內結束。");
}

// 帶 timeout 的 WaitForExit 就算傳回 true,非同步的輸出處理也可能還沒結束。
// 再呼叫一次不帶引數的 WaitForExit,等到輸出讀完為止。
process.WaitForExit();

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

逾時的邊界上一定有競爭。在 WaitForExit(30_000) 傳回 false 之後、呼叫 Kill 之前的那一小段時間裡,子處理程序有可能自己結束。這時 Kill 不會成功 ── 在 .NET 上,對結束處理中的處理程序會拋出 Win32Exception(「The process is terminating.」),在 .NET Framework 上,對已經結束的處理程序會拋出 InvalidOperationException。如果讓它就這樣穿過去,飛出來的就不是本來該拋的 TimeoutException,而是善後失敗的例外。呼叫端收到的不是「逾時了」,而是「出現了搞不清楚的錯誤」,最後那次由 WaitForExit() 進行的輸出讀取也會被跳過。要像上面那樣,先用 HasExited 確認「是不是真的已經結束」再把它吞掉。如果還活著,就表示沒能停下來,那就原樣重新拋出。另外,Kill(entireProcessTree: true) 拋出的 AggregateException(有部分子孫沒能停下來)不要攔下。因為那正是「整棵樹沒清乾淨」本身,也就是這一節想要防止的狀態。

逾時邊界上的競爭說明在 WaitForExit 傳回 false 之後到呼叫 Kill 之間的短暫時間裡,子處理程序可能自己結束,若放著不管,善後的失敗就會蓋掉本來該拋的 TimeoutException,因此要用 HasExited 確認是不是真的已經結束再吞掉,若還活著就重新拋出的圖。已經結束還活著等待切斷後子處理程序隨即自己結束Kill 失敗用 HasExited 確認這不是失敗,所以吞掉沒能停下來,所以重新拋出拋出本來該拋的 TimeoutException

圖 13: 在邊界的競爭中,用 HasExited 分辨 Kill 的失敗是不是真的失敗。

最後那個 WaitForExit() 不是忘了刪掉,而是必要的。 WaitForExit(int) 的文件裡寫著,把標準輸出重新導向到非同步事件處理常式時,這個多載傳回的當下,輸出處理可能還沒完成,並指示在收到 true 之後再呼叫一次不帶引數的 WaitForExit()。省略這一步的話,就會以 只有輸出的結尾缺一段 這種難以重現的形式壞掉。

6.2 要用 stdin,就要設計到 EOF

「能寫進 stdin」和「子處理程序能結束」不是同一件事。

  • 寫完輸入後沒有 close
  • 父處理程序以為「已經交出去了」
  • 子處理程序以為「後面還有」而一直等下去

這樣的狀態就會發生。要用 stdin,就必須把 寫完後 close 以傳達 EOF 也包含進設計裡。

stdin 要設計到 EOF說明寫入 stdin 之後若沒有 close,父處理程序以為已經交出去了,子處理程序卻以為後面還有而一直等下去,因此要把寫完後 close 以傳達 EOF 也包含進設計裡的圖。寫完輸入後沒有 close父處理程序以為「已經交出去了」子處理程序以為「後面還有」而等下去寫完後 close 以傳達 EOF

圖 14: stdin 的設計不止於能寫入,而要到 EOF 傳達得出去為止。

6.3 用不到的 pipe 端一定要關閉

父處理程序這邊、子處理程序這邊用不到的 end 沒有關閉,EOF 就傳不過去,結束條件也會失效。 這件事很單純,但在實務上是相當常見的事故。

6.4 不要含糊處理 UseShellExecute=false 與 handle 繼承

要使用標準輸入輸出的重新導向,在 .NET 上就以 UseShellExecute=false 為前提。 在 Win32 上也一樣,要讓什麼被繼承 盡量收斂比較安全。維持 bInheritHandles=TRUE 全部繼承的話,會成為意想不到的 handle leak 的原因。

7. watchdog 要放在「外面」

導入 watchdog 時最重要的是 不要把它放進和被監控對象相同的 Job。 worker 當掉時想重新啟動,如果連負責重新啟動的角色也一起死掉就沒有意義了。

watchdog 要放在被監控對象之外說明把 watchdog 放進和被監控對象相同的 Job 時,清理 worker 的時候連負責重新啟動的角色也會一起死掉,因此要把 watchdog 放在被監控對象的 Job 外面的圖。所以把 watchdog 放進同一個 Job清理時負責重新啟動的角色也一起死watchdog 放在 Job 外面worker 當掉也能重新啟動

圖 15: 不讓負責重新啟動的角色成為命運共同體,是擺放 watchdog 的第一個條件。

7.1 exit 監控要以 wait handle 為基礎

處理程序一結束就會進入 signaled 狀態。 所以 exit 監控本來就不需要用輪詢迴圈每 100ms 去看一次 HasExited。

在 Win32 上,

  • WaitForSingleObject
  • WaitForMultipleObjects
  • RegisterWaitForSingleObject
  • SetThreadpoolWait

才是正統做法。要處理多個 child 時,比起 timer polling,以 wait handle 為基礎更自然。

7.2 不要在 UI thread 無限等待

WaitForSingleObject(INFINITE) 很方便,但在持有 window 的 thread 上使用,很容易讓 message pump 停下來。 在 UI thread、COM apartment thread、持有 message pump 的 thread 上,先想清楚 等待要放在哪裡 比較安全。

exit 監控要以 wait handle 為基礎說明處理程序結束後會進入 signaled 狀態,因此 exit 監控不要用定期查看 HasExited 的輪詢,而要以 wait handle 為基礎進行,並且在 UI 執行緒上的無限等待會讓畫面凍住所以要避開的圖。定期輪詢 HasExited本來就不需要等待結束時會變成 signaled 的 handle以 wait handle 為基礎的監控在 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

這類 應用程式層的存活確認 就是必要的。

偵測 hang 需要 heartbeat說明在 CPU 100% 的狀態卡住、發生死結、沒有進度這類狀態,光靠處理程序還活著與否判斷不出來,因此想看到 hang 就需要 heartbeat 或進度等應用程式層的存活確認的圖。所以處理程序還活著但可能沒有在往前進光靠 exit 監控判斷不出來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 會比較好。

用 restart budget 擋下 crash loop說明為了避開立刻重新啟動又立刻當掉的 crash loop,要備有 backoff、一定時間內的重新啟動次數上限、連續失敗時停止並通知這樣的 restart budget 的圖。要防止的話立刻重新啟動→又立刻當掉crash loop 與日誌洪水加入 backoff一定時間內的次數上限連續失敗就停止並通知

圖 18: 重新啟動用預算來管理,用完就停下來通知人。

8. 依典型模式的建議組合

場合 建議組合
桌面應用程式啟動單次執行的 CLI helper 1 次啟動 = 1 個 Job。加上 KILL_ON_JOB_CLOSE,並平行 drain stdout / stderr。取消時走協調結束 → timeout → Job kill
helper 又會啟動孫處理程序 以 Job Object 為前提,不允許 breakaway。想從啟動時就固定的話,用 PROC_THREAD_ATTRIBUTE_JOB_LIST
service / watchdog 長時間監控 worker tree watchdog 用外部的 process / service。為每個 worker 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 的機制 分開。

提出請求的機制與收拾的機制說明在任何一種典型模式下最重要的,都是分別備有 close message 或結束 protocol 這類 graceful shutdown 的機制,以及由 Job Object 負責的 cleanup 機制的圖。graceful shutdown 的機制兩者分開各自備有cleanup 的機制(Job)正常時靠前者,異常時靠後者

圖 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 流到底。

要先決定的 4 件事說明由誰擁有 process tree、要怎麼傳達結束請求、標準輸入輸出要怎麼流到底、watchdog 要放在哪裡這 4 件事先決定好,對子處理程序出問題的機率影響最大的圖。樹的擁有者先決定好結束的傳達方式stdio 的處理方式監控的擺放位置啟動 API 只是入口

圖 20: 要降低出問題的機率,最有效的,是在啟動之前先把這 4 件事決定好。

11. 參考資料

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

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

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

常見問題

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

為什麼父處理程序已經當掉,子處理程序卻還留著?
因為光靠 process handle 或 process group,並沒有在父處理程序當掉時回收處理程序樹的機制。若要把父處理程序的生死與子處理程序樹的生命週期綁在一起,基準點就是 Job Object。加上 JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE 之後,最後一個 job handle 關閉時,屬於該 Job 的所有處理程序都會結束,因此連父處理程序異常結束在內的 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 之類的應用程式層存活確認。

作者檔案

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

Go Komura

小村軟體有限公司 代表

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

回到部落格一覽