macOS 的核心維護一張程序表(process table),每個存在的程序佔用其中一個格位,而格位總數有上限。佔用格位的條件是「核心還保留著這個程序的紀錄」,跟這個程序是否還在執行沒有關係——已經結束、但紀錄尚未被回收的程序同樣佔一格。這條區分是理解 fork 失敗的起點。fork 是 Unix 建立新程序的系統呼叫,任何開新程序的動作——執行一條指令、跑一個腳本、編譯器開一個子行程——最終都經過它;可用格位耗盡時它回傳失敗,訊息是 Resource temporarily unavailable,錯誤碼在 macOS 上是 35。看到這串字的時候,剩餘記憶體、CPU 使用率、以及正在執行的程序數量都可能完全正常。

兩道 fork 關卡與一道天花板

程序數量的上限由三個參數共同決定,但它們的執行時機分成兩處:其中兩個由核心在每次 fork 時檢查,第三個在 setrlimit 時把可設定的範圍壓住。

1sysctl kern.maxproc kern.maxprocperuid
2launchctl limit maxproc
3ulimit -u

一台 Apple Silicon Mac 上的實測輸出:

1kern.maxproc: 4000
2kern.maxprocperuid: 2666
3
4	maxproc     2666           4000
5
62666

fork 實際檢查的兩道是:全系統程序總數對 kern.maxproc,以及該 uid 的程序數對它自己的 RLIMIT_NPROC(soft limit,也就是 ulimit -u 讀到的那個值)。任一道不通過就回傳失敗。

kern.maxprocperuid 的執行點在第三處——setrlimit,也就是程序調整自己資源上限的那個系統呼叫(ulimit 這個 shell 指令是它的前端)。kern.maxprocperuid 規定 RLIMIT_NPROC 能被設到多高,因此它是天花板而非關卡:程序不會在 fork 的時候撞到它,而是在嘗試把自己的上限調高時被它壓住。kern.maxproc 由核心在開機時依機器規格決定,kern.maxprocperuid 在本機是前者的三分之二,目的是讓單一使用者的失控程式仍留給系統其他部分足夠的格位運作;這個比值不必記,每台機器直接讀 sysctl 就有。launchctl limit maxproc 印出的兩欄分別是 soft 與 hard limit,兩者都會被子程序繼承。

這個分工決定了調高上限的實際效果,而效果與直覺不同。把 ulimit -u 設成超過 kern.maxprocperuid 的值時,核心把它靜默壓回 2666,並且把 hard limit 一併降到 2666——而降低 hard limit 是單向不可逆的:

1python3 -c "
2import resource as r
3print('before:', r.getrlimit(r.RLIMIT_NPROC))
4r.setrlimit(r.RLIMIT_NPROC, (3000, 4000))
5print('set 3000 ->', r.getrlimit(r.RLIMIT_NPROC))
6"
1before: (2666, 4000)
2set 3000 -> (2666, 2666)

再嘗試設回 4000 會直接失敗(not allowed to raise maximum limit)——原本 4000 的 hard limit 額度就這樣消失了,恢復要重開 shell。

這裡還有一個讀數陷阱值得單獨記下。macOS 的預設 shell 是 zsh,而 zsh 的 ulimit builtin 回報的是它想設的值,不是核心實際持有的值:

1zsh:  ulimit -u 3000  ->  zsh 回報 soft=3000 hard=4000  |  核心持有 (2666, 2666)
2bash: ulimit -u 3000  ->  bash 回報 soft=2666 hard=2666  |  核心持有 (2666, 2666)

照著 zsh 的輸出判斷會得到「調高成功」的結論,而實際上限一點都沒動、hard limit 還少了三分之一。要確認核心持有什麼,讀 getrlimit 而非 shell builtin。

真正的天花板因此在 kern.maxprocperuid 這一層,調整它需要 root 權限。無論從哪一層調高,那個動作的作用都是延後撞牆的時間點,已經被佔住的格位一個都沒有因此釋放。

一般桌面使用的常駐程序數量落在幾百的量級,距離兩千多的上限有相當餘裕。當前用量可以直接量:

1echo $(( $(ps -u "$(id -un)" | wc -l) - 1 ))   # 扣掉表頭那一行

程序結束分成兩個階段

子程序呼叫 exit 的那一刻,它並沒有從程序表上消失。核心先釋放它持有的資源,再把一小塊紀錄留在原地等父程序來取——殭屍狀態就是這兩個動作之間的空檔,而觸發它們的是兩個不同的角色。

第一階段由子程序自己觸發。子程序呼叫 exit(或被信號終止)時,核心回收它的位址空間、開啟中的檔案描述子、以及其他持有的資源。這一階段結束後,這個程序已經不執行任何指令、不佔用記憶體、不排入 CPU 排程佇列。

第二階段由父程序觸發。核心在第一階段刻意保留一小塊紀錄,存放結束碼(exit status)與資源使用統計,因為父程序有權知道子程序是怎麼結束的。核心送出 SIGCHLD 通知父程序,父程序呼叫 wait()waitpid() 領取這塊紀錄,核心才釋放程序表格位。這個動作在 Unix 傳統上稱為收屍(reaping)。

處於兩階段之間的程序就是殭屍(zombie)。它在 psSTAT 欄位顯示為 Zcomm 欄位顯示為 <defunct>。設計良好的父程序在毫秒級內完成領取,殭屍狀態短到幾乎觀察不到;殭屍在列表裡穩定存在,代表第二階段沒有發生。

殭屍難以察覺的原因就在第一階段已經把大部分資源還回去了。它不佔使用者記憶體、不耗 CPU、沒有開啟的檔案,因此記憶體監控、CPU 監控、活動監視器的資源排行全部看不到它。它唯一消耗的是程序表格位,而程序表格位剛好就是 fork 那道 per-uid 關卡在計數的單位。一具殭屍的資源成本接近零,一千具的資源成本仍然接近零,但額度已經去掉超過三分之一。

父程序漏收的成因

第二階段沒有發生的原因集中在父程序這一側,常見的有三種。

未處理 SIGCHLD:父程序沒有安裝 handler,也沒有在主迴圈裡週期性呼叫 waitpid(-1, ..., WNOHANG)——WNOHANG 這個旗標讓呼叫在沒有子程序可收時立刻回來,因此它適合放在主迴圈裡不阻塞地掃一遍。核心送出的通知無人接收,紀錄就留著。

handler 存在但遲遲排不到執行:父程序卡在某個阻塞的系統呼叫、或主執行緒被長時間佔用,SIGCHLD 排隊等待處理。信號在傳統 Unix 語意下不排隊累積,短時間內多個子程序結束時,父程序可能只看到一次通知,若 handler 只呼叫一次 wait() 就會漏掉其餘的。正確的寫法是在 handler 內以迴圈呼叫 waitpid 直到回傳零或負值。

父程序長時間存活:這是讓漏收從瑕疵變成故障的放大條件。父程序若在幾秒內結束,殘留的殭屍會立刻被收走(機制見下節);父程序連續執行數天甚至數週時,同樣的漏收率會線性累積成上千具。

Unix 提供一個明示放棄結束狀態的機制:父程序把 SIGCHLD 設為 SIG_IGN,核心就直接回收子程序的紀錄、不再產生殭屍。應用程式若對子程序的結束碼沒有需求,這是最省事的做法。另一個常見的迴避手法是 double-fork——fork 出中介程序,中介程序再 fork 出真正的工作程序後立即結束,工作程序因此變成孤兒、由系統的 init 程序負責收屍。這個手法有一個容易漏掉的收尾:原父程序仍要對中介程序呼叫一次 waitpid。少了這一步,中介程序自己成為殭屍,一具換一具、什麼也沒省下。

孤兒的 reparenting

父程序先於子程序結束時,子程序成為孤兒(orphan),核心把它的 ppid 改成 1,交由 launchd 接手。這個過程稱為 reparenting。

launchd 是 macOS 的 PID 1,職責之一就是對收養來的子程序持續呼叫 wait()。這決定了一個實用的性質:終止一個堆積殭屍的父程序時,它底下的所有殭屍會在同一時間被 launchd 收養並立即收屍,格位一次全數釋放。回收量等於該父程序名下的殭屍總數,不需要逐一處理。

同一個性質也解釋了為什麼短命程式很少造成問題。一個執行三秒的腳本即使完全沒有收屍邏輯,它結束時所有遺留的殭屍都會被 launchd 清掉。殭屍的堆積需要「漏收」與「父程序長存」兩個條件同時成立。

格位耗盡時的表現

可用格位耗盡時,fork 回傳 EAGAIN。在 macOS 上這個錯誤碼是 35,對應訊息字串為 Resource temporarily unavailable

1python3 -c "import errno, os; print(errno.EAGAIN, os.strerror(errno.EAGAIN))"
135 Resource temporarily unavailable

錯誤碼的數值跨系統不同,這點會影響查表方向:Linux 的 EAGAIN 是 11,macOS 沿用 BSD 的 35。看到 os error 35 這串字時要對照的是 macOS 的表,用 Linux 的表查會得到 EDEADLK——而它的訊息字串是 Resource deadlock avoided,與 macOS 的 Resource temporarily unavailable 形狀相近、讀起來同樣像是「某項資源暫時有狀況」。查錯表的人因此不會察覺自己查錯了,而是被推去追鎖與死結。

額度見底之後,最先有反應的是那些每分鐘要開幾十次新程序的元件,而這個先後順序具有誤導性。已經在執行的常駐程序完全不受影響——瀏覽器、編輯器、正在跑的伺服器都照常運作,因為它們早就佔好了自己的格位。最先失敗的是高頻建立短命程序的元件:

shell prompt 的狀態查詢:像 gitstatusd 這類每次按下 enter 就查一次 repo 狀態的元件,每次都要 fork。

多層 shebang 的腳本#!/usr/bin/env -S uv run --script 這種寫法執行一次會疊起兩個並存的程序——直譯器管理工具,加上它開出來的直譯器本身。(env 不算在內:它用 execve 把自己替換掉,不多佔一格。)剩餘額度只有十來個時,同時要拿到兩格的呼叫是最先失敗的一批。

建置與測試工具鏈:編譯器、測試框架、任何會平行開子程序的工具。

失敗集中在這些元件上,容易讓人以為是該元件本身出了問題。判準是:失敗的操作彼此在功能上沒有關聯,唯一的共同點是都需要新開程序。符合這個形狀時,先量格位。

診斷順序

診斷用三個固定成本的查詢完成,每一步的輸出直接決定下一步,中間不需要推測。

先提一件反直覺的事:這些指令自己也要 fork。ps | sort | uniq -c 這種管線一次要開三到四個程序,而執行它的時機正好是格位最緊的時候。額度已經見底到指令跑不起來時,順序要倒過來——先從候選集裡關掉一個最大的常駐程式(模擬器、虛擬機),把格位騰出來,再回頭做診斷。關掉的那個若正好是元凶,殭屍會一併消失,這本身就是答案。

第一步量總額,確認格位確實見底:

1echo $(( $(ps -u "$(id -un)" | wc -l) - 1 ))   # 目前程序數
2ulimit -u                                       # 上限

第二步分類,判斷佔額度的是活程序還是殭屍:

1ps -u "$(id -un)" -o stat | grep -c '^Z'

範圍要限定在同一個 uid。配額是 per-uid 的,而 ps -eo stat 掃的是全系統——兩個數字並置比較時基準不同,會把別的使用者(含系統服務)的殭屍算進自己的帳上。

殭屍數若接近第一步的總數,方向就確定了。殭屍數接近零而總數仍逼近上限時,代表確實有那麼多活程序在跑,這時改依執行檔聚合,看是哪一支開出了幾百份:

1ps -u "$(id -un)" -o comm | sort | uniq -c | sort -rn | head -10

正常的機器上這份清單的頭幾名是 shell、瀏覽器的 renderer、編輯器的 language server,數量落在數十。某一支衝到三位數就是那條線索——常見的是重複啟動的開發伺服器(每次熱重載留下一份舊的)、平行度設定過高的測試套件,或退出時沒有清乾淨的容器。

第三步指認父程序,把所有殭屍依 ppid 聚合:

1ps -u "$(id -un)" -o ppid,stat,comm | awk '$2 ~ /^Z/ {print $1}' | sort | uniq -c | sort -rn | head -5

輸出的每一行是「殭屍數量 + 父程序 PID」。一台已經撞到上限的機器上,這行指令的輸出可能只有一行:

12008 89668

聚合結果集中在單一 PID 時,父程序已經確定,剩下的只有查它的身分:

1ps -o pid,ppid,etime,command -p <ppid>

一個實測例子的輸出:

1  PID  PPID     ELAPSED COMMAND
289668     1 10-05:12:33 qemu-system-aarch64 -netdelay none -netspeed full -avd Medium_Tablet_2

ELAPSED 欄位讀作十天五小時十二分,這個數字補上了時間維度:殭屍是十天內線性長出來的,跟查詢當天執行了什麼操作沒有關係,只是當天正好跨過上限那條線。累積型故障的共同特徵就是發作時機與觸發原因在時間上完全脫鉤,ELAPSED 是把兩者接回去的欄位。

ppid 聚合值得排在任何因果推測之前,理由是成本結構:它是一行指令的定值查詢,直接指認父程序,不依賴關於「誰在耗資源」的任何假設。當一個定值查詢能取代一串推論時,先跑查詢。

這條順序有一個超出 macOS 的理由。本文案例裡的模擬器十天內 fork 了兩千多次、平均每小時八次,遠少於 hook 的呼叫頻率——它之所以能吃掉四分之三的額度,是因為這兩千多次沒有一次歸還。佔用量是申請頻率乘上持有時間,而申請頻率的差距以倍數計、持有時間的跨度沒有上界,於是佔用量的排序由持有時間主導,報錯的位置卻只跟申請頻率有關。檔案描述子、連線池、鎖與 rate limit 額度上都存在同一組落差,跨資源的歸因順序與判讀徵兆整理在 #252 配額耗盡的症狀落在申請最頻繁的元件、成因在持有最久的那個

回收格位的手段

要拿回那些格位,動作全部落在父程序身上——殭屍自己已經沒有可以被操作的部分。對殭屍本身送信號沒有作用——信號的生效前提是接收方會再被排程執行一次,而殭屍在第一階段就已經停止執行,kill -9 送出後沒有任何指令會因此被執行。

催父程序去領

1kill -CHLD <parent-pid>

這會補送一次 SIGCHLD。父程序若有 handler 但錯過了先前的通知,可能因此開始回收。父程序沒有安裝 handler、或卡在阻塞呼叫時,這一步沒有反應,成本是一行指令,值得先試。

終止父程序

1kill <parent-pid>          # 先給正常關閉的機會
2kill -9 <parent-pid>       # 未退出時再強制

父程序結束後,reparenting 讓 launchd 一次收完所有殭屍。這是回收量最大、也最確定的手段。父程序是有使用者介面的應用程式時(模擬器、虛擬機、開發工具),從介面正常關閉的效果相同,且能讓它完成自己的清理流程。

重新開機同樣有效,但它是「終止父程序」的高成本超集。三步診斷已經指認出父程序的情況下,重開機只是把附帶損失擴大到整台機器。

哪類程式會累積殭屍

會累積殭屍的程式有一組共同特徵:長時間常駐、且在生命週期內反覆建立子程序而沒有確實收屍。這組描述對應前面那兩個必要條件——後半句是「漏收」,前半句是「父程序長存」;反覆建立子程序本身只是讓漏收有機會反覆發生,正確收屍的程式同樣頻繁 fork、而格位用完就還。前面那個 qemu 就是這個形狀的典型:模擬器為了周邊功能反覆開子程序,而它被設計成開著就不關。同一個形狀在開發環境裡還有幾個常見的落點——開發伺服器與檔案監看器、language server、長跑的容器 runtime,共同處境都是「本來就設計成一直開著」,於是再低的漏收率也終究會累積到上限。

日常檢查的成本是一行指令。寫成只在超線時才輸出,就可以掛進週期任務或 shell 啟動檔——門檻要能主動找上人,否則它只是事後判讀的參考值,而事後才去量的時候故障已經發生了:

1z=$(ps -u "$(id -un)" -o stat | grep -c '^Z'); p=$(( $(ps -u "$(id -un)" | wc -l) - 1 )); l=$(ulimit -u)
2[ $z -gt $((l/100)) ] || [ $p -gt $((l*7/10)) ] && echo "procs $p/$l, zombies $z"

判讀門檻用比值寫、不用絕對數,因為上限隨機器規格變動(本機 2666,別台機器可能是別的數)。兩條參考線:

殭屍數超過上限的百分之一(本機約 27 具)就值得去找父程序是誰。個位數的殭屍是正常的瞬時狀態——父程序還沒輪到處理 SIGCHLD;累積到這個量級代表某個父程序的回收邏輯有缺陷,而它會繼續累積下去。這條線的依據是「正常狀態的殭屍數不隨時間成長」,因此門檻設在哪都行,只要它明顯高於瞬時波動。

第二條線看總數:上限的七成(本機 1866)。這條線刻意留得寬,而且它沒有精確的推導——剩下的三成在本機是 800 格,遠多於一次建置或一輪測試所需的數十到上百個短命程序。寬是有理由的:撞到這條線的代價只是提早查一次,而撞到真正上限的代價是建置中斷、測試莫名其妙掛掉,且那些失敗不會說出自己是資源不足。

要把這條線收窄成有依據的值,在自己最重的工作型態下量一次峰值即可——建置或測試跑到最忙時讀一次 ps -u "$(id -un)" | wc -l,把那個讀數當作必須保留的餘裕。平行度開得高的測試套件、本機同時跑好幾個容器、或一邊編譯一邊跑模擬器,量出來的峰值都會比上面那個範圍大。

操作面最有效的預防是把用完的模擬器與虛擬機關掉。長跑的 Android 模擬器另有一組完全不同的表現層症狀——半活狀態會讓 flutter devices 掛在 device property 查詢上,判讀訊號與恢復順序見 flutter devices 卡住的訊號。兩者的來源同屬長跑程序,但一個表現為單一工具的查詢逾時,一個表現為全使用者範圍的 fork 失敗。

模擬器與虛擬機同時也是磁碟空間的大戶,常駐成本在空間與程序表兩邊各記一筆,磁碟側的排查流程見 磁碟空間診斷流程