Windows 應用安全處理子行程的 checklist - Job Object、結束傳播、標準輸入輸出、watchdog 的最佳實務

· · Windows, Process, Job Object, IPC, C++, .NET, C#

下載日英雙語 Excel 檢核表

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

但出事的,往往不是「能不能啟動」。

  • 父行程掛了,子行程卻還在
  • 孫行程獨自活著
  • stdout / stderr 塞住,WaitForExit 就是不回
  • watchdog 跟著被監視對象一起死
  • Kill(entireProcessTree: true) 看起來已經結束,但其實只是觀測先結束

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

本文把 Job Object、結束傳播、標準 I/O、watchdog 當成一張整體設計圖來整理。

1. 先下結論

先列出實務上最有效的重點:

  • 若要把父行程的生死與子行程樹的壽命綁起來,基準點就是 Job Object
  • 向 console 發出結束請求行程樹的回收 是兩件事
    • 前者要靠 process group 與 GenerateConsoleCtrlEvent
    • 後者則是 Job Object
  • 想讓子行程「從一啟動就在 Job 裡」,用 STARTUPINFOEXPROC_THREAD_ATTRIBUTE_JOB_LIST 設計最直接
  • 標準輸出 / 標準錯誤要平行抽乾
  • 要用 stdin,就要設計到「寫完後 close 來送 EOF」為止
  • watchdog 要放在被監視對象的 Job 外面
  • .NETKill(entireProcessTree: true) 好用,但它不是 Windows 的 tree lifecycle 管理本身

2. 哪些地方危險

啟動子行程的程式碼,一開始往往 10 行左右就寫好了。
但出事的,不在這 10 行裡面。

  • 父行程掛了之後,子孫還一直在
  • helper 又啟動 helper,程式卻只等直接子行程就滿足了
  • stdout / stderr 其中一邊塞住,父子互等
  • 在 UI thread 等待,畫面與 COM 一起凍
  • watchdog 與被監視對象是「命運共同體」,異常時一起倒

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

至少要拆成下面 4 件事來思考:

  1. 行程樹誰擁有
  2. 怎麼請對方協調結束
  3. 標準 I/O 怎麼流
  4. 異常結束與 hang 要怎麼監控

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

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

機制 主要角色 適合的情境 只有它還不夠的部分
process handle 等待單一行程結束、拿 exit code 等待單發工具完成 孫行程的回收
process group 把 Ctrl+Break 傳給 console console child 的協調結束 父行程崩潰時的清理、GUI 子行程
Job Object 把行程樹打包、限制、一次結束 worker tree、updater、helper chain 應用層的「存檔後再關」

process group 是決定 把 console signal 送給誰,它並不是 父行程一死就順便清掉整棵樹
Job Object 則是 Windows 用來 把一群行程視為一個單位 來管的機制。

4. 把 Job Object 當基準

Job Object 最強的一點,是以 「屬於哪個 Job」 而不是 「誰是父」 來綁 process tree。在 Job 裡的行程用 CreateProcess 開的子行程,預設也會在同一個 Job 裡。

再加上 JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE最後一個 job handle 關閉時,和這個 Job 綁在一起的所有行程都會結束。

4.1 先掌握的 4 點

1. 要讓父行程結束時順便清掉整棵樹,用 KILL_ON_JOB_CLOSE

這是 Windows 應用處理 helper / worker 時的底子。也可以明確地呼叫 TerminateJobObject,但 如果想把清理連同父行程異常退出都併到「父行程的壽命」裡,用 KILL_ON_JOB_CLOSE 會最直觀。

2. 不要隨便加 BREAKAWAY

JOB_OBJECT_LIMIT_BREAKAWAY_OKJOB_OBJECT_LIMIT_SILENT_BREAKAWAY_OK 看起來方便,但實際上容易讓「以為能清掉整棵樹」時有成員跑出去。沒有明確意圖,就別加 breakaway。

3. 要讓子行程「從一啟動就在 Job 裡」,用 PROC_THREAD_ATTRIBUTE_JOB_LIST

雖然可以用 AssignProcessToJobObject 事後加入 Job,但 若要從啟動那一刻就屬於 Job,用 STARTUPINFOEXPROC_THREAD_ATTRIBUTE_JOB_LIST 在建立時指定 Job 會更穩。

4. 不要讓 job handle 的擁有者含糊

KILL_ON_JOB_CLOSE 是在 最後一個 handle 關閉時 生效。
反過來說,如果把 job handle 複製到別的行程,或無意間讓它被繼承,父行程死了也不會照預期清理。誰是 job handle 的最終擁有者,要先決定。

4.2 Job Object 也能做觀測,但通知不是萬能

Job Object 可以關聯 I/O completion port 來接收通知。但 completion port 的通知並不是在所有情境下都「完全保證」,最好不要當作唯一真相看待。

completion port 適合用在:

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

correctness 不要完全依賴它

5. 結束傳播要以「protocol + timeout」來設計

子行程的結束 不是一個 kill API 就能搞定的事
最不容易出事的做法,大致是以下 3 階段:

  1. 請對方協調結束
  2. 用較短的 timeout 等一下
  3. 最後再用 Job 整個強制結束

依這個順序做,就能保留正常結束路徑,同時在 hang 的時候收得回來。

5.1 GUI child

對有 GUI 的子行程,.NETCloseMainWindow 會送 close message。
但這是 結束請求,不是強制結束。所以流程應該是:

  • CloseMainWindow
  • 等一段時間
  • 不行就用 Job 整個 kill

5.2 Console child

Console child 沒辦法用 GUI 的 close message。
這時要靠 process group 與 console signal

CREATE_NEW_PROCESS_GROUP 啟動,再用 GenerateConsoleCtrlEventCTRL_BREAK_EVENT。這裡要注意:

  • CTRL_C_EVENT 不適合限定到特定 group
  • 能收到 signal 的,是共用同一個 console 的行程
  • 用了 CREATE_NEW_PROCESS_GROUP 之後,CTRL+C 的意義也會變

5.3 Worker / headless child

Worker 或 headless child 往往既不是 GUI 也不是 console。
這時應該 為子行程定義專用的結束 protocol 比較安全。

  • stdinquit
  • 用 named pipe / socket / RPC 送 shutdown command
  • 用 event object 傳達停止請求

Windows 面由 Job Object 負責 tree cleanup;應用層由 pipe 或 stdin 負責 graceful shutdown。這種分工比較不會翻車。

6. 不要讓標準 I/O 塞住

6.1 stdout / stderr 要平行抽乾

最基本的原則:stdoutstderr 要平行讀。先讀完一邊再讀另一邊,很容易塞。

Windows 的 pipe 不是無限 buffer。子行程大量往 stderr 寫,父只讀 stdout,結果子在 write 卡住、父在等結束,這種情境很常見。

6.2 要用 stdin,就連 EOF 也要設計

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

  • 寫完輸入卻沒關
  • 父以為「給過了」
  • 子卻以為「後面還有」繼續等

這種狀況會自然發生。用 stdin,就要設計到 寫完後 close 送 EOF 為止。

6.3 用不到的 pipe 端一定要關

父側、子側沒用到的 end 不關,EOF 就傳不過去,結束條件就會崩。
原則很簡單,但在實務上還滿常翻車。

6.4 UseShellExecute=false 與 handle 繼承要釐清

要做標準 I/O 重新導向,.NETUseShellExecute=false 是前提。
Win32 端也一樣,要繼承什麼要盡量收斂。若保持 bInheritHandles=TRUE 全部繼承,很容易造成意外的 handle leak。

7. watchdog 要放在「外面」

放 watchdog 時最重要的事是 不要把它放進被監視對象的 Job 裡
worker 掉了要重啟,但連重啟的角色都一起死,那就沒意義。

7.1 exit 監測要用 wait handle

行程結束後 handle 會進入 signaled 狀態。
所以 exit 監測其實不需要用 polling loop 每 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,要先把 等待放哪裡 想清楚比較安全。

7.3 hang watchdog 需要 heartbeat

exit watchdog 用 process handle 就夠。
但 hang watchdog 不同:

  • CPU 100% 卡住
  • deadlock
  • event loop 活著但沒進度
  • 卡在等輸入

這些狀態「行程還活著嗎」單獨判斷不出來。若要看 hang,就要加:

  • heartbeat
  • progress sequence
  • last successful work timestamp
  • health probe

這類 應用層的存活檢查

7.4 負責重啟的要放被監視對象外

實務上常見兩種模式:

  • 父應用只是臨時啟動 helper
    • 父持有 Job,父結束就收走 helper tree
  • worker 要長期常駐,掛了要重啟
    • 由外部的 watchdog process / service 為每個 worker generation 建一個 Job

後者請 把 worker tree 與 restart authority 分開,設計才穩。

7.5 restart policy 要用 budget

一放 watchdog,下一個陷阱就是 crash loop:

  • 立即重啟
  • 立即又掛
  • 只留下巨量的 log

所以要:

  • backoff
  • 固定時段內的重啟次數上限
  • 連續失敗時停止並通知

restart budget 的概念來做。

8. 依典型情境的建議組合

情境 建議組合
桌面應用啟動單次性的 CLI helper 1 次啟動 = 1 個 Job。加上 KILL_ON_JOB_CLOSEstdout / 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 監控
想優雅地關掉 console 工具 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 機制」分開

9. 不該做的事

  • 以為 Kill(entireProcessTree: true) 就把 tree lifecycle 問題解掉
  • 維持 bInheritHandles=TRUE 全繼承
  • 先把 stdout 讀完再讀 stderr
  • pipe 的未使用端不關
  • 在 UI thread 用 WaitForSingleObject(INFINITE)
  • 把 watchdog 放進與被監視對象相同的 Job
  • 把 259 當成普通 exit code 使用
  • 把 Job completion port 的通知當作唯一真相

10. 總結

要在 Windows 應用上安全處理子行程,最有效的整理是:

行程樹誰擁有
結束請求怎麼傳
標準 I/O 怎麼流到完
watchdog 放哪裡

先把這 4 件事決定好。

在此之上,再粗略地說:

  • tree cleanup 的基準點是 Job Object
  • graceful shutdown 要依 GUI / console / worker 分別設計
  • stdio 要連同平行抽乾與 EOF 一起設計
  • watchdog 要放在被監視對象外,並用 wait handle 與 heartbeat 來看,不要 polling

CreateProcessProcess.Start 本身只是入口。
真正影響事故率的,是 結束責任的歸屬I/O 的徹底流完

11. 參考資料

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

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

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

作者檔案

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

Go Komura

小村軟體有限公司 代表

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

回到部落格一覽