題幹與適用情境
某 Linux 服務會反覆啟動執行時間很短的輔助行程。新工作開始間歇出現 fork: Resource temporarily unavailable。CPU 與常駐記憶體看起來正常,ps 卻顯示同一父行程下有數千個 Z 狀態的子行程。若服務在容器內執行,pids.current 已接近 pids.max,pids.events 裡的 max 計數也持續增加。
請解釋殭屍行程是什麼,證明未回收的子行程是否造成建立行程失敗,盡量在不重新啟動主機的情況下恢復服務,並提出長期修復。答案必須同時涵蓋一般主機,以及應用程式可能作為 PID 1 執行的容器。題目中的數量和部署型態是面試假設;即使已看到大量殭屍行程,仍要排查其他會讓 fork() 回傳 EAGAIN 的限制。
本題歸入 general,因為核心能力是 Linux 行程生命週期與故障診斷。目前公開 Linux 面試資料仍直接考察殭屍行程與孤兒行程的差異,Linux 與 Docker 官方資料則提供可核驗的執行邊界。這些證據支持它是一道具長期準備價值的代表性題目,但不能據此宣稱公司歸屬或面試頻率。
面試官考察重點
第一,候選人能否把行程狀態和資源症狀分開。殭屍行程已結束執行;在父行程透過 wait 系列呼叫領取結果前,核心仍保留 PID、結束狀態和資源統計。它不會繼續消耗一般 CPU,也不會保留已結束行程的完整使用者空間記憶體,但仍占用有限的行程表或 PID 槽位。
第二,能否找到真正的責任方。通常需要修復的是仍存活、建立了子行程卻沒有回收它們的父行程。對殭屍行程送出 SIGKILL 不能讓它「再結束一次」,外部 shell 也不能替另一個父行程呼叫 wait()。採取動作前要確認父行程、監管行程、部署單元和子行程管理程式碼。
第三,能否建立完整因果鏈。fork() 回傳 EAGAIN 可能來自使用者層級 RLIMITNPROC、系統層級 threads-max、pidmax,也可能來自 cgroup 的 pids.max。大量殭屍是強證據,但不能取代限制值、事件計數和時間軸驗證;存活執行緒與其他行程也可能消耗同一上限。
最後,修復是否涵蓋突發結束、例外路徑、停機和容器。每收到一次 SIGCHLD 只呼叫一次 waitpid() 並不可靠,因為信號可能合併。父行程必須一次排空所有已結束的子行程;容器還需要一個能正確接管並回收孤兒後代的 PID 1。
回答前需要釐清的問題
- 故障範圍在哪裡? 整台主機、一個使用者或一個容器觸頂,會對應不同限制和影響範圍。
- 精確錯誤與系統呼叫是什麼?
EAGAIN更像行程或執行緒數限制;ENOMEM或應用層拒絕需要另一條排查路徑。 Z狀態是否集中在一個 PPID? 一個占絕大多數的 PPID 指向明確責任方;多個 PPID 可能揭露共用包裝腳本或容器 init 模式問題。- 父行程是否存活、健康並受監管? 存活父行程可以修復或安全重新啟動;父行程結束後,子行程會交給最近的 subreaper 或命名空間 init,後者必須負責回收。
- 應用程式是否在容器內作為 PID 1? PID 1 還承擔孤兒行程回收責任,執行環境可能需要插入小型 init。
- 能否先排空流量或暫停工作入口? 停止新增 fork 後再受控重新啟動,才能保護進行中的工作和剩餘 PID 餘量。
- 業務是否需要子行程的結束結果? 結束碼、錯誤輸出和重試決策會影響阻塞等待、事件迴圈、工作池或語言執行環境 API 的選擇。
30 秒回答框架
「殭屍行程已經結束,只是父行程還沒有領取結束狀態。我會統計 Z 狀態並按 PPID 彙整,檢查主要父行程,再把殭屍成長時間軸和 fork 失敗對照。同時檢查 RLIMITNPROC、threads-max、pidmax,以及 cgroup 的 pids.current、pids.max 和 pids.events,因為 EAGAIN 有多個來源。kill -9 無法修復殭屍;我會先停止新增工作、排空流量,再安全重新啟動或修復父行程,讓正常運作的 subreaper 或 PID 1 接管並回收。長期方案是父行程收到結束通知後,循環執行非阻塞 waitpid(-1, ...),直到沒有已結束子行程,同時補齊例外和停機路徑。容器內還要在應用程式無法承擔 PID 1 職責時使用適當的 init,最後以突發結束壓測確認殭屍數和 PID 使用量維持有界。」
分步深入解答
第一步:確認狀態並找到責任父行程
PID 餘量已經很少時,先用不會建立大量額外行程的命令:
ps -eo pid=,ppid=,stat=,etime=,comm= | awk '$3 ~ /^Z/'
ps -eo ppid=,stat= | awk '$2 ~ /^Z/ { count[$1]++ } END { for (p in count) print count[p], p }' | sort -nrps 中以 Z 開頭的狀態代表殭屍;/proc/PID/stat 也會提供狀態 Z 和 PPID。不要只相信可能被截斷的行程名稱,要用 PPID 檢查仍存活的父行程:
ps -o pid=,ppid=,stat=,lstart=,etime=,cmd= -p PARENT_PID
cat /proc/PARENT_PID/status
cat /proc/PARENT_PID/limits記錄殭屍總量、成長速度、父行程部署版本和首次失敗時間。受控停機時短暫存在且數量穩定的殭屍,和每處理一個工作就繼續成長的洩漏,處理優先級不同。
第二步:證明究竟是哪項限制拒絕新行程
不能因為錯誤文字包含「Resource temporarily unavailable」就直接判斷記憶體不足。Linux 文件列出多個會讓 fork() 回傳 EAGAIN 的限制:
- 目前真實使用者的
RLIMIT_NPROC; /proc/sys/kernel/threads-max;/proc/sys/kernel/pid_max;- 實際生效的 cgroup PIDs 上限。
在 cgroup v2 中,應先找出服務真正所在的 cgroup,再讀取對應檔案:
cat /proc/PARENT_PID/cgroup
cat /sys/fs/cgroup/SERVICE_CGROUP/pids.current
cat /sys/fs/cgroup/SERVICE_CGROUP/pids.max
cat /sys/fs/cgroup/SERVICE_CGROUP/pids.events若 pids.events 的 max 計數在報錯時增加,可以直接證明建立工作碰到 PIDs 控制器上限。還要把 pids.current 和殭屍數、存活行程數及執行緒數對照;控制器會統計後代層級的工作,不能把每個槽位都歸因於殭屍。主機環境還需比較單一使用者工作數、系統工作總數及其限制。殭屍成長、剩餘 PID 餘量、EAGAIN 和父行程建立速率在時間上吻合時,因果鏈才完整。
第三步:解釋常見「修復」為何無效
kill -9 ZOMBIE_PID 無法執行結束邏輯,因為目標早已結束。剩餘核心記錄只有在父行程或後來的接管者等待它時才會消失。shell 的 wait 內建命令也只能管理該 shell 自己的子行程。
直接提高 pids.max、pidmax 或 RLIMITNPROC 可以暫時爭取恢復窗口,但洩漏仍在繼續,下一次故障的影響範圍反而可能更大。任意終止其他存活行程只能騰出槽位,不能修復父行程。重新啟動主機會清空行程樹,卻同時失去證據並造成不必要停機。
還要區分 D 狀態:不可中斷睡眠中的行程仍然活著,只是在核心中等待;即使 SIGKILL 暫時看似無效,它的診斷路徑也和已結束的 Z 狀態不同。
第四步:透過受控父行程切換恢復服務
先停止或限流新工作,避免故障父行程繼續耗盡剩餘槽位。保留至少一個管理工作階段,並在改變狀態前收集父行程日誌、/proc 資訊、限制值、事件計數和部署版本。
若父行程提供經過驗證、可重建子行程管理迴圈的 reload,優先使用;否則排空進行中的工作,透過監管程式重新啟動父行程。父行程結束後,未回收後代會交給最近的 child subreaper 或 PID 命名空間 init;正常接管者會等待它們。重新開放入口前,確認殭屍數確實下降。
若接管者也不回收,只重新啟動工作行程無法完成恢復。主機上要檢查服務監管程式或 subreaper;容器中可能必須受控替換整個容器,因為命名空間 PID 1 承擔最終責任。只有在同時限流並備妥修復版本時,才把提高 PID 上限當成有記錄的暫時措施。
第五步:實作能涵蓋突發與例外的回收邏輯
父行程需要阻塞等待某個已知子行程時,應呼叫 waitpid(child_pid, ...) 並處理信號中斷。非同步父行程可以讓 SIGCHLD 喚醒事件迴圈,再排空所有已完成狀態:
for (;;) {
pid_t pid = waitpid(-1, &status, WNOHANG);
if (pid > 0) {
record_child_result(pid, status);
continue;
}
if (pid == 0) break;
if (errno == EINTR) continue;
if (errno == ECHILD) break;
report_wait_error(errno);
break;
}這段迴圈應在一般事件迴圈情境執行。原始信號處理器只能執行目標執行環境允許的信號安全操作,常見作法是設定旗標或寫入 self-pipe。必須循環排空,因為多個子行程可能在一次可觀察通知前已經結束。建立成功但註冊失敗、逾時、取消、父行程停機和重試路徑都要涵蓋。在受管理的執行環境中,等價原則仍是 await 或依該執行環境規定的方式消費每個子行程完成結果。
明確忽略 SIGCHLD 或使用 SA_NOCLDWAIT,在支援的平台上可要求自動清理,但一般結束狀態也無法再按原方式收集,並有可攜性和 API 語意代價。這只能用於業務確實不需要子行程結果的設計,不能取代工作行程管理器的完成記帳。
第六步:把容器 PID 1 與驗證納入修復
容器主行程負責管理自己啟動的行程,也可能接管後代。若應用程式作為 PID 1 時不能正確轉發信號與回收行程,應使用執行環境提供的小型 init,例如 Docker 的 --init 或 Compose 等價設定。init 不能取代應用程式等待自己直接建立且需要結果的子行程;它解決的是 PID 1 職責與孤兒接管缺口。
修復後的壓力測試應超過原始並行量,同時注入子行程失敗和快速結束。驗收條件包括:
- 每個啟動的子行程都有且只有一個完成狀態被消費;
- 每輪突發後殭屍數回到零或約定的短暫上限;
pids.current能下降,pids.events不再出現新的限制命中;- 預期負載下不再出現
fork()或 spawn 的EAGAIN; - 停機流程會結束或排空子行程,並完成回收;
- 父行程崩潰後,後代會交給經過驗證的 subreaper 或 PID 1;
- 殭屍成長率和剩餘 PID 餘量告警能在工作建立失敗前觸發。
高品質示範回答
「我會先確認狀態欄位確實以 Z 開頭,按 PPID 彙整,再檢查占比最高且仍存活的父行程。殭屍行程已經執行結束,所以 CPU 和 RSS 正常並不矛盾。父行程呼叫 wait 系列函式前,核心會保留它的 PID 和結束狀態。因此,對殭屍執行 kill -9 沒有用,修復對象是父行程。
「接著我會證明 fork 失敗碰到哪項限制。父行程端檢查 RLIMITNPROC,主機檢查 threads-max 和 pidmax,服務 cgroup 檢查 pids.current、pids.max 和 pids.events。若報錯同時 max 計數增加且 cgroup 接近上限,就證明這一層原因;但我仍會統計存活執行緒和後代工作,避免把所有槽位都算成殭屍。
「恢復時先限流新工作、保留診斷證據並排空工作,再透過監管程式 reload 或重新啟動父行程。它留下的殭屍應由正常的 subreaper 或命名空間 PID 1 接管並回收。若故障接管者就是容器 PID 1,我會用啟用 init 的設定替換容器。提高上限只作為暫時餘量。
「永久修復是消費每個子行程結果。同步情境等待指定 PID;事件驅動情境把 SIGCHLD 當作喚醒信號,循環非阻塞 waitpid(),直到沒有已完成的子行程,同時涵蓋建立失敗、取消和停機。我會壓測快速結束、強制失敗、父行程崩潰與優雅停機;完成結果一一對應、殭屍維持有界、PID 使用量下降且不再出現限制事件,才算通過。」
常見錯誤
- 逐一對殭屍執行
kill -9→ 子行程早已結束,信號無法領取結束狀態 → 定位並修復、reload 或重新啟動父行程/接管者。 - 把它稱為記憶體洩漏 → 殭屍保留的是核心記帳資訊,不是已結束行程的完整位址空間 → 描述為行程表或 PID 耗盡,並測量真正觸頂資源。
- 從一次
ps快照斷言 cgroup 耗盡 →EAGAIN有多個限制來源,存活執行緒也占工作容量 → 對照限制、事件計數、工作數與時間軸。 - 每次信號只呼叫一次
waitpid()→ 子行程結束信號可能合併,仍會留下其他未領取狀態 → 非阻塞循環直到沒有可回收子行程。 - 把提高
pids.max當成長期修復 → 故障父行程仍持續洩漏槽位 → 只在恢復期增加餘量,並同步限流與部署修復。 - 加入 init 後就不管理直接子行程 → 應用程式仍擁有直接子行程的結果與重試語意 → 應用程式等待直接子行程,init 負責 PID 1 與接管後代。
- 收集證據前立即重新啟動 → 現場消失後無法證明限制和故障程式碼路徑 → 餘量允許時先記錄 PPID、限制、計數、版本、成長速率和日誌。
追問及應對
如果父行程在業務尖峰不能重新啟動怎麼辦?
先停掉洩漏路徑:關閉對應工作類型、降低並行,或把流量切到健康副本。政策允許時,只把有效 PIDs 上限提高到能保留管理和健康檢查餘量的程度。監控成長斜率並保守估算耗盡時間。外部行程不能替存活的父行程等待其子行程;如果父行程沒有受支援的線上修復動作,就必須在暫時餘量用完前安排受控切換。
如果殭屍只出現在容器裡怎麼辦?
在 PID 命名空間視角確認 PID 1 和殭屍 PPID,並從主機或編排平台核對服務的 cgroup 路徑與 PIDs 計數。若應用程式本身是 PID 1 卻沒有回收和信號轉發能力,部署小型 init;若包裝腳本是 PID 1,要確認它不會提前結束或只等待一個子行程。測試容器終止時,信號必須抵達應用程式,子行程結束,並在寬限期結束前收完所有狀態。
為什麼一次 SIGCHLD 不等於一個子行程結束?
傳統信號是通知,不是一個結束對應一筆記錄的持久佇列。父行程處理信號前可能已有多個子行程結束,通知也可能合併。穩健契約是「收到通知後檢查子行程狀態」,再反覆非阻塞等待,直到沒有已完成子行程;應用程式對每個回傳 PID 只記帳一次。
把 SIGCHLD 設成 SIG_IGN 能否解決?
在 Linux 上,明確忽略 SIGCHLD 或設定 SANOCLDWAIT 會改變殭屍行為,但應用程式也不能再依賴一般 wait() 收集結束狀態。預設處置文字標為「ignore」,與應用程式明確安裝 SIGIGN 並非相同語意。只有子行程結果確實沒有業務價值,且已核對語言與執行環境契約時才使用;工作行程管理器通常仍需明確完成記帳。
如何在使用者遇到 fork 失敗前告警?
同時監控按父行程彙整的持續殭屍成長率、cgroup 剩餘 PID 餘量、pids.events:max 增量和 spawn 失敗數。結合工作吞吐判斷,避免合法短時突發只憑總量觸發告警。為監管、遙測和恢復命令保留餘量,並在非正式環境命名空間以受控洩漏驗證告警是否及時。