fork() 先把父 process 整個位址空間複製一份,然後下一行的 exec() 立刻把它丟掉——你付了搬家費,只為了搬進門就放火燒掉。Mateusz Guzik 的結論很乾脆:「The entire fork + exec idiom is terrible and needs to be retired。」問題是,要拿什麼換它,社群桌上攤了四份草圖。
Linux 想退役 fork()+exec()——四條取代路線攤在桌上
fork()+exec() 是 Unix 最古老的程序建立慣用法,老到它的設計前提——「process 是 single-threaded、位址空間很小、複製很便宜」——在今天幾乎條條都不成立。LWN 這篇整理把 Linux 社群想退役這個 idiom 的動機與四條替代路線攤開來談:spawn templates、原生 posix_spawn()、Christian Brauner 的 pidfd builder、Josh Poimboeuf 的 io_uring spawn。它們不是彼此競爭的同一個 API 的四種寫法,而是把「建立一個全新 process」這件事拆在不同抽象層、各自換掉一段機制。這篇要做的事是把每條路線換掉哪一段、API 長什麼形狀、代價落在哪裡講清楚。
先把問題定位精準。Guzik 點出一個容易被忽略的事實:「the focus of the patch set was a bit strange in that it left the fork() part of the problem untouched. That is where most of the cost lies。」也就是說,大家直覺以為「建立 process 慢是因為 exec 要載入新執行檔」,但實測下成本大頭在 fork 階段——複製 page table、複製 VMA、複製 fd table、跑 pthread_atfork handler。exec 反而相對便宜。任何只優化 exec 而不碰 fork 的方案,都在優化錯的那一半。下面這張圖把這個成本不對稱畫出來。
drag the slider to set parent VMA/fd count · 2 cost curves
fork 階段(複製 page table / VMA / fd table)的成本隨父 process 的 VMA …
fork 成本隨父 process 的 VMA 與 fd 數量線性放大,exec 成本幾乎與父 process 規模無關。
圖裡那條陡峭上揚的是 fork 成本:父 process 每多一個 VMA、每多一個 open fd,fork 就要多複製一份 metadata,成本線性放大。一個重型多執行緒服務——比方一個 JVM、一個載了上千個動態模組的 Python 程序——位址空間裡輕易就有上萬個 VMA、幾千個 fd。對這種 process,「fork 自己再 exec」要付的複製成本可以遠超過新執行檔本身的載入成本。而 exec 那條線幾乎是平的:載入新執行檔的成本只跟新執行檔有關,跟父 process 多大無關。這就是 Guzik 說「成本大頭在 fork」的具體意思。
fork() 在多執行緒下的根本崩壞
傳統上 fork 的高成本由 copy-on-write(COW)緩解:fork 不真的複製記憶體頁,只把父子的 page table 都標成 read-only,等任一方寫入時才複製那一頁。問題是 COW 緩解的是「資料頁複製」,沒緩解「page table 與 VMA metadata 複製」——後者必須在 fork 當下就全部建好,這正是隨 VMA 數量線性放大的那一段。而且如果你 fork 完馬上 exec,連 COW 都是浪費:你建好了整套 read-only 映射,一頁都還沒寫,exec 就把它全部拆掉。
vfork() 是針對「fork 後立刻 exec」設計的逃生口:它用 CLONE_VFORK | CLONE_VM 建立子 process,子與父共用同一個位址空間(不複製 page table),並把父執行緒掛起,直到子 exec 或 exit 才喚醒。這省掉了位址空間複製,但它是個 footgun——子在 exec 之前跑在父的記憶體上,任何對 stack 或 global 的寫入都會污染父 process。posix_spawn() 的 glibc 實作正是建立在 vfork+exec 之上,把這段危險窗口包在標準庫裡,使用者看不到。但 Guzik 的批評是:vfork「leaves the fork() performance problem untouched」——它繞過了複製,卻沒解決「為什麼要從一個既有 process 複製狀態」這個根本問題。他主張「creating a pristine process is the way to go」——直接建一個乾淨的空 process,而不是從父 process 拷貝再丟掉。
多執行緒讓事情更糟。POSIX 規定 fork 只複製呼叫 fork 的那一條執行緒到子 process,其他執行緒全部消失。如果某個消失的執行緒當時正持有一把 malloc 內部的鎖,子 process 就繼承了一把永遠不會被釋放的鎖——子裡任何 malloc 呼叫直接死鎖。這不是理論上的角落案例:glibc 的 allocator、log 函式庫、許多 C++ runtime 都在內部用鎖,子 process 在 fork 與 exec 之間如果碰到任何一個,就可能整個卡住。標準對這段的規定本身就很嚴苛——fork 之後在子 process 裡,你只能呼叫 async-signal-safe 的函式,連 printf 都不在白名單上。這正是為什麼「fork 完盡快 exec、中間什麼都別做」是唯一安全的寫法,而這個寫法又恰好證明了 fork 把整個位址空間複製過來毫無意義。
pthread_atfork() 提供 prepare / parent / child 三段 handler 想救這個局面:fork 前依序取得所有相關鎖、fork 後在父子兩邊依序釋放,讓子 process 繼承的是一組「已知處於一致狀態」的鎖。但 Guzik 指出,當一個程序連結了上千個各自註冊 atfork handler 的模組,handler 之間的鎖序(lock ordering)幾乎不可能保證無環——一個模組的 prepare handler 拿鎖 A 再拿鎖 B,另一個拿 B 再拿 A,fork 就 deadlock 在 prepare 階段,連 exec 都還沒走到。更糟的是這個 deadlock 跟註冊順序、跟當下哪些執行緒在跑哪段程式碼有關,是典型「在 CI 跑不出來、上 production 偶發」的那種 bug。「mixing threading and fork」在實務上被視為不受支援,但現實是幾乎每個非平凡的程式都同時用了執行緒與某種「叫一個外部命令」的需求。下面這個 scrubber 把 vfork 那段「借來的位址空間」窗口逐刻拆開看——注意它要在這麼受限的條件下完成 fd、signal、cwd 全部設定。
drag the handle along the vfork window · 5 phases
互動圖表
vfork 子 process 在 exec 前跑在父的記憶體上,任何寫入都會污染父,設定 fd 只能用 async-signal-safe syscall。
spawn templates:被拒的「快取一份模板」路線
Li Chen 提的 spawn templates 是四條路線裡唯一已經寫成補丁、又被明確拒絕的一條。它的觀察很實際:很多程式會反覆 spawn 同一個執行檔——Git 在一次操作裡可能 spawn 同一個 helper 上百次、build 系統反覆叫同一個 compiler。如果每次都從頭走 fork+exec,那些「對同一執行檔每次都一樣」的設定就被重做了無數遍。template 的想法是把這些設定預先打包成一個可重用的物件。
API 是兩個新 syscall:spawn_template_create() 接受一個 spawn_template_create_args 結構(欄位含 flags、execfd、exec_flags、filename),建立一個快取的模板;spawn_template_spawn() 用這個模板反覆啟動 process。模板上可以掛一串 spawn_template_action——CLOSE、檔案 dup、open、改 directory、signal 處理——描述每次 spawn 要做的 file action。實測效能提升「approximately 2%」。這個數字本身就是它被拒的一部分原因:~2% 對「需要新增兩個 syscall 與一整套 template lifecycle 管理」的複雜度來說太薄。Christian Brauner 的評語是留了餘地的——「The idea of having a builder api for exec isn't all that crazy」——但他不認為 template 是對的形狀。更要命的是,spawn templates 跟前面那張圖指出的問題正面撞上:它優化的是 exec 那一段(重用執行檔載入設定),而成本大頭在 fork。Li Chen 最後也同意 Brauner 的 pidfd 路線「seemed better」,並表示未來會往那個方向走。
下面把四條路線並排——每條換掉的是 process 建立流程裡的哪一段機制、API 入口在哪、致命弱點是什麼。
click any route to read its mechanism + weakness · 4 routes
四條取代 fork+exec 的路線——各自換掉哪一段機制
click any route above
① spawn templates · Li Chen(已拒)
機制:spawn_template_create() 接 spawn_template_create_args(flags / execfd / exec_flags / filename)建快取模板,spawn_template_spawn() 反覆啟動。模板掛一串 spawn_template_action(CLOSE / dup / open / chdir / signal)。
弱點:實測僅約 2% 提升,卻要兩個新 syscall 加整套 template lifecycle。且它優化的是 exec 段,成本大頭在 fork——優化錯了那一半。Li Chen 最終同意 pidfd 路線更好。
② native posix_spawn() · 社群偏好的形狀
機制:posix_spawn() 是 POSIX 標準的 fork+exec 合一 API,目前 glibc 用 vfork+exec 在 userspace 實作。把它變成 kernel 原生 syscall,就不再有 vfork 那個共用位址空間的危險窗口藏在標準庫裡。
弱點:反方意見問——現有 vfork+posix_spawn 是否已經夠用?要證明原生版本值得,需要具體 benchmark,而不只是「fork 很糟」的直覺。
③ pidfd builder · Christian Brauner(社群偏好)
機制:擴充 pidfd_open() 建立一個空 process(pidfd 在手但還沒「成形」),再用新的 pidfd_config() syscall 一步步配置它——設 fd、signal、cwd、執行檔。形狀類比 fsconfig():先 fsopen 拿 fd、再用一串 fsconfig 設 mount 選項、最後 fsmount。是 Guzik「建乾淨 process」主張的 API 化身。
弱點:全新的 syscall 表面與 lifecycle 語意,要把「半成形 process」的所有狀態轉換定義清楚——比 template 大得多的設計工程。
④ io_uring spawn · Josh Poimboeuf
機制:用一個 syscall 把執行檔載入一次,後續的 spawn 動作全部用 io_uring 的 ring 下達——sqe 描述「收下這些 fd、把它們裝好」,整串以 exec 收尾。若 ring 在沒 exec 的情況下結束,那個半成形 process 被 SIGKILL。
弱點:把 process 建立綁進 io_uring 的 sqe 模型,語意新穎但偏離傳統 process API 直覺;錯誤處理(ring 中途失敗)的清理路徑要很小心。
互動圖表
四條取代路線中,spawn templates 因只優化 exec 段而被拒,pidfd builder 直接從乾淨 process 建立是社群偏好方向。
native posix_spawn():把標準 API 沉進 kernel
第二條路線最保守,也是社群裡阻力最小的方向:與其發明新 API,不如把已經存在的 POSIX 標準 posix_spawn() 變成 kernel 真正的原生 syscall。今天 posix_spawn() 不是 syscall——它是 glibc 在 userspace 用 vfork+exec 拼出來的 library function。前面那個 scrubber 拆開的危險窗口,就是這個拼裝藏在標準庫裡的東西。把它沉進 kernel 的好處是:kernel 可以在自己的位址空間裡安全地建立子 process 的 fd table、signal mask、cwd,完全不需要「子在父的記憶體上小心翼翼地不寫任何東西」那段表演。
posix_spawn() 的 API 形狀對使用者來說已經定義好了:一個 spawn 呼叫,加上兩組描述子——file actions(一串 dup2 / close / open)與 spawn attributes(signal mask、process group、scheduling)。把它原生化,是把這兩組描述子直接交給 kernel 解釋,而不是在 userspace 翻譯成一連串「在 vfork 窗口裡能安全執行的 syscall」。值得注意的是,glibc 內部正是用 clone3() 配上 CLONE_VFORK | CLONE_VM 來實作 posix_spawn 的——它已經盡力避開了 full fork,並在那個共用位址空間的窗口裡小心地完成所有 file action。換句話說,posix_spawn 今天就已經是「最不糟」的選項,這也是為什麼有人會問:把它再往下推進 kernel,邊際收益到底有多大?
這條路線於是遇到最直接的反方意見:現有的 vfork+posix_spawn 真的不夠用嗎?如果 glibc 已經把危險窗口包得夠好、效能也可接受,那麼「原生化」要證明自己值得,需要拿出具體的 benchmark 數字——展示原生 syscall 在多執行緒、大位址空間的情境下比 vfork 拼裝快多少,而且要快到值得新增 kernel 維護面。這個質疑跟 spawn templates 那 ~2% 的命運是同一條規則在作用:kernel 社群對「新增 syscall 表面」的門檻很高,任何提案都得先證明既有路徑的瓶頸是真的、而且新路徑能顯著拉開差距。在那個數字出現之前,原生 posix_spawn 停在「聽起來合理、但還沒被需求逼出來」。
pidfd builder:Brauner 的 fsconfig 式組裝
Brauner 的路線是四條裡最有結構野心的,也是 Li Chen 最終認同的方向。它的核心是把「建立 process」變成一個 builder pattern:不是一個原子的大呼叫,而是「先拿到一個 handle、再一步步往上配置、最後敲定」。具體三步——擴充 pidfd_open() 建立一個空的 process(你拿到一個 pidfd,但這個 process 還沒成形、還沒跑任何程式碼),用一個全新的 pidfd_config() syscall 逐步配置它(這個 fd 給它、那個 signal disposition、cwd 設成這個、執行檔指這個),最後敲定讓它開始跑。
Brauner 自己給的類比是 fsconfig()。檔案系統掛載在新 API 下正是這個形狀:fsopen() 拿到一個 fs context 的 fd,一連串 fsconfig() 設定 source、設定各種 mount option,最後 fsmount() 把它變成真正的 mount。pidfd builder 把同一個 idiom 搬到 process 建立上——pidfd 就是那個 context fd,pidfd_config() 就是那一串設定呼叫。這個形狀直接體現了 Guzik「creating a pristine process is the way to go」的主張:你不是從父 process 拷貝一份再修改,你是從一個乾淨的空 process 開始,只把你真正要的東西加上去。fork 那段線性放大的複製成本——複製父的所有 VMA、所有 fd——從根本上消失了,因為根本沒有「父的狀態」被拷貝過來。下面把 fsconfig 與 pidfd_config 的 builder 對應關係畫出來。
pidfd builder 借用 fsconfig() 已經建立的 idiom:open 拿 context fd、一…
pidfd builder 以 fsconfig() idiom 逐步配置:先開 handle 再加參數,從乾淨 process 組裝。
對開發者來說,這條路線的成本不在使用端而在 kernel 設計端。「半成形 process」是一個全新的核心物件:它在 pidfd_open 之後、敲定之前處於什麼狀態?這段期間它佔不佔 pid、出不出現在 /proc?收到 signal 怎麼辦?被 ptrace attach 會怎樣?父 process 在敲定之前就死掉,這個半成形物件由誰回收?pidfd_config() 的每一個配置動作都要定義在這個半成形狀態上的語意,而且要把所有非法轉換(例如敲定之後又想改 fd)擋掉。這比 spawn template「快取幾個欄位」要重得多——但它換來的是真正解決 Guzik 指出的根本問題,而不是在 exec 段邊緣擠出 2%。pidfd 本身在近幾年已經逐步成為 Linux 管理 process 的正統 handle(取代容易被 race 的裸 pid),把建立流程也收進 pidfd 體系,方向上是連貫的。
io_uring spawn:把建立動作下成 ring 上的 sqe
Poimboeuf 的路線從一個完全不同的方向切入:既然 io_uring 已經是 Linux 下「把一串操作批次下達給 kernel、非同步完成」的通用機制,為什麼不把 process 建立也表達成 ring 上的一串 sqe?想法是——用一個 syscall 把執行檔載入一次,之後每次要 spawn,就往 ring 裡塞 sqe:收下這些 fd、把它們裝進新 process 的 fd table、做這些設定,整串以 exec 收尾。建立動作變成 ring 上的資料,而不是一連串同步 syscall。
這條路線有個漂亮的安全語意:如果 ring 在沒有 exec 的情況下結束——比方中途某個 sqe 失敗、或程式邏輯錯誤忘了下 exec——那個半成形的 process 直接被 SIGKILL。它不會以一個「配置到一半、不知道該跑什麼」的殭屍狀態洩漏出來。這跟 pidfd builder 共享同一個哲學內核:process 從一個乾淨狀態開始,逐步配置,只是 pidfd 用 syscall 序列配置、io_uring 用 sqe 序列配置。對已經重度使用 io_uring 的高效能服務——那些把 accept、read、write 全下進 ring 的服務——把 spawn 也納進同一個提交路徑,意味著建立 worker process 不再需要跳出 ring 的非同步模型去做一次阻塞的 fork。代價是語意新穎:把 process lifecycle 綁進 ring 的提交/完成模型,偏離了三十年來「fork 回傳兩次」的直覺,錯誤處理路徑(ring 中途失敗時誰負責清理那個半成形 process)需要被非常小心地定義。下面把三條「活著的」路線的 API 形狀並排,看它們在程式碼層面長什麼樣。
switch tabs to compare 3 API shapes · 3 tabs
// 原子呼叫 + 兩組描述子(今天在 glibc 是 vfork+exec;原生化後直接交 kernel)
posix_spawn_file_actions_t fa;
posix_spawn_file_actions_addclose(&fa, child_log_fd);
posix_spawn_file_actions_adddup2(&fa, pipe_w, STDOUT_FILENO);
posix_spawnattr_t attr;
posix_spawnattr_setsigmask(&attr, &clean_mask);
posix_spawn(&pid, "/usr/bin/git", &fa, &attr, argv, envp);
// kernel 一次拿到 file actions + attrs,不需要 vfork 窗口表演
形狀是「一個呼叫,外加事先打包好的 file actions 與 attributes」。原生化的賣點是把這兩組描述子直接交 kernel 解釋,消掉中間那層 vfork 翻譯。
// builder:open 拿 handle、一串 config 配置、最後敲定(類比 fsconfig)
int pidfd = pidfd_open(PIDFD_EMPTY, 0); // 空 process,尚未成形
pidfd_config(pidfd, PIDFD_SET_EXEC, "/usr/bin/git", 0);
pidfd_config(pidfd, PIDFD_DUP_FD, pipe_w, STDOUT_FILENO);
pidfd_config(pidfd, PIDFD_CLOSE_FD, child_log_fd, 0);
pidfd_config(pidfd, PIDFD_SET_SIGMASK, &clean_mask, 0);
pidfd_config(pidfd, PIDFD_FINALIZE, 0, 0); // 敲定 → 開始跑
// 全程沒有「父 process 狀態」被拷貝——pristine process
形狀對應 fsopen → fsconfig×N → fsmount。沒有任何父 process 的 VMA 或 fd 被拷貝,fork 那段線性成本從根上消失。(syscall 常數為示意,標準化前 API 未定。)
// 執行檔載入一次,spawn 動作下成 ring 上一串 sqe,以 exec 收尾
int exec_handle = io_uring_register_exec(ring, "/usr/bin/git");
sqe = io_uring_get_sqe(ring);
io_uring_prep_spawn_install_fd(sqe, pipe_w, STDOUT_FILENO);
sqe = io_uring_get_sqe(ring);
io_uring_prep_spawn_close_fd(sqe, child_log_fd);
sqe = io_uring_get_sqe(ring);
io_uring_prep_spawn_exec(sqe, exec_handle, argv, envp); // 收尾
io_uring_submit(ring);
// 若 ring 結束時沒有 exec → 半成形 process 被 SIGKILL
形狀把 process 建立融進 io_uring 的提交/完成模型。對已重度使用 ring 的服務,建立 worker 不必跳出非同步路徑去做阻塞 fork。(prep 函式名為示意。)
互動圖表
三條路線都從乾淨空 process 逐步配置出發,差別只在用一組描述子、一串 syscall、還是一串 io_uring sqe 下達。
把三條形狀並排,會看出一個共同的方向感:它們都在離開「從既有 process 複製狀態」這個前提。posix_spawn 原生化把危險的 vfork 窗口從標準庫收進 kernel;pidfd builder 與 io_uring spawn 則更激進,直接從一個乾淨的空 process 開始逐步配置。差別只在配置的提交介面——一組描述子、一串 syscall、還是一串 sqe。Guzik 那句「creating a pristine process is the way to go」是這三條路線共享的設計北極星,而被拒的 spawn templates 之所以出局,正因為它停在 exec 段做模板快取、沒有觸碰「為什麼要從父 process 拷貝」這個真正昂貴的前提。
這場討論的當下結論是收斂而非定案:spawn templates 不會合併,社群把焦點移到「讓 posix_spawn 能在 userspace 用 kernel 原語乾淨地實作,而不是把 fork+exec 的複雜度藏起來」。但反方那句質疑仍懸在桌上——現有 vfork+posix_spawn 是否已經夠用,需要具體 benchmark 才能定奪。在那個數字出現之前,四條草圖裡沒有任何一條被正式刻進 kernel。對下週要寫程式的人,務實的讀法是:今天繼續用 posix_spawn() 而非手寫 fork+exec(你已經免費拿到 vfork 的好處),同時知道你呼叫的那個函式底層正準備換引擎。
What this enables:當「建立 process」不再等於「複製一個 process 再丟掉」,那條隨 VMA 與 fd 數量線性放大的 fork 成本曲線就從多執行緒大型服務的 hot path 上消失——重型 server spawn 子程序這件事,第一次有機會跟父 process 有多大脫鉤。