---
title: UNIX 作業系統 fork/exec 系統呼叫的前世今生
image: https://i.imgur.com/xm1056l.png
tags: LINUX KERNEL, LKI
---
# [並行程式設計](https://hackmd.io/@sysprog/concurrency): UNIX 作業系統 fork/exec 系統呼叫的前世今生
資料整理: [jserv](https://wiki.csie.ncku.edu.tw/User/jserv)
> 啟發自 dog250 的網誌 [1](https://blog.csdn.net/dog250/article/details/100538009), [2](https://blog.csdn.net/dog250/article/details/100168430), [3](https://blog.csdn.net/dog250/article/details/100560290)
> 授權條款: [CC 4.0 BY-SA](https://creativecommons.org/licenses/by-sa/4.0/)
本文從 1963 年 Conway 提出 fork 概念開始,追溯早期 Unix 的 overlay 機制如何催生 fork/exec 分離設計,再深入分析 fork 在記憶體、安全性與多執行緒環境下累積的代價,最後探討 Linux 如何以 `clone`、`clone3`、pidfd 與 `posix_spawn` 等介面逐步補強傳統 `fork` 模型。過程中善用 Linux 核心的 eBPF 和 perf 一類的機制,精準測量 fork 的開銷並分析對應的系統設計議題。

> 出處: [forked!](https://turnoff.us/geek/forked/)
## fork 的由來
儘管 [fork 系統呼叫](https://man7.org/linux/man-pages/man2/fork.2.html) 在 [UNIX 第一版](https://github.com/jserv/unix-v1/blob/master/src/lib/fork.s) 就出現 (開發始於 1969 年,針對 [PDP-7](https://en.wikipedia.org/wiki/PDP-7) 硬體),距今超過 50 年,不過 fork 系統呼叫背後的概念可追溯到 1963 年,也就是早於 UNIX 的 6 年前即存在。
> 可線上查閱 UNIX 第一版的技術手冊,[section 2](https://man.cat-v.org/unix-1st/) 即是系統呼叫
1960 年代中期,與阿波羅登月計畫齊頭並進的 [Project MAC](https://multicians.org/project-mac.html) 啟動,這是個由 DARPA 贊助的宏大計畫,旨在開發一個能夠支援多使用者、分時多工,並運作於多處理器 (multi-processor) 硬體環境的作業系統,也就是 [Multics](https://web.mit.edu/multics-history/)。有意思的是,Project MAC 這麼龐大的計畫卻由麻省理工學院 (MIT) 領軍開發關鍵技術,並由 GE (美國通用電氣,也稱奇異公司) 提供硬體及 AT&T 旗下的 Bell Labs 開發軟體和技術支持 (受到反壟斷條款的處分,AT&T 不得涉及硬體銷售,但研發專利技術並授權他人不在此限,於是軟體技術就成為 Bell Labs 的施力點),今日我們熟知的 C 語言開創者,也就是已故的 [Dennis M. Ritchie](https://en.wikipedia.org/wiki/Dennis_Ritchie) (縮寫 dmr) 和仍遊走於資訊科技產業的 [Ken Thompson](https://en.wikipedia.org/wiki/Ken_Thompson) (縮寫 ken),即任職於 Bell Labs。
從商業視角來看,Multics 雖沒能取得預期的成功,但 ken 和 dmr 卻從中吸取靈感,帶著一絲幽默,開發出名為 UNICS 的作業系統,`uni-` 與 `multi-` 形成鮮明對比,這類命名的幽默持續在泛 UNIX 的系統中出現。隨後 UNICS 正式更名為 UNIX,從此名聲大噪,深遠影響我們生活的各個層面。UNIX 最初使用組合語言開發,主要提供檔案系統服務。隨後,它被用 C 語言重寫,其中 [Research UNIX](https://en.wikipedia.org/wiki/Research_Unix) Version 6 (簡稱 V6) 在貝爾實驗室所屬的 AT&T 對 UNIX 施加高昂授權費之前,V6 已被眾多學校和企業廣泛採用。在 UNIX 的演進過程中,其從既有資訊系統借鑑而來的痕跡不言自明,其中 fork 系統呼叫便是一例。
1963 年,電腦科學家 [Melvin Conway](https://en.wikipedia.org/wiki/Melvin_Conway) 博士 (以 [Conway's Law](https://en.wikipedia.org/wiki/Conway%27s_law) 聞名於世) 發表論文〈[A Multiprocessor System Design](https://archive.org/details/AMultiprocessorSystemDesignConway1963/page/n7)〉,正式提出 fork 思想。論文標題直接點出 fork 的思想最初是作為多處理器並行處理的執行模型,其核心概念可由下方流程圖理解。
> 此處「並行」通指 [concurrent](https://dictionary.cambridge.org/dictionary/english/concurrent) (形容詞) 和 [concurrency](https://dictionary.cambridge.org/dictionary/english/concurrency) (名詞),這詞彙在中國簡體稱為「并发」。本文使用「並行」指 concurrent/concurrency。
延伸閱讀: [資訊科技詞彙翻譯](https://hackmd.io/@sysprog/it-vocabulary)
我們考慮以下流程圖:
```graphviz
digraph Graph1
{
graph [splines=ortho];
node [style=rounded shape=box label=""];
A;
subgraph cluster_fork {
style="rounded";
label=" fork 點";
B[style=diagonal shape=diamond];
}
subgraph cluster_join {
style="rounded";
label=" join 點";
D;
}
subgraph cluster_parallel1 {
label="可並行 ";
CL;
}
subgraph cluster_parallel2 {
label=" 可並行";
CR;
}
E;
A -> B;
B -> {CL CR};
{CL CR} -> D;
D -> E;
}
```
若確認電腦程式能藉由一組明確流程予以描述,我們便可斷言該並行處理方案是可行的。留意上方流程圖中的分岔點,就是 fork,原意是「分岔」,這裡用來比喻流程圖的某個分岔點,一旦衍生出來的路徑得以確保處理邏輯的獨立,就會成為實施並行處理的關鍵要求。於是,這些因分岔點所衍生的獨立路徑,就轉變成不同的行程 (process) 的形式。當時,對這一概念的描述還僅限於 "process" 這個術語,未能滿足現代作業系統中「行程」的諸多特性。
join 表示多個並行處理的行程由於某種原因不得不進行同步的時間點,也就是多個並行行程會合之處。在當今的多執行緒 (multi-threaded) 程式中,這個點依然叫作 join。比如 Java 核心套件裡頭的 [java.lang.Thread](https://docs.oracle.com/javase/8/docs/api/java/lang/Thread.html) 的 join 方法以及 [POSIX Thread](https://en.wikipedia.org/wiki/POSIX_Threads) 函式庫的 [pthread_join](https://man7.org/linux/man-pages/man3/pthread_join.3.html) 函式。
廣義來說,join 也意味著不同的行程必須經過的執行路徑,因此減少 join 的數量將會提高並行的效率。
Conway 的論文另一創舉是,他將行程 (也就是後來作業系統中的 process 的概念) 及執行該行程的處理器 (即 CPU processor) 分離出來,抽象出 scheduler 層面。
大意是說:「只要滿足系統中的活動處理器數量是總處理器數量和並行行程總和的最小值即可」。這意味著 scheduler 可以將多處理器系統的所有處理器和系統所有行程分別看成是統一的資源提供者和消費者來一起排程。
```graphviz
digraph Graph2 {
graph [nodesep=2]
node [shape=box];
B [label="一堆處理器"];
A [label="一堆行程"];
B -> A [style=dotted dir=both label=" 多對多映射"];
A -> B [dir=both label=" scheduler"]
}
```
在 Unix 引入 fork 之後,這種多處理器並行的設計思想就深入到 Unix 的核心。這個思想最終也影響 Unix 及後來的 Linux,並延續至今。
這套設計思想之所以能長期影響 Unix 及其家族,和 Conway 在 1968 年正式提出的 [Conway's Law](https://en.wikipedia.org/wiki/Conway%27s_law) 不無關係。Bell Labs 以小型自治研究團隊為組織特色,這種扁平、模組化的溝通結構直接映射到 Unix 的 fork/exec 管線設計。Conway's Law 指出:
> Any organization that designs a system (defined broadly) will produce a design whose structure is a copy of the organization's communication structure.
美國威斯康辛大學 Remzi H. Arpaci-Dusseau 和 Andrea C. Arpaci-Dusseau 二位教授撰寫的開放存取式教科書《[Operating Systems: Three Easy Pieces](https://pages.cs.wisc.edu/~remzi/OSTEP/)》,在其〈[The Abstraction: The Process](https://pages.cs.wisc.edu/~remzi/OSTEP/cpu-intro.pdf)〉一章提到:
> HOW TO PROVIDE THE ILLUSION OF **MANY CPUS**?
Although there are only a few physical CPUs available, how can the OS provide the illusion of a nearly-endless supply of said CPUs?
作業系統藉由虛擬化 (virtualize) CPU 資源來達到在單一處理器實作出有如同時多個程式執行於各自的處理器之上的假象,其中關鍵的手法就是分時多工 (time-sharing),而 Unix 的第一篇論文〈[The UNIX Time Sharing System](https://people.eecs.berkeley.edu/~brewer/cs262/unix.pdf)〉,由 Ken Thompson 和 Dennis Ritchie 在 1973 年 10 月 ACM [Symposium on Operating Systems Principles](https://www.sosp.org/) (SOSP) 中提出,稍後於 1974 年 7 月的 [Communications of the ACM](https://cacm.acm.org/) 發表,正是採用分時多工作為主題。
:::success
至於《[Operating Systems: Three Easy Pieces](https://pages.cs.wisc.edu/~remzi/OSTEP/)》(可簡稱為 OSTEP) 的 "Three Easy Pieces" 也有典故,是向已故物理學家費曼致敬,後者著有《Six Easy Pieces: Essentials Of Physics Explained By Its Most Brilliant Teacher》。用 OSTEP 作者的話說,作業系統只有物理學一半難度,那就折半為 《Three Easy Pieces》,該書的三大主軸:
* 虛擬化 (Virtualization);
* 並行 (Concurrency);
* 持續保存 (Persistence): 主要探討檔案系統;
:::
fork 的源頭已初步觸及,接下來看 Unix 作業系統 fork 的另一個脈絡。
## 早期 Unix 的覆疊 (overlay) 手法
1969 年最初的 Unix 採取一種從現在眼光來看,會覺得非常奇怪的方案。
較早期的 Unix 現稱為 [Research UNIX](https://en.wikipedia.org/wiki/Research_Unix)。2002 年,Caldera International 將多個早期 Research UNIX 版本置於一份 [BSD-style license](https://www.tuhs.org/pipermail/tuhs/2002-January/003540.html) 之下,這些原始程式碼與相關材料如今多整理於 [The Unix Heritage Society](https://www.tuhs.org/),讓後人得以追溯作業系統的演化。[Research UNIX](https://en.wikipedia.org/wiki/Research_Unix) 尤其是 V6,在 1970 年代後期為許多大學院校採用,廣泛運作於相對低廉的 PDP-11 硬體上。1977 年,澳洲新南威爾斯大學 (UNSW) 的 John Lions 為 V6 原始程式碼撰寫逐行註解,集結成《[Lions' Commentary on UNIX 6th Edition, with Source Code](https://en.wikipedia.org/wiki/Lions%27_Commentary_on_UNIX_6th_Edition,_with_Source_Code)》(簡稱 Lions Book)。依 TUHS 的整理,AT&T 當年對該書的正式散布施加嚴格限制,通常只允許每套 Unix 安裝持有一冊,因此它在 1970 到 1980 年代大量以影印本形式流傳。書中保存一段著名註解,出現在 V6 核心 `slp.c` 的行程切換程式碼中:"[You are not expected to understand this](https://en.wikipedia.org/wiki/A_Commentary_on_the_UNIX_Operating_System#%22You_are_not_expected_to_understand_this%22)",也正反映早期 Unix 核心與 PDP-11 編譯器、calling convention 之間高度耦合的現實。
因此,大部分論及 Unix 的材料是從 v6 講起,算是較為現代的版本,即便是[在 PDP-7 上執行的 Unix](https://github.com/DoctorWkt/pdp7-unix) (也被稱為 [Unix Edition Zero](https://www.tuhs.org/Archive/Distributions/Research/McIlroy_v0/)),也是引入 fork 之後的版本,而在那之前最原始版本已難見蹤跡。
Bell Labs 於 1969 年 4 月正式退出 Multics 計畫後,Thompson、Ritchie 等人曾多次申請 PDP-10 等中型主機來開發新系統,均遭管理層否決。Thompson 最終在一台閒置的 PDP-7 上進行開發,最初動機之一是移植他在 Multics 上撰寫的太空旅行遊戲 (Space Travel)。PDP-7 是台 18 位元字組定址的機器,缺乏索引暫存器,DMA 控制器在傳輸期間無法與 CPU 同時存取記憶體,硬體條件極度受限。Ken Thompson 最初開發的 Unix 極為簡陋,可在 dmr 的〈[The Evolution of the Unix Time-sharing System](https://www.read.seas.harvard.edu/~kohler/class/aosref/ritchie84evolution.pdf)〉窺知樣貌。最初的 Unix 雖然是個分時系統 (Time-sharing System),但裡頭只有二個 shell 行程,分別屬於二個終端機 ([terminal](https://en.wikipedia.org/wiki/Computer_terminal),對應到 Linux 核心的 tty 子系統,對照閱讀〈[看漫畫學 Linux](https://hackmd.io/@sysprog/linux-comic)〉以得知其發展脈絡),不難見其簡陋。
> Processes (independently executing entities) existed very early in PDP-7 Unix. There were in fact ==precisely two of them, one for each of the two terminals attached to the machine.== There was no *fork*, *wait*, or *exec*. There was an *exit*, but its meaning was rather different, as will be seen. The main loop of the shell went as follows.
最初的 Unix 為了體現分時系統的特性,實作最低限度的二個 shell 行程。需要注意到最初的 Unix 沒有 fork,也沒有 exec,而且尚未發展出後來 UNIX 那種可由一般程式任意衍生子行程的模型。不過從 dmr 的回顧可知,當時已存在最基本的 process 實體。為了跟 [Research UNIX](https://en.wikipedia.org/wiki/Research_Unix) 各版本有所區隔,本文將 1969 年最初的 Unix 稱為 "Ken Unix",之所以不叫 Version 0,是因為在 1971 年 11 月 [UNIX 手冊](https://www.bell-labs.com/usr/dmr/www/1stEdman.html) 在 Bell Labs 發布以前,Unix 程式碼經歷大量變更,為避免討論的歧異,本文特別標示 Ken Unix 為最早期的 Unix 實作,儘管當時甚至不叫 Unix 這名稱。
事實上,Ken Unix 用二個單元構成的區塊 (Process Control Block, PCB) 來容納所有行程,此處區塊僅為概念,因為 Ken Unix 用 PDP-7 組合語言撰寫,還沒有後來 C 語言明確的結構體。
> 關於 Unix 執行在 DEC PDP-7 主機的樣貌,可參見 [UNIX Version 0, Running on A PDP-7](https://hackaday.com/2019/11/17/unix-version-0-running-on-a-pdp-7-in-2019/),內附場景重現的短片,甚至可藉由 [SimH](http://simh.trailing-edge.com/) 模擬器來執行,參見 [pdp7-unix](https://github.com/DoctorWkt/pdp7-unix)
我們現在考慮其中一個終端機的 shell 行程如何工作,問題馬上就浮現:這個 shell 行程該如何執行別的程式?
如果說系統中最多只能容納二個行程,一個終端機只有一個行程的話,當該終端機的 shell 執行其他程式時它自己該怎麼辦?
:::warning
注意: 不要用現代的眼光去評價 1969 年的 Unix。按照現代的眼光,執行一個程式 (program,靜態存在於儲存媒介中) 必然要產生一個新的行程 (process,執行中的程式,自儲存媒介中載入),顯然這在初版的 Unix 中並不適用。
:::
答案是根本不用產生新的行程,直接將程式碼載入記憶體並「覆疊」(overlay) 掉 shell 所在的記憶體空間即可!當程式執行完後,再用 shell 的程式碼覆疊回去原本的記憶體空間。針對單獨的終端機,系統其實一直在執行下面的覆疊循環。以下摘自〈[The Evolution of the Unix Time-sharing System](https://www.read.seas.harvard.edu/~kohler/class/aosref/ritchie84evolution.pdf)〉論文的 Process Control 章節:
> 1. The shell closed all its open files, then opened the terminal special file for standard input and output (file descriptors 0 and 1).
> 2. It read a command line from the terminal.
> 3. ==It linked to the file specifying the command, opened the file, and removed the link. Then it copied a small bootstrap program to the top of memory and jumped to it; this bootstrap program read in the file over the shell code, then jumped to the first location of the commmand (in effect an exec).==
> 4. the command did its work, then terminated by calling *exit*. The *exit* call caused the system to read in a fresh copy of the shell over the terminated command, then to jump to its start (and thus in effect to go to step 1).
在 `fork` 被引入 Unix 之前,就是如此運作:一個終端機上一直都是那個行程,一下子執行 shell,一下子執行其他程式的程式碼。以下是一個覆疊程式的結構 (圖片摘自《[FreeBSD 作業系統設計與實作](https://dl.acm.org/citation.cfm?id=2659919)》一書):

結合上圖和覆疊循環的第 3 步,你會發現該步驟已經很接近後來 `exec` 的核心語意,也就是「保留既有行程身分,改載入另一個程式映像」。若對照 Linux 今日 `execve` 載入 ELF 的流程,可將它視為概念上的前身;不過二者不可直接等同,因為早期 Unix 仍靠簡單的 bootstrap 與覆疊手法完成載入,而現代 Linux 則由 `execve` 路徑中的格式解析器與載入器 (例如 ELF 對應的 `load_elf_binary`) 負責更複雜的定址空間建立、輔助向量佈置與動態連結啟動。
然而,當時畢竟還沒有將這個邏輯封裝為 exec 系統呼叫,所以每一個行程都需要實作這些動作:
* shell 需要執行程式時,執行磁碟 (disk) I/O 來載入程式覆疊掉自己
* 程式結束時,exit 讓磁碟 I/O 把 shell 程式載入回來
exec 邏輯是 shell 程式的一部份,由於顧及會被所有的程式使用到,這個邏輯也被封裝到 exit 這個呼叫中。
## Unix fork 的誕生
歸納上述,fork 引入 Unix 前的表象如下:
1. 1963 年 Melvin Conway 提出 fork 的思想,作為在多處理器中並行執行行程的一個手段;
2. 1969 年 Ken Unix 僅有二個 shell 行程,而且使用覆疊手法來執行命令;
截至目前,我們看到的表象是:Ken Unix 沒有 fork、沒有 exec、沒有 wait,其 exit 也和現在的 exit 系統呼叫大相逕庭。更精確地說,Ken Unix 已有極簡的 process 實體,但還不是後來那種可普遍建立、等待與回收子行程的多行程系統,只是個可以跑二個終端機的簡陋分時系統。
fork 如何引入 Unix 呢?
這要從採用覆疊手法的 Ken Unix 固有的問題談起,論文中提到
> It also exhibited some irritating, idiosyncratic problems. For example, a newly recreated shell had to close all its open files both to get rid of any open files left by the command just executed and to rescind previous IO redirection.
以及
> Moreover, the shell could retain no memory across commands, because it was reexecuted afresh after each command. Thus a further file system convention was required: each directory had to contain an entry *tty* for a special file that referred to the terminal of the process that opened it. If by accident one changed into some directory that lacked this entry, the shell would loop hopelessly.
Ken Unix 的檔案系統同樣原始:沒有路徑名稱 (pathname) 的概念,所有檔名都是相對於目前目錄的簡單字串,不支援 `/usr/bin/` 這樣的階層式路徑。為了在目錄間導覽,系統仰賴一個名為 `dd` 的特殊目錄項目指向上層目錄 (即日後 `..` 的前身)。這些限制和上述的 `tty` 慣例一樣,都是覆疊機制的副作用。
很顯然,程式不能夠覆疊掉 shell 行程。解決方案是用「置換」(swap)。
要解決這些問題,Ken 提出簡單的執行方案:留著 shell 行程而非銷毀。執行命令時將其置換 (swap) 到磁碟即可。
不只在 UNIX,「置換」和「覆疊」都曾在多款作業系統中,用來解決有限記憶體的多行程 (或多任務) 使用問題,不同點在於二者方向不同:
* 覆疊:用不同的行程磁碟映像 (image) 覆疊目前行程的記憶體映像
* 置換:將行程的記憶體映像置換到磁碟,再載入別的行程的磁碟映像
使用置換手法解決覆疊的問題,意味著要建立新的行程:
* 在新的行程中執行程式
Unix 需要進行改動,二個 PCB 的空間顯然不夠用。當然,解決方案也不麻煩:
1. Expansion of the process table
2. Addition of a fork that copied the current process to the disk swap area, using the already existing swap IO primitives, and made some adjustments to the process table.
現在,剩下唯一的問題就是如何建立新行程!誰來臨門一腳呢?
要講效率,創造不如抄襲,建立新行程最直接的就是複製目前 shell 的行程,在複製的新行程中執行覆疊,讓程式覆疊掉複製過來的新行程,而目前的 shell 行程則被置換到磁碟儲存。
覆疊跟置換結合,Unix 朝現代化邁開一大步!
確立複製目前行程的方案後,進一步的問題是要如何複製它。
現在要說回 fork。
在 Conway 提出 fork 思想後,沒多久就出現實作 fork 的原型系統 (正如 Conway 自己所說,他只是提出一個可能的想法而未實作出來),也就是 [Project Genie](https://en.wikipedia.org/wiki/Project_Genie) (1964 年始於 UC Berkeley 的分時多工作業系統,也由 DARPA 經費支持),其分時多工系統稱為 [Berkeley Timesharing System](https://en.wikipedia.org/wiki/Berkeley_Timesharing_System),Project Genie 的成果最終商業化為 [Scientific Data Systems](https://en.wikipedia.org/wiki/Scientific_Data_Systems) (SDS) 公司的 [SDS 940](https://en.wikipedia.org/wiki/SDS_940) 大型電腦裡頭的分時多工作業系統,而 Ken Thompson 在 UC Berkeley 進修時,一度投入 SDS 940 的開發。1969 年,SDS 被 Xerox 收購,SDS 940 則更名為 XDS,表示 "Xerox Data Systems"。
> 延伸閱讀: 《[The Computers Nobody Wanted: My Years at Xerox](https://worrydream.com/refs/Strassmann_2008_-_The_Computers_Nobody_Wanted,_My_Years_With_Xerox.pdf)》,該書第 22 頁的 "SDS was also a user of early versions of the UNIX operating system that was favored by defense firms." 描述不正確,SDS 創立時主要客戶就是軍方及其供應商,且 SDS 被 Xerox 收購時,Unix 尚未發展。不過該書談及 SDS 是早期採納積體電路和電晶體的公司,且積極擁抱新技術來滿足 NASA 一類客戶的需求,甚至在 1960 年代就實作支援虛擬記憶體的硬體,軟體的推進更是不遑多讓,Xerox 在收購 SDS 後,催生 Palo Alto Research Center (PARC),許多關鍵電腦技術,像是電腦網路和圖形人機介面,就源於 PARC。
Berkeley Timesharing System 的 fork 並非盲目地複製行程,對 fork 的過程有精細的控制權,例如可決定要配置多大的記憶體空間、複製哪些必要的資源等等。從 1968 年的〈[SDS 940 Time-Sharing System Version 2.0 Technical Manual](https://bitsavers.org/pdf/sds/9xx/940/901116B_940_TimesharingTechMan_Aug68.pdf)〉第 11 頁 "Forking Structure" 可見,這套機制已經把 fork 從抽象的 fork-join 想法,落實為可操作的行程建立原語 (primitive)。也因為如此,Unix 後來採用的 `fork`,更適合視為對 SDS 940/BTSS 系統介面的簡化繼承,而非從零開始的獨立發明。
Unix 的 fork 其實是對 Berkeley 作業系統裡頭 fork 的簡化與繼承。Ken Thompson 在 UC Berkeley 進修時,深度參與 SDS 940 的開發,甚至為其撰寫 QED 編輯器 (Unix `ed` 的前身)。1969 年,當他在 PDP-7 上實作 Unix 時,他選擇 `fork` 模型,因為它極其簡潔,在 Unix 第一版中,`fork` 的組合語言實作僅需約 27 行。
Unix 借用 fork 的概念,但將其與 `exec` 分離,這種分離讓 Shell 能夠在 `fork` 之後、`exec` 之前進行 I/O 重導向,而核心無需感知重導向細節,這正是 Unix 哲學的精髓。
```c
FORK(2) Linux Programmer's Manual FORK(2)
NAME
fork - create a child process
SYNOPSIS
#include <sys/types.h>
#include <unistd.h>
pid_t fork(void);
```
沒有參數,簡潔而優雅。Unix V1 的 [`fork.s`](https://github.com/jserv/unix-v1/blob/master/src/lib/fork.s) 使用者空間包裝僅約 20 行 PDP-11 組合語言:執行 `sys fork` 陷入核心後,核心利用硬體進位旗標 (carry bit) 區分成功與失敗。成功時親代行程以暫存器 r0 帶回子行程 PID 並返回;子行程則跳轉至 `br 1f` 路徑,先 `clr r0` (將 r0 清零) 再返回,使同一個系統呼叫在兩個執行脈絡中各返回一次。
> fork 本來就不是讓你來覆疊新行程的,不然為何多此一舉? fork 是讓你來分解程式流程來並行處理。
我們回顧 Unix fork 誕生前的景象:
```graphviz
digraph Graph3 {
rankdir=LR;
node [shape=record];
disk [label="<f0>|<f1>shell|<f2>|<f3>cmd|<f4>"]
memory [label="<f0>行程|<f1>current\ncode|<f2>current\ndata"]
disk:f1 -> memory:f1 [label="覆疊"];
disk:f3 -> memory:f2 [label="覆疊"];
}
```
每個終端機單一的行程循環覆疊,再來看 fork 誕生後的景象:
```graphviz
digraph Graph4 {
rankdir=LR;
node [shape=record]
memory1 [label="<f0>行程|<f1>shell\ncode|<f2>shell\ndata"];
memory2 [label="<f0>行程|<f1>cmd\ncode|<f2>cmd\ndata"];
disk [label="<f0>|<f1>\n\nshell\n\n\n|<f2>|<f3>\n\ncmd\n\n\n|<f4>"];
fork [shape=plain]
disk:f1 -> memory1:f1 [label="置換" dir="back"];
disk:f3 -> memory2:f1 [label="覆疊"]
memory1:f2 -> fork [arrowhead=none]
fork -> memory2:f0
{rank=same; disk;}
{rank=same; memory1; memory2}
}
```
有 fork 之後,Unix 行程變可組合出無限可能,正式成為一個名副其實的多使用者、多行程現代作業系統。fork 孕育無限的可能性 (Linux 上可以用 [pstree](https://man7.org/linux/man-pages/man1/pstree.1.html) 命令來觀察):
```graphviz
digraph Graph5 {
graph [splines=ortho]
node [shape=box]
subgraph cluster_disk {
label="磁碟"
style=filled
d1 [style=invis]
d2 [style=invis];
d3 [style=invis];
d4 [style=invis];
d1 -> d2 -> d3 -> d4 [style=invis];
}
d5 [label="置換\n覆蓋" shape=plain]
subgraph cluster_memory {
label="記憶體"
A1[label="init\n行程"];
A2[label="行程"];
A3[label="行程"];
A4[label="行程"];
A1 -> A2 -> A3 -> A4 [label=" fork"];
B1d[style=invis]
B2[label="行程"];
B3[label="行程"];
B4[label="行程"];
A1 -> B1d [style=invis];
A2 -> B2 [label=" fork"];
A3 -> B3 [label=" fork"];
A4 -> B4 [label=" fork"];
B1d -> B2 -> B3 -> B4 [style=invis];
C1d[style=invis];
C2[label="行程"];
C3[label="行程"];
C4d[style=invis];
B1d -> C1d [style=invis];
B2 -> C2 [label=" fork"];
B3 -> C3 [style=invis];
B4 -> C4d [style=invis];
C2 -> C3 [label=" fork"];
C1d -> C2 [style=invis];
C3 -> C4d [style=invis];
D1d[style=invis];
D2d[style=invis];
D3[label="行程"];
D4[label="行程"];
C1d -> D1d [style=invis];
C2 -> D2d [style=invis];
C3 -> D3 [label=" fork"];
C4d -> D4 [style=invis];
D3 -> D4 [label=" fork"];
D1d -> D2d -> D3 [style=invis];
E1d[style=invis];
E2d[style=invis];
E3[label="行程"];
E4[label="行程"];
D1d -> E1d [style=invis];
D2d -> E2d [style=invis];
D3 -> E3 [label=" fork"];
D4 -> E4 [label=" fork"];
E1d -> E2d -> E3 -> E4 [style=invis];
{rank=same; A1; B1d; C1d; D1d; E1d;}
{rank=same; A2; B2; C2; D2d; E2d;}
{rank=same; A3; B3; C3; D3; E3;}
{rank=same; A4; B4; C4d; D4; E4}
}
d1 -> d5 [dir=back style=dotted]
d5 -> A2 [style=dotted]
}
```
於是 Unix 正式邁開現代化建設的步伐,一直走到今日。
## Unix 經典的 fork-exec-exit-wait 組合
exec 就是上述覆疊邏輯的封裝,程式只需呼叫 exec 系統呼叫即可載入新映像。當 fork 引入 Unix 後,exit 的語意發生巨大的改變。
在原本 Ken Unix 中,因為每個終端機只有一個主要行程,意味著覆疊永遠是在 shell 程式和某個程式之間進行的:
* shell 執行命令 A: 程式 A 覆疊記憶體中 shell 的程式碼
* 命令 A 執行結束: shell 覆疊結束的命令 A 在記憶體中的程式碼
然而,fork 引入 UNIX 後,雖然 shell 執行某個命令依然是特定的程式覆疊 fork 出來的 shell 子行程,但是當命令執行完後,exit 邏輯卻不能再讓 shell 覆疊目前程式,因為 shell 從來沒有結束過,它作為親代行程只是被交換到磁碟而已 (後來出現記憶體後可容納多個行程時連交換都不需要)。
那麼 exit 將讓誰來覆疊目前行程呢?
答案是不用覆疊,按照 exit 的字面意思,它只要結束自己即可。
本著自己的資源自己管理的責任原則,exit 只需要清理掉自己配置的資源,比如清理掉自己的記憶體空間以及其他的資料結構。
對於子行程本身而言,由於它由親代行程所產生,所以子行程被親代行程管理釋放。至此,經典的 Unix 行程管理 fork + exec + exit + wait 等系統呼叫的組合正式成軍:
```graphviz
digraph Graph6 {
node [shape=box]
rankdir=LR
A [label=<<B>fork</B>>]
B [label="create task" style=filled]
C [label="fork task"]
D [label="copy resource" style=filled]
E [label=<<B>exec</B>>]
F [label="release resource" style=filled]
G [label="reload resource" style=filled]
H [label="fork ret" style=filled]
I [label=<<B>exit</B>>]
J [label="release resource" style=filled]
K [label="new task"]
L [label=<<B>wait</B>>]
M [label="release task" style=filled]
d1 [style=invis shape=plain]
d2 [style=invis]
A -> {H B};
B -> C -> D;
C -> E -> F -> G;
E -> I;
I -> J;
I -> K;
H -> d1 [arrowhead=none]
d1 -> I [dir=back style=dotted label="notify parent"]
d1 -> L -> M -> K;
K -> d2 [style=invis];
J -> d2 [style=invis];
{rank=same; A;H;d1;L;}
{rank=same; B;M;}
{rank=same; C;E;I;K;}
{rank=same; D;F;G;J;d2}
}
```
fork 還帶來一個深遠的副作用:I/O 偏移量的共享語意。在 Ken Unix 的覆疊時代,檔案的讀寫偏移量 (seek pointer) 原本儲存在各行程自身的記憶體中。當命令腳本依序執行多個程式時,每個程式各自從偏移量 0 開始寫入同一個輸出檔,彼此覆蓋而非接續。此缺陷促使 Unix 將 I/O 偏移量從行程空間抽離,放入全域的 open file table,使得 fork 後親代與子行程共享同一個 open file description (含共享偏移量)。此設計後來被 POSIX 固化為標準語意。
### execve 與 ELF 載入
雖然經典組合常被稱為 `fork` + `exec`,但 `exec` 不會建立新行程,而是進行「鳩佔鵲巢」。在 Linux 核心中,`execve` 系統呼叫 (對應 `fs/binfmt_elf.c` 中的 `load_elf_binary`) 會執行以下關鍵操作:
1. 清除舊記憶體:清空目前行程的定址空間 (釋放既有的 `vm_area_struct` 與實體記憶體頁面)
2. 解析執行檔標頭:讀取 ELF (Executable and Linkable Format) 標頭,確定執行檔結構
3. 映射新段落:將 ELF 檔中的 `.text` (程式碼) 與 `.data` (已初始化資料) 映射到虛擬記憶體空間
4. 佈置 stack:在 stack 頂部佈置環境變數 (`envp`)、命令列參數 (`argc`/`argv`)
5. 動態連結準備:若為動態連結程式,會將動態連結器 (如 `ld-linux.so`) 與 `vDSO` 一併映射入記憶體
6. 重置暫存器:將 CPU 的指令指標設到新的入口點 (通常是 `_start` 或動態連結器的入口)
經過這些步驟,原有的行程外殼 (PID 等屬性) 保持不變,但內在已經完全變成一個全新的程式。這也是為何前面實驗中,子行程一呼叫 `exec`,先前為舊映像維護的大量分頁表與 VMA metadata 會迅速被釋放。
從 Linux 核心的實作路徑來看,`execve` 的骨架大致是:
1. 入口先落到 `do_execveat_common()`,建立 `struct linux_binprm`,把路徑、`argv`、`envp` 與權限檢查所需資訊收攏到同一個物件;
2. 接著透過 `search_binary_handler()` 依序嘗試各種 `binfmt` 載入器,ELF 對應的就是 `load_elf_binary()`,script 則會走 `#!` 解譯器路徑;
3. 在真正切換程式映像前,核心會透過 `flush_old_exec()` 拆掉舊的使用者空間映像,並處理 `de_thread()` 等收尾步驟,確保多執行緒行程在 exec 後只剩下 thread-group leader;
4. 新映像建立完成後,核心重建使用者 stack、auxv、程式入口位址,最後把控制權交還使用者空間。
`execve` 不是單純把檔案映射進記憶體而已,它還會重設一批行程屬性。依據 [execve(2)](https://man7.org/linux/man-pages/man2/execve.2.html),捕捉中的 signal handler 會回到預設值,替代 signal stack、不共享的 `mmap` 區域、POSIX timer、`atexit` handler 等狀態都不會保留;若先前因 `clone(CLONE_FILES)` 共享檔案描述子表,exec 時也會解除共享,並依 `FD_CLOEXEC` 關閉標記為 close-on-exec 的描述子。這也是為何 `exec` 在語意上更接近重新初始化目前行程,而非單純載入另一個 ELF 檔案。
### fork/exec 分離如何實現 shell 重導向與管線
exec 跨越邊界時保留 PID 和已開啟的檔案描述子,這正是 shell 實作 I/O 重導向與管線的關鍵。以 `ls > out.txt` 為例,shell 在 fork 後、exec 前透過 [`dup2`](https://man7.org/linux/man-pages/man2/dup2.2.html) 操作子行程的檔案描述子對應的表格:
1. shell 呼叫 `fork()` 建立子行程
2. 子行程以 `open("out.txt", O_WRONLY | O_CREAT | O_TRUNC, 0644)` 取得新的 fd (假設為 3)
3. 子行程呼叫 `dup2(3, STDOUT_FILENO)` 將 fd 1 重導向至 out.txt,再 `close(3)`
4. 子行程呼叫 `execvp("ls", ...)` 載入 ls 程式
5. ls 寫入 fd 1 時,輸出自動流向 out.txt,ls 本身不知道重導向的存在
管線 (`cmd1 | cmd2`) 則利用 [`pipe`](https://man7.org/linux/man-pages/man2/pipe.2.html) 系統呼叫建立核心內的單向資料通道 (一對 fd:`pipefd[0]` 讀端、`pipefd[1]` 寫端):
1. shell 呼叫 `pipe(pipefd)` 取得一對 fd
2. fork 出 cmd1 子行程:`dup2(pipefd[1], STDOUT_FILENO)` 後關閉 `pipefd[0]` 和 `pipefd[1]`
3. fork 出 cmd2 子行程:`dup2(pipefd[0], STDIN_FILENO)` 後關閉 `pipefd[0]` 和 `pipefd[1]`
4. shell 自身關閉 `pipefd[0]` 和 `pipefd[1]` (若不關閉,cmd2 永遠收不到 EOF)
5. cmd1 和 cmd2 各自 exec
這套機制的精妙之處在於:核心不需要為 I/O 重導向提供專用 API,一切都由 fork/exec 之間的使用者空間程式碼完成。新增任何核心功能 (如 namespace、cgroup) 也不需要修改 fork 或 exec 的介面,只需在 fork 後、exec 前呼叫對應的系統呼叫即可。這就是 fork/exec 分離設計的正交性優勢,也是 Unix 哲學中最具影響力的設計決策之一。
## 行程樹的必然結果:孤兒行程與 Subreaper
`fork` 的設計強行規範行程之間的親代-子代 (Parent-Child) 關係。這在 Linux 中形成一棵以 PID 1 (通常是 `init` 或 `systemd`) 為根的行程樹。
當子行程結束時,它會變成殭屍行程 (Zombie Process),等待親代呼叫 `wait` 來回收其結束狀態。但如果親代行程先於子行程死亡,子行程就會變成孤兒行程 (orphan process)。在傳統 UNIX 語意下,孤兒行程會被過繼給 PID 1,由 `init` 來負責善後 (reaping)。
然而,隨著現代容器技術和進階系統管理工具 (如 tmux、Docker Daemon) 的普及,讓 PID 1 承擔所有孤兒行程的回收負擔變得缺乏彈性。為此,Linux 3.4 引入 `prctl(PR_SET_CHILD_SUBREAPER)` 系統呼叫。
呼叫此 API 的行程可以宣告自己為 subreaper。此後,由它衍生出的所有子子孫孫行程,若變成孤兒行程,將不會被過繼給 PID 1,而是被過繼給這個離它們最近的 Subreaper。這項機制的出現,展示 Linux 如何透過修補與擴充,來應對 `fork` 強制樹狀結構在複雜應用場景下帶來的管理難題。
`fork` 之所以能延續數十年,不只是因為介面簡潔,也因為大量 shell、程式庫與應用程式都建立在 `fork` + `exec` 的組合之上。當既有軟體生態、教材、工具鏈與除錯模型都圍繞這套語意展開時,任何替代方案都必須先跨過巨大的相容性門檻。
## fork 的後續
Andrew Baumann (Microsoft Research), Jonathan Appavoo (Boston University), Orran Krieger (Boston University), Timothy Roscoe (ETH Zurich) 等人發表在 HotOS 2019 的研究 [A fork() in the road](https://www.microsoft.com/en-us/research/uploads/prod/2019/04/fork-hotos19-slides.pdf) 清晰地交代 fork 系統呼叫是如何一步一步走向今日複雜的面貌。
> [論文全文](https://www.microsoft.com/en-us/research/uploads/prod/2019/04/fork-hotos19.pdf)
PDP-7 的記憶體映射非常簡單,當時的程式也非常小,fork 直接複製整個親代行程是最簡單有效的方案。在早期 Unix 與工作站時代,這種做法通常仍在可承受範圍內;真正讓問題全面浮現的,是行程定址空間、分頁層級與多執行緒 runtime 在 1990 年代之後同步膨脹。
隨著計算機結構的發展,電腦的能力愈來愈強,商業邏輯的繁複也促進軟體技術的茁壯。程式越來越大,記憶體映射機制越來越複雜,fork 的開銷開始不容忽視,此時 Copy-on-Write ([CoW](https://en.wikipedia.org/wiki/Copy-on-write)) 來救場。不過 CoW 只是把成本延後,並未改變 `fork` 會完整繼承親代定址空間佈局這個核心事實;在特定服務模型下,這也會帶來安全面的代價:
- [ ] fork 與 ASLR:共享佈局帶來的攻擊面
[Address Space Layout Randomization](https://en.wikipedia.org/wiki/Address_space_layout_randomization) (ASLR) 是現代作業系統對抗記憶體腐敗漏洞的核心防線,透過隨機化 stack、heap、共享函式庫、可執行檔的載入位址,讓攻擊者無法預知目標位址。然而在典型的 fork 伺服器模型中,子行程會繼承親代當下的定址空間佈局,因此同一批子行程之間往往共享相同的 ASLR 結果與 stack canary。這不代表 ASLR 完全失效,但確實讓攻擊者能把多次試探累積在同一份隨機化狀態上。
以下分析 fork 如何將 ASLR 的熵從指數級降至線性級。
> 適用前提:以下分析針對的是 fork server 模型,即親代行程持續以 fork 產生子行程來處理請求,子行程崩潰後親代會再 fork 新子行程。此模型具備三個攻擊者可利用的特性:可反覆重試 (子行程崩潰不影響親代)、可觀測結果 (crash 與否作為 oracle)、跨子行程保留相同隨機化狀態。若程式在 fork 後立即 exec (如 shell 管線),exec 會重置 ASLR 和 canary,下述攻擊不適用。
- [ ] Stack canary 的逐位元組探測
[Stack canary](https://en.wikipedia.org/wiki/Buffer_overflow_protection#Canaries) 是放置在 stack frame 中的隨機值,函式返回前檢查其是否被覆寫來偵測緩衝區溢位。在典型的 64 位元 Linux 系統上,canary 為 8 bytes,其中最低位元組固定為 `\x00` (用以截斷字串操作),實際隨機部分為 7 bytes。
若無 fork,攻擊者必須一次猜中完整的 canary 值,搜尋空間為 $256^7 = 2^{56}$,暴力搜尋在實務上不可行。
但在 fork 伺服器模型 (如早期的 Apache prefork、某些 SMTP/FTP 服務) 中,每個連線由 fork 出的子行程處理,且所有子行程共享相同的 canary。攻擊者可在子行程逐位元組探測,若子行程沒崩潰,即猜到正確的位元組:
$$
C_{\text{byte-by-byte}} = \sum_{i=1}^{n} 256 = 256n
$$
其中 $n$ 為 canary 的隨機位元組數。以 $n = 7$ 計算:
$$
C_{\text{byte-by-byte}} = 256 \times 7 = 1792
$$
相較於完整猜測的 $2^{56} \approx 7.2 \times 10^{16}$,攻擊成本降低 $\frac{2^{56}}{1792} \approx 4 \times 10^{13}$ 倍。
探測過程如下:攻擊者對 canary 的第 $i$ 個位元組 (從低位開始) 逐一嘗試 0x00 至 0xFF 共 256 個值。若猜錯,子行程因 `__stack_chk_fail` 終止 (觸發 `SIGABRT`),親代 fork 出新子行程繼續服務。若猜對,子行程正常返回,攻擊者即確認該位元組的值,再對下一個位元組重複此過程。整體最多 $256 \times 7 = 1792$ 次連線即可完整還原 canary,在網路環境中數秒內即可完成。
- [ ] ASLR 熵的同步降級
同樣的邏輯適用於定址空間佈局本身。以 Linux x86-64 為例,stack 的 ASLR 熵為 22 bits (stack 基底位址的隨機偏移量),意味著 $2^{22} = 4{,}194{,}304$ 種可能的佈局。在非 fork 的情境下,每次攻擊嘗試都針對一個獨立的隨機化實例,成功機率為 $\frac{1}{2^{22}}$。
但在 fork 模型中,所有子行程共享同一個佈局。攻擊者可利用被 fork 出的子行程的回應行為 (崩潰 vs 存活) 作為 oracle,逐 bit 探測位址。以 Return-Oriented Programming (ROP) 為例,攻擊者需要找到一個 gadget 的位址,可透過以下策略降低搜尋空間:
$$
C_{\text{ASLR}} \leq 2^{22} \text{ (最壞情況,逐一嘗試所有可能的基底位址)}
$$
但在實務中,攻擊者通常可利用部分已知的偏移量 (如同一共享函式庫內各符號間的相對距離是固定的),將問題簡化為僅需探測基底位址的隨機部分。搭配 Blind ROP (BROP) 技術,攻擊者甚至不需要事先取得目標程式的二進位檔,僅憑網路互動即可遠端建構 ROP chain。
- [ ] 實際案例與 CVE
此類攻擊並非理論推演。2014 年發表的 [Blind Return Oriented Programming (BROP)](https://www.scs.stanford.edu/brop/) 攻擊即以可反覆崩潰並重新啟動子行程的 fork 伺服器為目標,展示攻擊者如何利用穩定的位址佈局與 crash oracle,逐步重建可利用的 ROP 鏈。關鍵議題不是 `fork` 單獨造成漏洞,而在可重試、可觀測、且跨子行程保留相同隨機化如此的組合,會大幅降低攻擊者試探的成本。
- [ ] 緩解策略與系統設計取捨
| 策略 | 機制 | 限制 |
|---|---|---|
| exec-after-fork | 子行程立即呼叫 `exec`,取得全新的 ASLR 佈局和 canary | 無法用於需在 fork 後保留親代狀態的場景 |
| per-fork canary 重新隨機化 | 每次 fork 後由核心或 runtime 重新設定 canary | 須修改 glibc/核心,相容性風險高;且無法保護位址佈局 |
| 不使用 fork 的並行模型 | 以多執行緒 + `posix_spawn` 取代 fork 伺服器 | 多執行緒本身有共享記憶體的安全問題 |
| 崩潰節流 (crash throttling) | 偵測子行程連續崩潰並延遲或拒絕新連線 | 降低服務可用性,且只是提高攻擊成本而非消除風險 |
| `MADV_WIPEONFORK` | fork 時將標記區域清零,保護敏感資料 | 僅保護特定記憶體區域,無法保護 stack canary 或程式碼位址 |
從系統設計的角度來看,fork 的完整繼承語意與 ASLR 每次執行重新隨機化的目標之間存在張力:前者要求子行程與親代盡量一致,後者則希望每次程式啟動都盡量彼此獨立。這不是實作上的疏忽,而是兩種設計目標的直接衝突。Windows NT 的 `CreateProcess` 因為從不複製既有行程的定址空間,在這一點上天生較容易維持每次啟動的獨立隨機化。
- [ ] 緩衝 I/O 重複
若程式使用具有緩衝區的 I/O (如 C 的 `printf`),fork 時緩衝區內容會被複製。親代與子行程各自 flush 緩衝區時,同一份資料被輸出兩次。
- [ ] 不可複製的硬體狀態
現代應用常涉及 GPU、NIC 或其他硬體加速器,這些硬體的內部狀態無法透過核心的 fork 簡單複製,導致子行程的加速功能失效或行為異常。
計算機結構發展帶來效益,代價卻是管理開銷的增加。以分頁表為例,如果你覺得 32 位元系統一個稀疏定址空間的行程分頁表開銷尚可接受,那來計算 64 位元系統中一個稀疏定址空間的分頁表開銷。在這樣的背景下,fork 機制即便輔以 CoW,系統也將不堪重負 (分頁表的 CoW 以及分頁錯誤的例外處理)
> Linux 在 Aarch64 (64 位元的 Arm 架構) 的記憶體支援中,甚至允許指定 3 層或 4 層的虛擬記憶體轉換表,分別對應到 39 位元虛擬定址空間 (即 512 GiB) 或 48 位元虛擬定址空間 (即 256 TiB),其實就是避免分頁開銷過度增長。
> 參見: [Memory Layout on AArch64 Linux](https://www.kernel.org/doc/Documentation/arm64/memory.txt)
若從今日的工程實務回頭看,fork 更適合保留為可組合的歷史產物,而把建立新程式的常見路徑逐步交給語意更明確的介面處理。
fork 不僅是個擁有超過 50 年歷史的古老系統呼叫,也是 Unix 介面設計最具代表性的例子之一。它沒有參數,語意卻極強,讓「建立子行程」這件事變成可組合的基本原語。不過也正因為它過度簡潔,才把大量設定工作推遲到 `fork` 之後、`execve` 之前處理。對照之下,Windows 的 [CreateProcess](https://learn.microsoft.com/en-us/windows/win32/procthread/creating-processes) 把更多屬性顯式收進單一呼叫,介面較繁複,但也較不依賴事後修補。
## 貫徹「懶惰」思維的 fork
C 語言教科書往往無法簡明地交代 fork 系統呼叫的使用,因為 fork 不符合 C 語言函式呼叫慣例。C 語言和作業系統原本是兩門正交的課程:C 語言標準函式可在沒有作業系統的微控制器上使用,但 fork 通常無法。
若想要理解 fork 的回傳值,要先理解作業系統行程。換句話說,對 fork 的理解依賴作業系統,包含以下系統呼叫:
1. `execl` 和 `execv` 系列: 正確執行時,不會返回,也就沒有回傳值;
2. `fork`: 正確執行時,2 個回傳值;
為何一個函式可返回兩次?按照對 C 語言函式的認知,建立行程的函式原型宣告應該要長這樣:
```c
int create_process(void *(*start)(void*), void *arg, ...);
```
然後可在 `start` (看作是 [callback function](https://en.wikipedia.org/wiki/Callback_(computer_programming))) 裡頭呼叫 `exec` 來載入新的執行檔。
和上述 `create_process` 相比,fork 的介面顯得不尋常:沒有入口函式指標,沒有參數傳遞,卻要在單一呼叫中返回兩次。這種設計在哪些場景下仍是好原語、在哪些場景下已不再適用,是後續段落要探討的主軸。
Linux 不也有個 [clone](https://man7.org/linux/man-pages/man2/clone.2.html) 系統呼叫嗎?用法如下:
```c
#define _GNU_SOURCE
#include <sched.h>
int clone(int (*fn)(void *), void *child_stack,
int flags, void *arg, ...
/* pid_t *ptid, void *newtls, pid_t *ctid */ );
```
我們來看 clone 的手冊:
> [clone()](https://man7.org/linux/man-pages/man2/clone.2.html) creates a new process, in a manner similar to [fork(2)](https://man7.org/linux/man-pages/man2/fork.2.html).
> …
> When the child process is created with clone(), it commences execution by calling the function pointed to by the argument fn. (This differs from fork(2), where execution continues in the child from the point of the fork(2) call.) The arg argument is passed as the argument of the function fn.
光是 clone 的參數列表,跟 Windows API 的常見冗長風格有得拼,與 UNIX 崇尚的簡潔風格大異其趣。
fork 不需要任何參數,也就是說,你沒辦法在建立一個新行程之前去設定這個新行程的任何參數,比如優先等級等。在呼叫 fork 之前什麼都沒有,然而在 fork 呼叫結束後就什麼都有。也就是說,新行程繼承親代行程的一切!
記住,一切都繼承自親代行程,連同程式碼的行為也是。所以說,你想要設定新行程的優先權,你就必須在新行程中進行變更:
```c
if (fork() == 0) {
// 變更優先等級
nice(-3);
} else {
...
}
```
而非這樣:
```c
int prio = -3;
ret = create_process(new_process, &argv[0], prio, ...);
```
那麼,為何一切都繼承親代行程?因為懶!這可是 Dennis Ritchie 自己說的話。
Genie/BTSS 常被視為早期 `fork` 觀念的重要實作者,而 Unix 則把這個概念進一步簡化成無參數、預設完整繼承的系統呼叫。
這種簡化極為成功,因為它讓 `fork` 介面異常容易使用,也讓 shell 的管線、重導向與行程控制得以快速普及。不過代價同樣明確:凡是不想繼承全部狀態的情境,都只能在 `fork` 之後額外修補,於是後來才陸續出現 CoW、`FD_CLOEXEC`、`MADV_DONTFORK`、`MADV_WIPEONFORK` 與 `posix_spawn` 等補強。
Unix 作業系統的 fork 取巧實作留下坑洞,促使後來的 CoW (Copy on Write) 來填坑,卻仍未能填平。
在 Unix 剛出現的年代,記憶體空間很小,一般的行程也很小,所以 fork 中完全複製親代行程沒有問題。然而隨著較大的行程出現,記憶體開銷開始越來越大,才採用 CoW 手法來緩解這種大的記憶體開銷。
即便使用 CoW,但對於資料結構的複雜操作仍無法忽略,這點在稍後的程式碼會證實。
Linux 核心是個類似 Unix 的系統核心,其原始程式碼唾手可得。現在只要提到 Unix,Linux 都可作為替代。AIX, Solaris, HP-UX 這種老牌經典 Unix 現在已不易取得和實驗 (通常根本缺乏 x86 硬體架構的支援),所以一般用 Linux 來替代。
以下展示的程式碼都針對 Linux。
### fork 的失敗模式
fork 需要配置 `task_struct`、核心堆疊、分頁表等核心資源,在資源耗盡時會失敗並回傳 -1。常見的 errno 值:
* `EAGAIN`:已達使用者的行程數上限 (`RLIMIT_NPROC`)、系統全域執行緒上限 (`/proc/sys/kernel/threads-max`)、PID 上限 (`/proc/sys/kernel/pid_max`),或 cgroup 的 pids controller 配額用盡
* `ENOMEM`:實體記憶體不足以配置 `task_struct` 或分頁表,系統瀕臨觸發 OOM killer
* `ENOSYS`:在無 MMU 的嵌入式架構 (如 [uClinux](https://en.wikipedia.org/wiki/UClinux)) 上,`fork` 因無法建立獨立虛擬定址空間而未實作,此時唯一的選擇是 `vfork`
在容器化環境中,`EAGAIN` 最容易被觸發:cgroup 的 pids controller 限制 Linux 容器內的行程總數,即使宿主機資源充裕,容器內的 fork 仍可能失敗。這是許多容器化部署中 fork bomb 防護的基礎。
## fork 的開銷
如果你去參加科技公司面試,一提到 fork,預期的標準答案似乎都是
> 「避免用行程,因為建立行程的開銷太大,儘量用執行緒」
席間,或許還會談到執行緒會共享記憶體空間,而行程不會。更進一步,如果採用 fork 子行程的話,切換定址空間需要切換分頁表...切換分頁表就一定不好嗎?不好,為何?因為牽涉分頁表切換、TLB 狀態與記憶體管理資料結構的處理成本,而不只是單純清除 cache 這麼簡化。
fork 尚有明確的開銷是圍繞於 CoW,後者會帶來可觀的分頁錯誤,而處理分頁錯誤需要付出時間。接下來探討避開 cache 和分頁錯誤,來一窺 fork 過程那些除此之外隱藏的開銷,關注一些不為人知的祕密。
### fork 在 Linux 核心的開銷
通常,大樓愈低,電梯配給越少、可居住的比例則高,反之,如果樓層數量偏高,光是電梯就要很多部 (公共設施也更多,特別是符合安全規範),留下的住宅空間比例自然也就越低。
同樣,在作業系統中也千萬不要忽略核心資料結構的開銷。本文講的是 fork,與其有關的兩種資料結構也該提及:
1. 分頁目錄和分頁表
2. `vm_area_struct`
先說分頁表的開銷,在行程的定址空間比較稀疏的情況下,光是分頁表就會佔據很大的記憶體空間,在 64 位元的系統中這問題會更嚴重。多層次的分頁表主要解決稠密定址空間不必要的分頁表配置問題,本身無法節省記憶體。相反地,在稀疏定址空間,這樣做更會浪費記憶體空間。
參見下圖,我們構建一個稀疏的定址空間的多層分頁表。若僅有一層,只有葉節點需要佔據記憶體,多層分頁表則整棵樹都要佔據記憶體空間:
```graphviz
digraph Graph7 {
rankdir=LR;
node [shape=record]
page_f [label="<f0>分頁目錄|<f1>|<f2>|<f3>|<f4>...||||||"];
page_m1 [label="分頁中間目錄|<f1>|<f2>...|<f3>"];
page_m2 [label="分頁中間目錄|<f1>|<f2>...|<f3>"];
page_m3 [label="分頁中間目錄|<f1>|<f2>...|<f3>"];
page_m4 [label="分頁中間目錄|<f1>|<f2>...|<f3>"];
page_c [label="<f0>分頁表|<f1>|<f2>|<f3>|<f4>...|<f5>|<f6>||||"];
page_f:f1 -> page_m1:f1
page_f:f5 -> page_m2:f1
page_m1:f1 -> page_m3:f1
page_m2:f3 -> page_m4:f1
page_m3:f1 -> page_c:f1
}
```
稍後將藉由程式構建一個稀疏的定址空間,以此放大 fork 呼叫的 CoW 帶來的分頁表開銷。
再來看 `vm_area_struct`,我們知道使用者層級行程申請的每塊記憶體空間,在系統核心中均以 `vm_area_struct` 所維護。如果我呼叫 10000 次 mmap,那就有 10000 個 `vm_area_struct` 物件被建立。在 fork 呼叫中,即使沒有任何對記憶體寫入的操作,這 10000 個 `vm_area_struct` 依然會被複製。因此在這個例子中,一次 fork 呼叫的記憶體消耗至少是 `10000 * sizeof(struct vm_area_struct)`
這往往沒必要,因為子行程一般都會呼叫 `exec`,從而釋放掉這些定址空間的記憶體以及對應的 `vm_area_struct` 物件。
之所以來這麼一齣,完全是迎合那個沒有參數的 fork!
### fork 帶來的記憶體開銷
儘管 CoW 旨在延遲並減少實體記憶體的複製,但它無法完全避免記憶體管理 metadata 的開銷。以下程式碼以一個分配 16 GiB 定址空間並逐頁寫入的親代行程為例,觀察 `fork` 過程中的分頁表 (Page Table) 開銷:
```c
/* page-table-mm-usage.c */
#include <string.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
#include <sys/types.h>
#include <sys/mman.h>
#define GIG (1ul << 30)
#define NGIG 16
#define SIZE (NGIG * GIG)
#define BUFFER_SIZE 512
unsigned int parser(char *buffer, const char *symbol)
{
if (strstr(buffer, symbol)) {
char dump1[BUFFER_SIZE];
char dump2[BUFFER_SIZE];
unsigned int tmp;
sscanf(buffer, "%s %u %s", dump1, &tmp, dump2);
return tmp;
}
return 0;
}
void read_file(const char *prefix)
{
int pid = (int)getpid();
char file[BUFFER_SIZE] = { 0 };
char buffer[BUFFER_SIZE] = { 0 };
FILE *fp = NULL;
unsigned int vmPTE = 0;
sprintf(file, "/proc/%d/status", pid);
fp = fopen(file, "r");
if (!fp) {
perror("fopen");
exit(1);
}
while (fgets(buffer, BUFFER_SIZE, fp)) {
buffer[BUFFER_SIZE - 1] = '\0';
vmPTE += parser(buffer, "VmPTE");
memset(buffer, '0', BUFFER_SIZE);
}
fclose(fp);
printf("[%s] [PID %d] VmPTE:%u kB\n", prefix, pid, vmPTE);
fflush(stdout);
}
void touch(char *p, int page_size)
{
/* Touch every page */
for (long i = 0; i < (long) SIZE; i += page_size)
p[i] = 0;
}
int main(void)
{
int pid, page_size = getpagesize();
char *p = NULL;
p = mmap(NULL, SIZE, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS,
-1, 0);
if (p == MAP_FAILED) {
perror("mmap");
exit(1);
}
madvise(p, SIZE, MADV_NOHUGEPAGE);
touch(p, page_size);
pid = fork();
if (pid == 0) {
sleep(4);
read_file("Child");
if (execl("/bin/bash", "bash", "script.sh", NULL) < 0)
perror("execl");
exit(0);
}
read_file("Parent");
waitpid(pid, NULL, 0);
return 0;
}
```
測試用的腳本: (檔名 `script.sh`)
```shell
#!/usr/bin/env bash
echo -n "[exec] [PID $$] VmPTE:"
awk '/VmPTE:/{ sum = $2 } END { printf "%u", sum }' /proc/$$/status
echo " kB"
```
編譯及執行:
```shell
$ gcc -o page-table-mm-usage page-table-mm-usage.c
$ ./page-table-mm-usage
```
> 注意:`read_file` 中的 `fflush(stdout)` 不可省略。若 stdout 為 fully-buffered (如輸出導向管線或檔案),`execl` 以新映像取代行程後,尚在緩衝區的 `[Child]` 輸出會隨舊行程記憶體一同消失,這本身就是 fork + exec 常見的陷阱。
在 AArch64 (4 KiB page) 主機上得到類似以下輸出:
```
[Parent] [PID 3238203] VmPTE:32884 kB
[Child] [PID 3238217] VmPTE:32880 kB
[exec] [PID 3238217] VmPTE:56 kB
```
親代行程配置 16 GiB 記憶體並逐頁寫入,從第一行輸出可見 PTE 佔用約 32 MiB (AArch64 四層轉換表,16 GiB / 4 KiB = 4M 頁,每項 PTE 8 bytes)。`fork` 後子行程完整複製分頁表,VmPTE 幾乎相同。呼叫 `exec` 後驟降至 56 kB,可見 fork 到 exec 之間那份龐大的分頁表對此類工作負載而言幾乎全屬過渡成本。
為了量化 fork 延遲如何隨親代行程記憶體成長,可用以下程式逐步增加 mmap 區域 (256 MiB 至 8 GiB),每步 fork 一次並測量延遲:
```c
/* fork-latency-vs-rss.c */
#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/mman.h>
#include <sys/wait.h>
#include <time.h>
static long elapsed_us(struct timespec *a, struct timespec *b)
{
return (b->tv_sec - a->tv_sec) * 1000000L +
(b->tv_nsec - a->tv_nsec) / 1000;
}
int main(void)
{
int page_size = getpagesize();
size_t chunk = 256UL * 1024 * 1024; /* 每步 256 MiB */
int steps = 32; /* 最多 8 GiB */
printf("rss_mb,fork_us\n");
for (int s = 1; s <= steps; s++) {
size_t total = chunk * s;
char *p = mmap(NULL, total, PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
if (p == MAP_FAILED) break;
madvise(p, total, MADV_NOHUGEPAGE);
for (size_t i = 0; i < total; i += page_size)
p[i] = 1;
struct timespec t0, t1;
clock_gettime(CLOCK_MONOTONIC, &t0);
pid_t pid = fork();
if (pid == 0)
_exit(0);
clock_gettime(CLOCK_MONOTONIC, &t1);
waitpid(pid, NULL, 0);
printf("%zu,%ld\n", total >> 20, elapsed_us(&t0, &t1));
munmap(p, total);
}
return 0;
}
```
在 AArch64 (224 核,125 GiB RAM) 上的結果:
```
rss_mb,fork_us
256,10792
512,22397
1024,45750
2048,80411
4096,145443
8192,292174
```
fork 延遲從 256 MiB 的 10.8 ms 線性增長到 8 GiB 的 292 ms,斜率約 37 ms/GiB;在這個實驗設計下,主因可合理歸因於分頁表與相關記憶體管理結構的複製:

一條近乎完美的直線,讓記憶體越大、fork 越慢這個定性描述變為可量化的觀察。讀者可自行以 gnuplot 或 matplotlib 繪製:
```shell
$ ./fork-latency-vs-rss > fork-latency.csv
$ gnuplot -e "
set terminal png size 900,500 font 'sans,13';
set output 'fork-latency-vs-rss.png';
set datafile separator ',';
set xlabel 'Parent RSS (MiB)'; set ylabel 'fork() latency (ms)';
set title 'fork latency vs parent memory (AArch64, 4 KiB page)';
set grid; set key off;
plot 'fork-latency.csv' skip 1 using 1:(\$2/1000.0) with linespoints pt 7 lw 2
"
```
若只是利用 fork + exec 執行一個記憶體開銷極小的程式,可能會因親代行程的稀疏 (sparse) 定址空間,導致在 fork 時配置大量的分頁表,進而導致非必要的記憶體開銷。
上述實驗向我們闡明,在下面的條件被滿足的場景中,fork 呼叫光是分頁表的記憶體開銷相當巨大:
* 親代行程定址空間是 sparse 或 large 的;
* 子行程 exec 在親代行程之前複製親代行程的定址空間;
這些條件在大型伺服器的背景服務 (daemon) 中非常容易被滿足,例如 memcached 和 redis。若在記憶體吃緊之際誤用 fork,說不定會失敗,更嚴重甚至會觸發系統核心的 OOM ([out-of-memory](https://www.kernel.org/doc/gorman/html/understand/understand016.html))。
往往 fork 出來的子行程僅進行一些非常簡單的工作,這類分頁表開銷在工程上通常沒有太高的投資報酬。
這個問題之所以沒成為普遍的問題,確實部份原因是,並非所有的親代行程都是稀疏定址空間,且恰好親代行程的記憶體剛好都不是 file-backed mapping。這裡也補充一點:Linux 的 CoW 主要發生在使用者頁面的寫入時機,分頁表本身通常仍需在 fork 過程建立或複製對應結構,因此即便子行程很快就 `exec`,前述分頁表開銷依然可能存在。但這並不能掩蓋問題本身,上面的例子不就建立這樣的場景嗎?
此外,該議題不受重視還有個原因是,即便是稀疏定址空間的分頁表影響記憶體消耗,該消耗是稍縱即逝。一般而言,子行程會馬上呼叫 exec,對作業系統核心的影響彷彿是被針扎一下,稍後就無感。換言之,不是不想發現問題,而是過往的工具捕捉不到如此精細的事件,同樣的事情也發生在 Linux 核心中 CPU 排程器的負載平衡,詳見〈[The Linux Scheduler: a Decade of Wasted Cores](https://people.ece.ubc.ca/sasha/papers/eurosys16-final29.pdf)〉。
當然,該問題也可透過 vfork 解決,但這對親代行程顯得不公道。
在實務上,高效能伺服器 (如 Redis 建立 RDB 快照) 若仍須使用 fork,可透過 `madvise()` 標記不需要被子行程存取的巨大記憶體區塊:
```c
madvise(huge_buffer, buffer_size, MADV_DONTFORK);
```
標記為 `MADV_DONTFORK` 的區域在 fork 時不會被複製到子行程的定址空間,核心會直接跳過對應的分頁表項目。類似的還有 `MADV_WIPEONFORK` (Linux 4.14 起),它會在子行程中將對應頁面清零而非跳過,適合保護含有密碼學金鑰的記憶體區域。這些 `madvise` 旗標的存在本身就是 fork 語義感染 (semantic infection) 的證據。Baumann 等人在〈[A fork() in the road](https://www.microsoft.com/en-us/research/wp-content/uploads/2019/04/fork-hotos19.pdf)〉中指出,現行 POSIX 規範累積至少 25 項特殊情況 (涵蓋 file lock、async I/O、process tracing、timer 等子系統) 來規範 fork 時親代狀態該如何複製或不複製,加上 `O_CLOEXEC`、`FD_CLOEXEC`、`MADV_DONTFORK`、`MADV_WIPEONFORK`、`pthread_atfork` 等一系列事後修補旗標,fork 從原本 20 行組合語言就能實作的簡潔原語,膨脹為涉及記憶體管理、安全、並行等多重子系統的複雜操作。
### fork 帶來的 `vm_area_struct` 開銷
這個部份無關於 CoW。
fork 呼叫在核心內部,親代行程的整個定址空間會被複製到子行程。這裡的定址空間是以 `vm_area_struct` 來表達的。
這個複製的過程和結果帶來的影響很直接:
* 如果親代行程 `vm_area_struct` 物件非常多,複製的時間會非常長。
* 如果親代行程 `vm_area_struct` 物件非常多,子行程 `vm_area_struct` 物件佔用的記憶體就會很大。
和上個部份的分頁表記憶體佔用一樣,`vm_area_struct` 物件記憶體也常駐於核心空間的實體記憶體,用多少物件,實體記憶體就縮減多少。因此,實體記憶體緊縮的後果在 fork 中可能會發生,結論是建立子行程失敗,而根本的原因竟然是 fork 機制的全面複製不合理。
讓我們建立超級多的 `vm_area_struct` 物件。不難,呼叫超級多次 [mmap](https://man7.org/linux/man-pages/man2/mmap.2.html) 即可。
也許你把事情想簡單,下面程式碼可達到我們的要求嗎?
```c
for (i = 0; i < 100000000; i++) {
data = mmap(NULL, ps - 1, PROT_READ | PROT_WRITE, MAP_ANON | MAP_PRIVATE, -1, 0);
cnt++;
}
```
答案是不行!
Linux 核心的最佳化策略用漢語來說,就是「見縫插針」。如果你按照上面的邏輯呼叫 mmap,Linux 核心通常會把超級多個 mmap 區域 (即 `vm_area_struct` 物件) 合併成一個。可進行合併操作的前提很簡單,只要二個 `vm_area_struct` 物件的首尾連續即可。
為了不讓核心進行這種合併,我們要保留 mmap 的 FIXED 參數。
```c
for (i = 0; i < CNT; i ++) {
// FIX 映射 ps - 2 的大小,每次跨越一個頁面,阻止 vm 區域合併
data = mmap(base, ps - 2, PROT_READ | PROT_WRITE, MAP_ANON | MAP_PRIVATE | MAP_FIXED, -1, 0);
base += ps * 2;
cnt++;
}
```
以下完整程式將上述概念整合為可自動測量四階段 slab 用量的實驗:
```c
/* slab-spike.c */
#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/mman.h>
#include <sys/wait.h>
#define CNT 60000
static unsigned long read_slab_kb(void)
{
FILE *fp = fopen("/proc/meminfo", "r");
if (!fp) return 0;
char buf[256];
unsigned long val = 0;
while (fgets(buf, sizeof(buf), fp))
if (sscanf(buf, "Slab: %lu kB", &val) == 1)
break;
fclose(fp);
return val;
}
int main(void)
{
int ps = getpagesize();
char *base = (char *)0x100000000UL;
printf("stage,slab_kb,label\n");
printf("0,%lu,before mmap\n", read_slab_kb());
fflush(stdout);
for (int i = 0; i < CNT; i++) {
mmap(base, ps - 2, PROT_READ | PROT_WRITE,
MAP_ANON | MAP_PRIVATE | MAP_FIXED, -1, 0);
base += ps * 2;
}
printf("1,%lu,after %d mmaps\n", read_slab_kb(), CNT);
fflush(stdout);
pid_t pid = fork();
if (pid == 0) {
printf("2,%lu,child after fork\n", read_slab_kb());
fflush(stdout);
execlp("true", "true", NULL);
_exit(0);
}
waitpid(pid, NULL, 0);
printf("3,%lu,after child exec+exit\n", read_slab_kb());
return 0;
}
```
在 AArch64 主機上的執行結果 (slab delta 以基線為基準):
| 階段 | Slab (kB) | Delta |
|---|---|---|
| 0: before mmap | 5,101,360 | baseline |
| 1: after 60,000 mmaps | 5,119,584 | +18 MiB |
| 2: child after fork | 5,134,844 | +33 MiB |
| 3: after exec+exit | 5,120,696 | +19 MiB |
以下長條圖呈現四階段的 slab delta:

fork 讓 slab 從 +18 MiB 跳到 +33 MiB (子行程複製整份 VMA 結構),exec 後回落到接近 fork 前的水準。每個 `vm_area_struct` 含紅黑樹節點約 500-600 bytes,60,000 個恰好吻合觀察到的增量。
若嘗試用 `watch -d -n 1 free -m` 或 [slabtop](https://man7.org/linux/man-pages/man1/slabtop.1.html) 在另一個終端觀察,會發現完全抓不到這個尖峰,因為 fork + exec 的間隔遠短於 1 秒的輪詢週期。要捕捉這類瞬態開銷,可使用 [bpftrace](https://github.com/bpftrace/bpftrace) 直接追蹤核心的 `dup_mmap` 函式:
```
#!/usr/bin/env bpftrace
/* trace-fork-vma.bt — 在另一終端執行 sudo bpftrace trace-fork-vma.bt */
kprobe:dup_mmap { @start[tid] = nsecs; }
kretprobe:dup_mmap /@start[tid]/ {
$dur_us = (nsecs - @start[tid]) / 1000;
printf("pid=%d comm=%s dup_mmap=%d us\n", pid, comm, $dur_us);
@dup_mmap_us = hist($dur_us);
delete(@start[tid]);
}
```
同時執行 `./slab-spike`,可觀察到:
```
pid=3256627 comm=bash dup_mmap=103 us
pid=3256634 comm=slab-spike dup_mmap=53251 us ← 60,000 VMAs 的代價
```
一般 bash fork 僅需 ~100 μs 完成 `dup_mmap`,但帶有 60,000 個 VMA 的行程需要 53 ms,差距超過 500 倍。bpftrace 的直方圖輸出會自動分桶,在 `Ctrl-C` 結束時印出,清楚區隔正常 fork 與重載 fork。
不要小看這個轉瞬即逝的記憶體用量激增現象。如果剛好此時網路子系統需要配置 skb,就可能因記憶體不足而失敗。問題難以排除,讓系統管理員焦急:「記憶體明明夠用,也沒有碎片化,為何 skb 的配置會失敗呢?」
## fork 帶來的死結問題
UNIX 作業系統發展的前期,根本就沒有執行緒的概念。那個時候行程就是一切,而行程的一切就是個獨享的定址空間。後來慢慢起變化:
* 執行緒出現,多個執行緒共享同個定址空間;
* 定址空間不再是一切,還包括很多其他不在主記憶體的硬體狀態上下文 (context);
對於 Linux 核心的實作,不管是執行緒還是行程 (只有一個執行緒的行程),一切都是 `task_struct`。fork 發生之際,子行程複製的僅是呼叫執行緒的 `task_struct`。倘若這時操作同一個定址空間的其他 task_struct 持有 lock,即便呼叫 fork 的 `task_struct` 並不知道這件事 (要持有 lock 後才知),但這個事實還是會悄無聲息地傳給子行程。子行程如果這時候想去持有 lock,會發生死結!

> 出處: [brothers conflict (at linux kernel)](https://turnoff.us/geek/brothers-conflict/)
根源是:多個 `task_struct` 在操作同一個定址空間,但 fork 只參照呼叫者的狀態進行定址空間的複製。
重現的程式碼很簡單:
```c
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
void *mmap_unmap(void *arg)
{
while (1) {
pthread_mutex_lock(&mutex);
sleep(4);
pthread_mutex_unlock(&mutex);
}
}
int main(int argc, char *argv[])
{
pthread_t tid;
pthread_create(&tid, NULL, mmap_unmap, NULL);
sleep(1);
if (fork() == 0) {
pthread_mutex_lock(&mutex);
pthread_mutex_unlock(&mutex);
printf("No deadlock\n");
}
sleep(1000);
return 0;
}
```
編譯並執行:
```shell
$ gcc -pthread -o fork-deadlock fork-deadlock.c
$ timeout 5 ./fork-deadlock; echo "exit: $?"
exit: 124
```
程式被 `timeout` 強制終止 (exit code 124),始終等不到 "No deadlock" 出現。子行程在 `pthread_mutex_lock` 處永久阻塞,因為持有該 mutex 的執行緒並未被複製到子行程的定址空間中。
由於這是 fork 自己的問題,所以 POSIX Thread 引入特殊的函式來處理:
```c
int pthread_atfork(void (*prepare)(void), void (*parent)(void), void (*child)(void));
```
和這個風格相似的還有類似 `FD_CLOEXEC` 這種,本來就是 fork 的事,fork 完全沒有參數,直接把問題丟給 exec。
## fork 的其他開銷
fork 呼叫的實作是無條件複製親代行程的整個定址空間所有 `vm_area_struct` 物件。複製的過程是需要持有 lock,也就是 `dup_mmap` 的操作。
```c
down_write_nested(&mm->mmap_sem, SINGLE_DEPTH_NESTING);
```
上述程式碼對應較舊核心版本的寫法;在較新的 Linux 核心中,`mmap_sem` 已改名為 `mmap_lock`,但要說明的現象不變:`fork` 在複製記憶體管理結構時,會和其他操作定址空間的路徑競爭同一把鎖。
這個號誌 (semaphore) 在所有操作定址空間的呼叫中都要拿。在多核多執行緒的場景下,如果執行緒頻繁操作定址空間的話,fork 呼叫則必然會與之產生競爭,徒增時間開銷。
僅為了 fork 實作的便利,竟然如此折騰。
前面提及分頁表、`vm_area_struct` 物件、鎖等帶來的空間或者時間開銷,及棘手的死結問題,還有嗎?
實在太多!來觀察 `copy_process` 這個函式,看看 fork 都需要複製什麼就知道。
fork 的確過時!在多執行緒、多核 SMP、分散式系統的時代,fork 不合時宜。
諷刺的是,在記憶體空間很小的年代,fork 還能被接受。如今記憶體如此廉價,為什麼 fork 就不合時宜呢?可見,空間開銷只是事情的一面,時間和空間開銷才讓 fork 病入膏肓。
### 微觀架構的衝擊:TLB 刷新與 cache 冷啟動
前面的分析集中在記憶體配置與分頁表複製,但 fork + exec 對處理器微觀架構的衝擊同樣顯著。
`execve` 成功後,雖然 PID 不變,但整個定址空間已被新程式映像取代。此時 CPU 的 TLB (Translation Lookaside Buffer) 中快取的虛擬-實體位址對映全部失效,必須進行大規模刷新。在支援 ASID (Address Space Identifier) 的架構 (如 AArch64) 上,核心會分配新的 ASID,舊項目自然失效;在不支援 ASID 的架構或 ASID 耗盡時,則需執行完整的 TLB flush。無論何種情況,新程式啟動初期都會面臨密集的 TLB miss,每次 miss 都需要走訪多層分頁表 (page table walk),在 AArch64 的四層轉換表配置下,最多需要 4 次記憶體存取才能解析一個虛擬位址。
L1/L2 cache 對新程式而言是冷的:指令快取 (I-cache) 沒有新程式的任何指令,資料快取 (D-cache) 沒有新程式的任何資料。分支預測器 (branch predictor) 和 µop cache 同樣需要重新訓練。這段暖機期 (warm-up period) 意味著即使是極小的程式,頻繁的 fork + exec 仍會對系統整體造成效能抖動 (jitter)。
在延遲敏感的場景 (如高頻交易、即時音訊處理) 中,一次 fork + exec 引發的 TLB/cache 冷啟動可能導致數百微秒至數毫秒的延遲尖峰,足以違反 service-level objective (SLO)。這也是為何此類系統傾向於在啟動階段預先建立所有需要的工作行程,而非在處理請求的熱路徑上呼叫 fork。
> 可用 `perf stat -e dTLB-load-misses,iTLB-load-misses,cache-misses` 量測 fork + exec 後的 TLB 與 cache miss 率。
### 資源洩漏的邊界:檔案描述子與殭屍行程
fork 預設複製親代行程的所有開啟檔案描述子。在多執行緒服務中,這意味著子行程會意外持有親代的資料庫連線、網路 socket、甚至加密 session。若子行程在 exec 前未關閉這些描述子,對端可能因連線未正常關閉而逾時或資源洩漏。
Linux 透過 `O_CLOEXEC` (open 時指定) 和 `FD_CLOEXEC` (fcntl 事後設定) 讓指定的描述子在 exec 時自動關閉。自 Linux 2.6.23 起,許多常用的檔案描述子建立介面都陸續新增 `_CLOEXEC` 變體 (如 `SOCK_CLOEXEC`、`EPOLL_CLOEXEC`、`pipe2` 的 `O_CLOEXEC` 旗標),正是因為在多執行緒環境中,open 和 fcntl 之間存在競態窗口:另一個執行緒可能在此窗口內呼叫 fork,導致尚未設定 `CLOEXEC` 的描述子被複製到子行程。
殭屍行程 (zombie) 是另一類容易被忽視的資源洩漏。子行程結束後,核心保留其 `task_struct` 中的結束狀態,等待親代呼叫 `wait` 回收。殭屍行程不佔用使用者空間記憶體 (定址空間已釋放),但佔用核心的 `task_struct` 槽位與 PID。系統的 PID 上限 (可透過 `/proc/sys/kernel/pid_max` 查詢,預設 32768 或 4194304) 是有限的,若親代行程持續 fork 卻不 wait,殭屍行程會逐漸耗盡可用 PID,最終導致系統無法建立任何新行程。
> 典型症狀:`fork: Resource temporarily unavailable`,但 `free -m` 顯示記憶體充足、CPU 使用率正常,問題出在 PID 耗盡。可用 `ps aux | grep -c Z` 計算殭屍行程數量。
## vfork 和 fork 的後繼者
[vfork](https://man7.org/linux/man-pages/man2/vfork.2.html) 與 fork 具有相同呼叫方式,但僅能在特定情況使用。[vfork](https://man7.org/linux/man-pages/man2/vfork.2.html) 源於 3BSD,後者是首個支援虛擬記憶體的 UNIX。較精確地說,`vfork()` 已不再列入 POSIX.1-2008,而 [posix_spawn](https://man7.org/linux/man-pages/man3/posix_spawn.3.html) 常被視為較現代、也較安全的替代路徑。
在發出一個 vfork 系統呼叫時,親代行程被暫停,直至子行程完成執行或被新的可執行映像取代 (經由 exec 家族的系統呼叫)。子行程借用親代行程的 MMU 設定和記憶體頁面,在親代行程與子行程之間共享,不進行複製,尤其是缺乏 CoW 的行為。因此,若子行程在任何共享頁面中進行修改,不會建立新的頁面,且修改的頁面對親代行程同樣可見。
System V 在 Release 4 (SVR4) 之前,不支援 [vfork](https://man7.org/linux/man-pages/man2/vfork.2.html),因為記憶體共享容易出錯:
> Vfork does not copy page tables so it is faster than the System V fork implementation. But the child process executes in the same physical address space as the parent process (until an exec or exit) and can thus overwrite the parent's data and stack. A dangerous situation could arise if a programmer uses vfork incorrectly, so the onus for calling vfork lies with the programmer. The difference between the System V approach and the BSD approach is philosophical: Should the kernel hide idiosyncrasies of its implementation from users, or should it allow sophisticated users the opportunity to take advantage of the implementation to do a logical function more efficiently?
> Maurice J. Bach
同樣,Linux 對 [vfork 的手冊頁面](https://man7.org/linux/man-pages/man2/vfork.2.html) 強烈不鼓勵使用:
> It is rather unfortunate that Linux revived this specter from the past. The BSD man page states: "This system call will be eliminated when proper system sharing mechanisms are implemented. Users should not depend on the memory sharing semantics of vfork() as it will, in that case, be made synonymous to fork(2)."
以下極簡範例展示 vfork 最具代表性的陷阱:堆疊損壞 (stack corruption)。因為子行程共享親代行程的堆疊,若在 `exec` 或 `_exit` 前從呼叫 `vfork` 的函式中 `return`,或修改區域變數,會直接破壞親代行程的 call stack:
```c
/* vfork-danger.c — 展示 vfork 堆疊損壞 */
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
void do_vfork(void)
{
int local_var = 42;
pid_t pid = vfork();
if (pid == 0) {
local_var = 999; /* 破壞親代的 stack frame */
_exit(0); /* 若改成 return 則更加致命 */
}
/* 親代恢復:local_var 可能已被子行程竄改 */
printf("parent: local_var = %d (expected 42)\n", local_var);
}
int main(void)
{
do_vfork();
return 0;
}
```
```shell
$ gcc -O0 -o vfork-danger vfork-danger.c
$ ./vfork-danger
parent: local_var = 999 (expected 42)
```
子行程寫入 `local_var = 999` 直接改變親代 stack frame 中的值。若子行程做更多操作 (如呼叫其他函式),就會覆蓋親代的 return address,導致親代恢復執行時 segmentation fault。這正是 BSD 手冊所警告的危險情況的具體展現。
> 注意:此行為屬未定義行為 (undefined behavior),不同編譯器最佳化等級的結果可能不同。使用 `-O0` 較容易重現。
使用 vfork 的其他問題包括 deadlock,並可能發生在多執行緒程式中,由於與動態連結造成非預期的行為。POSIX 引入 posix_spawn 函式家族,它結合 fork 和 exec 的動作,可實作為 fork 的程式庫常式 (如在 Linux),或者為了更好的效能而實作為 vfork (如 Solaris)。實務上,`posix_spawn` 的價值正在於它把「建立新程式」這件事包成較高階的介面,讓底層可依平台特性選擇較合適的實作。從 Linux man-pages 的角度看,`vfork` 的標準地位後來被移除,而 `posix_spawn` 則逐漸成為「我其實只是要啟動另一個程式」時更合理的 API。
### `vfork` 作為無 MMU 架構的唯一選擇
儘管 `vfork` 在具備記憶體管理單元 (MMU) 的現代主機上充滿危險與反面模式,但它在計算機歷史中曾扮演不可或缺的救星。在沒有 MMU 的嵌入式處理器上 (如早期的 ARM Cortex-M 或執行 [uClinux](https://en.wikipedia.org/wiki/UClinux) 的微控制器),系統只有單一的實體定址空間,根本無法實作硬體層級的虛擬記憶體隔離與 CoW。
在這種無 MMU 的環境下,傳統的 `fork` 通常無法實作。因此,暫停親代行程、讓子行程直接借用實體記憶體直到呼叫 `exec` 或 `_exit` 的 `vfork`,長期以來一直是這類系統上最重要、也最常見的可行路徑之一。作業系統的設計往往是硬體限制下的妥協,`vfork` 就是這類妥協的代表性產物。
### 探索中的方向:io_uring 行程建立
2022 年 Josh Triplett 提出在 [io_uring](https://man7.org/linux/man-pages/man7/io_uring.7.html) 中實作行程建立的構想,2024 年底又有更新的修補系列重新推進 ([LWN, Dec 2024](https://lwn.net/Articles/1002371/))。這類提案的核心目標,是把建立行程、設定屬性、搜尋執行檔、載入程式串成單一非同步流程,減少使用者空間與核心空間之間的往返。
就本文修訂時可查得的公開討論來看,相關設計仍在演進中,尚未成為主線 Linux 的穩定介面。現階段的工程實務裡,`posix_spawn` 或 `clone3` 搭配 pidfd 仍是較穩健的行程建立路徑。
---
## Linux 對於 fork 實作的手法
作為多行程的替代機制,多執行緒的本質和 fork 的原始意義其實沒有太大分歧,區別就是資源共享的深度不同。值得先指出:今日 Linux 上 glibc 的 `fork()` 並非直接對應傳統 UNIX 的 fork 系統呼叫。自 glibc 2.3.3 起,NPTL 提供的 `fork()` 包裝函式實際呼叫 `clone(SIGCHLD)`,僅以 `SIGCHLD` 旗標模擬傳統 fork 語意。換言之,在 Linux 上行程建立與執行緒建立共用同一套 `clone` 底層機制,差異僅在旗標組合。
若以今日核心程式碼的視角來看,`fork()`、`vfork()`、`clone()`、`clone3()` 其實只是同一條建立 task 路徑的不同前端。較粗略地說,呼叫會進到 `kernel_clone()`,核心主體工作則集中在 `copy_process()`:先配置新的 `task_struct` 與核心 stack,接著再依旗標決定哪些資源要複製、哪些只共享引用。對傳統 `fork()` 而言,這些子步驟大致包含:
1. `dup_task_struct()`:複製目前執行流最基本的 task 描述與 kernel stack 骨架;
2. `copy_mm()`:建立新的 `mm_struct`,並透過 `dup_mmap()` 複製 VMA 與分頁表骨架,資料頁面則延後到 CoW 時才真正分離;
3. `copy_files()`、`copy_fs()`:處理檔案描述子表與目前工作目錄、root directory 等檔案系統狀態;
4. `copy_sighand()`、`copy_signal()`:決定 signal handler table 與 thread-group 層級 signal 狀態要複製還是共享;
5. `copy_namespaces()`、`copy_io()`、`copy_thread()`:處理 namespace、I/O context 與 CPU 暫存器初始狀態,讓子 task 能從使用者空間看起來像是從同一個呼叫點返回。
也因此,`fork()` 真正昂貴的地方並非只在「複製記憶體」這一句話,Linux `fork(2)` 手冊提及,在 CoW 策略下,主要代價集中於複製親代的分頁表,及為子代建立獨立的 task 結構。本文後面觀察到的 `dup_mmap`、`VmPTE`、`vm_area_struct` 與 slab 尖峰,正是這條 `copy_process()` 路徑在大型定址空間上留下的具體痕跡。
Linux 核心的設計者很早就意識到這點:早期 Linux 核心不設計一個表示行程的結構體 (PCB),而是實作 `task_struct` (以下簡稱 `task`),該結構體包含讓指令序列得以執行所需的最基本單元,當然,隨著時間的推移,這個 `task_struct` 變得相當複雜,可參見 [Linux 核心設計: 不僅是個執行單元的 Process](https://hackmd.io/@sysprog/linux-process)。有趣的是,早期的 `task_struct` 不包含特指行程和執行緒的描述;一個 task 物件只是個原材料,它和其它 task 物件對資源的共享關係,決定它該是什麼。對應底層關於 task 靈活的設計,必須給予應用程式對應的程式介面,以適應這種靈活,也就是透過 Linux 的 clone 系統呼叫達成,後者在 Linux 核心很早期就已存在:

在傳統 UNIX 系統或者類 UNIX 系統,則沒有 clone 這樣的系統呼叫。箇中原因可能是 UNIX 一開始就明確定義行程的實作,到後來,當 UNIX 不得不支持執行緒的時候,就要引入一個所謂輕量級行程 (LWP) 的概念,意思是可共享某些資源的行程,這以 Solaris 的 LWP 實作最為出名。在這些老牌 Unix 系統中,一開始過重的行程概念在引入多執行緒機制時造成阻礙。然而對於 Linux,為了支持執行緒而引入新的資料結構則完全沒有必要,Linux 核心內部沒有表示 LWP 的結構體。
Linux 核心在底層 task 設計及系統呼叫介面如此這般的設計,注定它實作 POSIX Thread 規範可相當容易,一個參數的指定即可達成:

注意上述 `(since Linux 2.4.0)` 訊息,較精確地說,它表示 Linux 2.4 開始具備 thread group 與 `CLONE_THREAD` 這類核心語意,為較完整的 POSIX 執行緒模型鋪路。在此之前,Linux 主要依賴 [LinuxThreads](https://en.wikipedia.org/wiki/LinuxThreads) 這類以 `clone()` 拼裝出的早期實作;而後來較成熟、語意較接近 POSIX 的 NPTL,則是在 Linux 2.6 搭配 glibc 2.3.x 之後才真正普及。因此,2.4 是關鍵轉折點,但不宜直接等同於「NPTL 已完成」。
該如何建立一個執行緒呢?參考以下程式碼:
```c
#include <pthread.h>
#include <stdio.h>
void *func(void *unused) {
printf("sub thread\n");
return (void *)123;
}
int main(int argc, char **argv) {
pthread_t t1;
void *p;
pthread_create(&t1, NULL, *func, NULL);
pthread_join(t1, &p);
printf("main thread:%d\n", (int)p);
return 0;
}
```
關於執行緒,我們關注建立和摧毀的操作,用 `strace` 及選項 `-f` (`--follow-forks`) 來追蹤:

其中,clone 系統呼叫的 flags 參數的意思大致可表述如下:
* 黃色: 指示都共享哪些資源、記憶體管理、檔案描述子 (fd)、或檔案系統等等;
* 紅色: 實作 POSIX Thread 的語義,比如共享行程 PID、signal 傳遞等等;
clone 之後,就建立一個執行緒。執行緒執行 func 之後便退出。問題是,執行緒是如何退出的呢?
對於普通的 C 程式,我們知道 `main` 函式會回傳到 C runtime (詳見: [你所不知道的 C 語言: 執行階段程式庫 (CRT)](https://hackmd.io/@sysprog/c-runtime) 的討論),後者在 `main` 回傳後會呼叫 `exit(3)` 以結束整個行程。而在多執行緒程式中,新執行緒的起始函式回傳後,使用者空間執行緒函式庫會接手收尾,最後讓目前執行緒結束;若是最後一個執行緒或主執行緒結束整個行程,則會走到 `exit_group` 這類終止整個 thread group 的路徑。
概念上可把 `pthread_create` 想成下面這類包裝:
```c
void clone_func(Thread *thread) {
ret = thread->fn(...);
pthread_exit(ret);
}
int pthread_create(..., fn, ...) {
thread = malloc(sizeof(*thread));
thread->fn = fn;
ret = clone(clone_func, &thread);
return ERR_NO(ret);
}
```
從上述 `strace` 的結果可見,執行緒結束時,底層會看到只終止目前 task 的系統呼叫;而主行程結束時,常會看到 `exit_group`,也就是終止目前 thread group 的所有執行緒。這裡要區分二個層次:使用者空間常談的是 `pthread_exit()` 與 `exit(3)`,核心層看到的則是結束單一 task 或整個 thread group 的系統呼叫。但 `clone` 系統呼叫不僅實作多執行緒,它也被拿來改善傳統 `fork` 型行程建立的效率。對照傳統 UNIX 風格的 `fork`,Linux 的 `clone` 路線可概括如下:
1. 在執行新行程層面,Linux 後來也拿 `clone` 這條路線來實作較輕量的建立機制;例如 glibc 的 `posix_spawn` 會視情況採用 `clone` 搭配 `CLONE_VM | CLONE_VFORK` 來避免傳統 `fork` 的不必要複製;
2. 在並行多處理層面,如上述,clone 的 `CLONE_` 搭配 `CLONE_THREAD` 可實作核心層級的 POSIX Thread;
為了適應日益複雜的系統需求,Linux 核心近年引入以下強化機制:
* `clone3` 系統呼叫:`clone` 的參數過多 (超過 6 個) 導致暫存器不足,`clone3` 使用 `struct clone_args` 傳遞參數,具備更好的擴充性
* `pidfd` (process file descriptor):傳統 PID 會因回收再利用而導致訊號發送錯誤。透過 `CLONE_PIDFD` (在 `clone3` 中指定),建立者可取得代表該行程的檔案描述子,即便 PID 已被回收也不會誤操作
* `CLONE_INTO_CGROUP`:允許新行程在建立當下直接進入指定的 cgroup,消除容器環境中先建立行程再搬移 cgroup 的競態窗口
這些介面的共同方向很清楚:把過去依賴 PID 整數值與事後補救的流程,逐步改成建立當下就取得可辨識、可等待、可送訊號的明確控制介面。
以下範例展示如何以 clone3 搭配 `CLONE_PIDFD` 建立子行程,並透過 pidfd 傳送訊號:
```c
/* clone3-pidfd.c */
#define _GNU_SOURCE
#include <stdint.h>
#include <linux/sched.h>
#include <sched.h>
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <sys/syscall.h>
#include <sys/wait.h>
#include <unistd.h>
int main(void)
{
int pidfd = -1;
struct clone_args args = {
.flags = CLONE_PIDFD,
.pidfd = (uint64_t)(uintptr_t)&pidfd,
.exit_signal = SIGCHLD,
};
pid_t child = syscall(SYS_clone3, &args, sizeof(args));
if (child == 0) {
execlp("sleep", "sleep", "60", NULL);
_exit(1);
}
if (child < 0) { perror("clone3"); return 1; }
printf("child pid=%d, pidfd=%d\n", child, pidfd);
/* 透過 pidfd 傳送訊號,不受 PID 重用影響 */
if (syscall(SYS_pidfd_send_signal, pidfd, SIGTERM, NULL, 0) < 0)
perror("pidfd_send_signal");
siginfo_t info;
waitid(P_PIDFD, pidfd, &info, WEXITED);
close(pidfd);
printf("child terminated by signal %d\n", info.si_status);
return 0;
}
```
```shell
$ gcc -o clone3-pidfd clone3-pidfd.c
$ ./clone3-pidfd
child pid=3276589, pidfd=3
child terminated by signal 15
```
與傳統 fork 最大的差異在於:行程建立和 pidfd 取得是同一個不可分割的操作,不存在行程已建立但尚未取得 handle 的窗口。傳送訊號時使用 pidfd 而非 PID,即使目標行程已結束且 PID 被重用,`pidfd_send_signal` 會回傳 `ESRCH` 而非誤殺其他行程。以下時序圖呈現 PID 重用造成的競態條件:
```graphviz
digraph pid_reuse {
rankdir=LR;
node [shape=record fontsize=10];
t1 [label="t1\n行程 A (PID 1234)\n正在執行"];
t2 [label="t2\n行程 B 決定\nkill(1234, SIGKILL)"];
t3 [label="t3\n行程 A 結束\nPID 1234 被回收"];
t4 [label="t4\n新行程 C\n獲得 PID 1234"];
t5 [label="t5\nkill() 送達\n行程 C 被誤殺"];
t1 -> t2 -> t3 -> t4 -> t5;
}
```
> 行程 B 在 t2 決定終止行程 A,但訊號在 t5 才送達,此時 PID 1234 已指向無辜的行程 C。pidfd 解決此問題:它綁定的是行程本身而非 PID 數值,若行程 A 已結束,`pidfd_send_signal()` 回傳 `ESRCH`,不會波及行程 C。
### clone 與容器化技術
clone 的精細旗標控制不僅服務於執行緒,更催生現代容器化技術。一系列 `CLONE_NEW*` 旗標讓新行程擁有獨立的系統資源視野:
| 旗標 | 隔離範圍 | 引入版本 |
|---|---|---|
| `CLONE_NEWNS` | 掛載點 (mount namespace) | Linux 2.4.19 |
| `CLONE_NEWUTS` | 主機名稱 | Linux 2.6.19 |
| `CLONE_NEWPID` | PID 命名空間 | Linux 2.6.24 |
| `CLONE_NEWNET` | 網路堆疊 | Linux 2.6.29 |
| `CLONE_NEWUSER` | 使用者/群組 ID 對映 | Linux 3.8 |
| `CLONE_NEWCGROUP` | cgroup 根目錄 | Linux 4.6 |
[Docker](https://www.docker.com/)、[Podman](https://podman.io/)、[LXC](https://linuxcontainers.org/) 等容器引擎的核心就是對這些旗標的組合運用:一個容器本質上仍是行程,只是透過 `clone` 類介面建立,並在命名空間與 cgroup 上施加更細緻的隔離。這也是傳統無參數 `fork` 很難直接表達的能力。
### 資源共享的程度
Linux 統一以 `task_struct` + 旗標實作從行程到容器到執行緒的整個光譜,以下圖示呈現此設計的擴充性:
```graphviz
digraph sharing_spectrum {
rankdir=LR;
node [shape=box style=rounded fontsize=11];
fork [label="fork()\n幾乎不共享\n獨立定址空間\n獨立 fd 表"];
container [label="clone(CLONE_NEW*)\n共享核心\n隔離命名空間\n獨立定址空間"];
thread [label="clone(CLONE_THREAD)\n共享定址空間\n共享 fd 表\n共享訊號"];
fork -> container [label=" 增加共享 " style=dashed];
container -> thread [label=" 增加共享 " style=dashed];
{ rank=same; fork; container; thread; }
}
```
> 上方 (fork) 幾乎不共享任何資源;中間 (容器) 共享核心但隔離命名空間;下方 (執行緒) 共享定址空間與檔案描述子表。三者底層都是 `task_struct`,差異僅在 clone 旗標的組合。
Linux 繼承 fork 的相容性包袱,也逐步把 Conway 當年偏向「拆分執行流」的想法,轉譯為一組更細粒度的 task 建立與資源共享旗標。這些補強不是要推翻 fork,而是顯示 Linux 在維持 UNIX 相容性的同時,持續為現代行程管理補上更明確的介面。
### 現代程式語言如何繞過 fork
前述討論集中於 C 語言與 POSIX 環境,但現代高階語言的執行期 (runtime) 對 fork 的處理方式同樣值得關注。
Go 的 runtime 高度依賴多執行緒 (goroutine 透過 M:N scheduler 映射至 OS 執行緒),這使得裸 fork 幾乎不可行:fork 只複製呼叫執行緒,其他 runtime 執行緒 (GC、netpoller、scheduler) 全部消失,殘留的鎖幾乎必定導致死結。因此,Go 標準函式庫 [`os/exec`](https://pkg.go.dev/os/exec) 在 Linux 上的底層實作 (參見 `syscall.forkAndExecInChild`) 直接使用原始系統呼叫路徑,而非經由 glibc `fork()` 包裝;在不涉及 user namespace 的常見情況下,會使用 `CLONE_VFORK | CLONE_VM` 類型的旗標組合來避免不必要的定址空間複製。也因此,Go 對「先安全地建立子行程,再立刻 `exec`」這條路徑投入大量特別處理。
Rust 的 [`std::process::Command`](https://doc.rust-lang.org/std/process/struct.Command.html) 採雙路徑策略:程式路徑明確且無需 `pre_exec` hook 時,優先走 `posix_spawnp()` 快速路徑 (參見 [rust-lang/rust#77455](https://github.com/rust-lang/rust/pull/77455));需要 `chroot`、`setuid` 等複雜前置操作時才回退到 `fork` + `exec`。在 glibc < 2.24 的系統上,即使走快速路徑,`posix_spawn` 底層仍可能使用 `fork` 而非 `vfork`。
共同趨勢:以明確的屬性宣告取代隱式的全面複製,在行程建立前而非建立後指定所需資源。
### fork vs vfork vs posix_spawn:量化對比
前面定性分析 fork 的開銷,這裡用量化實驗直接測量三種行程建立路徑的延遲差異。以下程式在不同親代行程 RSS 下,各執行 500 次 `fork+exec`、`vfork+exec`、`posix_spawn`:
```c
/* spawn-bench.c */
#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <spawn.h>
#include <sys/mman.h>
#include <sys/wait.h>
#include <time.h>
extern char **environ;
static long elapsed_us(struct timespec *a, struct timespec *b)
{
return (b->tv_sec - a->tv_sec) * 1000000L +
(b->tv_nsec - a->tv_nsec) / 1000;
}
static long bench_fork_exec(int n)
{
struct timespec t0, t1;
clock_gettime(CLOCK_MONOTONIC, &t0);
for (int i = 0; i < n; i++) {
pid_t p = fork();
if (p == 0) { execl("/bin/true", "true", NULL); _exit(1); }
waitpid(p, NULL, 0);
}
clock_gettime(CLOCK_MONOTONIC, &t1);
return elapsed_us(&t0, &t1) / n;
}
static long bench_vfork_exec(int n)
{
struct timespec t0, t1;
clock_gettime(CLOCK_MONOTONIC, &t0);
for (int i = 0; i < n; i++) {
pid_t p = vfork();
if (p == 0) { execl("/bin/true", "true", NULL); _exit(1); }
waitpid(p, NULL, 0);
}
clock_gettime(CLOCK_MONOTONIC, &t1);
return elapsed_us(&t0, &t1) / n;
}
static long bench_posix_spawn(int n)
{
struct timespec t0, t1;
char *argv[] = { "true", NULL };
clock_gettime(CLOCK_MONOTONIC, &t0);
for (int i = 0; i < n; i++) {
pid_t p;
posix_spawn(&p, "/bin/true", NULL, NULL, argv, environ);
waitpid(p, NULL, 0);
}
clock_gettime(CLOCK_MONOTONIC, &t1);
return elapsed_us(&t0, &t1) / n;
}
int main(void)
{
int n = 500;
size_t alloc_mb[] = { 0, 64, 256, 1024, 2048 };
int nalloc = sizeof(alloc_mb) / sizeof(alloc_mb[0]);
printf("rss_mb,fork_exec_us,vfork_exec_us,posix_spawn_us\n");
for (int a = 0; a < nalloc; a++) {
char *p = NULL;
size_t sz = alloc_mb[a] * 1024UL * 1024;
if (sz > 0) {
p = mmap(NULL, sz, PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
if (p != MAP_FAILED) {
madvise(p, sz, MADV_NOHUGEPAGE);
int ps = getpagesize();
for (size_t i = 0; i < sz; i += ps) p[i] = 1;
}
}
long t_fork = bench_fork_exec(n);
long t_vfork = bench_vfork_exec(n);
long t_spawn = bench_posix_spawn(n);
printf("%zu,%ld,%ld,%ld\n", alloc_mb[a], t_fork, t_vfork, t_spawn);
fflush(stdout);
if (p && p != MAP_FAILED) munmap(p, sz);
}
return 0;
}
```
```shell
$ gcc -O2 -o spawn-bench spawn-bench.c
$ ./spawn-bench
```
在 AArch64 主機上的結果:
實驗前提如下:
* 4 KiB page,並以 `MADV_NOHUGEPAGE` 避免 huge page 影響;
* 子行程目標程式固定為 `/bin/true`,盡量把差異集中在建立成本本身;
* 數值主要用來觀察趨勢,而非宣稱跨機器可直接複製同一絕對延遲。
| 親代 RSS | fork+exec | vfork+exec | posix_spawn | fork 倍率 |
|---|---|---|---|---|
| 0 MiB | 1,026 μs | 822 μs | 842 μs | 1.2x |
| 64 MiB | 3,939 μs | 824 μs | 841 μs | 4.7x |
| 256 MiB | 12,053 μs | 828 μs | 842 μs | 14x |
| 1 GiB | 81,346 μs | 838 μs | 842 μs | 97x |
| 2 GiB | 187,179 μs | 833 μs | 843 μs | 222x |
以下以 log scale 呈現三條曲線的差異 (y 軸取對數才能同時看見三者):

在這組量測裡,`vfork` 和 `posix_spawn` 的延遲幾乎不受親代 RSS 影響 (~840 μs 附近),`fork` 則隨 RSS 顯著增長,在 2 GiB 時慢了超過 200 倍。
這組資料清楚回答之前的命題:「fork 的開銷究竟有多大?」答案是,取決於親代行程的記憶體佔用。對小型工具而言可忽略不計,對大型系統服務 (如 Redis、PostgreSQL) 而言則是致命的效能瓶頸。
讀者可自行以 gnuplot 重現 (注意 y 軸需取 log scale,否則 vfork/posix_spawn 會被壓平在 x 軸上):
```shell
$ ./spawn-bench > spawn-bench.csv
$ gnuplot -e "
set terminal png size 900,550 font 'sans,13';
set output 'spawn-bench.png';
set datafile separator ',';
set xlabel 'Parent RSS (MiB)'; set ylabel 'Latency (μs, log scale)';
set title 'fork+exec vs vfork+exec vs posix_spawn';
set grid; set logscale y; set key top left;
plot 'spawn-bench.csv' skip 1
using 1:2 with lp pt 7 lw 2 lc rgb '#E74C3C' title 'fork+exec',
'' using 1:3 with lp pt 9 lw 2 lc rgb '#27AE60' title 'vfork+exec',
'' using 1:4 with lp pt 5 lw 2 lc rgb '#2E86C1' title 'posix_spawn'
"
```
### CoW 分頁錯誤的核心開銷
前面的實驗測量 fork 本身的延遲,但 fork 的代價不止於此。fork 後,親代與子行程的分頁表項目 (PTE, Page Table Entry) 都會被核心標記為唯讀 (read-only)。當任何一方嘗試寫入時,CPU 的記憶體管理單元 (MMU) 會觸發硬體例外 (page fault,在 x86 上是 `#PF`)。核心隨即陷入例外處理常式 (對應 Linux 的 `do_wp_page` 函式),發現這是一次合法的 CoW 寫入,於是配置一個新的 4 KiB 實體記憶體頁面,將原頁面資料複製過去,並將 PTE 的權限改回可讀寫 (Read-Write),最後讓 CPU 重新執行觸發例外的寫入指令。
這套軟硬體協同機制雖然避免不必要的記憶體複製,但頻繁的 Page Fault 和記憶體配置本身就是極大的負擔。以下程式量化此成本:
```c
/* cow-faults.c */
#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/mman.h>
#include <sys/wait.h>
#include <time.h>
static long elapsed_us(struct timespec *a, struct timespec *b)
{
return (b->tv_sec - a->tv_sec) * 1000000L +
(b->tv_nsec - a->tv_nsec) / 1000;
}
int main(void)
{
size_t sz = 1UL << 30; /* 1 GiB */
int ps = getpagesize();
size_t npages = sz / ps;
char *p = mmap(NULL, sz, PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
if (p == MAP_FAILED) { perror("mmap"); return 1; }
madvise(p, sz, MADV_NOHUGEPAGE);
for (size_t i = 0; i < sz; i += ps)
p[i] = 'A';
printf("Parent: %zu pages populated (%zu MiB)\n", npages, sz >> 20);
fflush(stdout);
pid_t pid = fork();
if (pid == 0) {
struct timespec t0, t1;
clock_gettime(CLOCK_MONOTONIC, &t0);
for (size_t i = 0; i < sz; i += ps)
p[i] = 'B'; /* 每次寫入觸發 CoW fault */
clock_gettime(CLOCK_MONOTONIC, &t1);
printf("Child: wrote %zu pages in %ld ms (CoW faults)\n",
npages, elapsed_us(&t0, &t1) / 1000);
fflush(stdout);
_exit(0);
}
waitpid(pid, NULL, 0);
return 0;
}
```
搭配 `perf stat` 執行:
```shell
$ gcc -O2 -o cow-faults cow-faults.c
$ sudo perf stat -e page-faults,minor-faults,cpu-clock ./cow-faults
Parent: 262144 pages populated (1024 MiB)
Child: wrote 262144 pages in 988 ms (CoW faults)
Performance counter stats for './cow-faults':
524,379 page-faults
524,379 minor-faults
1,677.54 msec cpu-clock
0.057s user / 1.891s sys (97% 時間花在核心)
```
524,379 次 page fault 與理論上的 262,144 次親代初始寫入加上 262,144 次子行程 CoW 寫入大致相符,差額來自程式啟動與量測本身的額外 fault。輸出中 page-faults 與 minor-faults 數值一致,也表示這批 fault 幾乎全屬 minor fault (無磁碟 I/O)。若粗略以子行程 262,144 次 CoW fault 估算,每次 fault 約耗費數微秒量級,且 97% 的 CPU 時間花在核心模式。這解釋為何大 RSS 行程的 fork 之後,只要子行程一開始寫入繼承的記憶體,就會觸發顯著的效能衰退。
## 替代選擇與對 fork 的辯護
### Windows NT 的設計哲學
行為至此,主要討論 Unix/Linux 的 fork/exec 模型及其衍生問題。值得簡短對照的是,Windows NT 從一開始就選擇截然不同的路線。
Windows NT 的 [CreateProcess](https://learn.microsoft.com/en-us/windows/win32/procthread/creating-processes) 將「建立行程」與「載入執行檔」合併為單一操作,從不提供「複製目前行程」的機制。行程與執行緒在 Windows NT 核心中從設計之初就是完全分離的物件:行程是資源容器 (定址空間、handle table),執行緒是排程單位。建立新行程時,NT 核心直接為新定址空間載入目標執行檔的映像,不存在「先複製再覆蓋」的中間狀態。
這個設計決策讓 Windows 在若干面向上自然避開 fork 帶來的典型技術債:
* ASLR 在每次 CreateProcess 時獨立隨機化,不存在子行程繼承親代記憶體佈局的問題
* 不存在多執行緒下 fork 的典型死結風險,因為它不提供 fork 這類先複製目前行程、再於子行程繼續執行的原語
* 不需要 `O_CLOEXEC`、`MADV_DONTFORK`、`pthread_atfork` 等事後修補
代價是 CreateProcess 的介面遠比 fork 複雜 (10 個參數),且在 fork-to-exec 之間無法插入任意程式碼來設定子行程環境。Unix 的 fork + exec 分離設計讓 shell 的 I/O 重導向與管線操作極為自然;Windows 則必須透過 `STARTUPINFO` 結構體和 handle 繼承旗標來達成類似效果,彈性不如 Unix 但行為更明確。
二種設計反映不同的取捨:Unix 選擇簡潔的原語搭配事後組合,Windows NT 選擇明確的單次操作。半個世紀的演進顯示,Linux 正透過 clone3、pidfd、posix_spawn 逐步朝明確指定的方向移動,某種程度上是在向 NT 的設計直覺靠攏,只是路徑截然不同。
### 替代架構的嘗試
除 Windows NT 外,學術界對行程建立模型的探索從未停歇:
* MIT 的 Exokernel 計畫 (1995 年) 在 [ExOS](https://pdos.csail.mit.edu/archive/exo/) 中以使用者空間程式庫實作 fork,完全繞過核心,證明 fork 不必然是核心原語
* [CHORUS/MiX](https://en.wikipedia.org/wiki/Chorus_(operating_system)) 將 Unix 行程語意映射到微核心的訊息傳遞抽象上,fork、exec、kill 均以遠端程序呼叫 (RPC) 完成
* [Capsicum](https://www.cl.cam.ac.uk/research/security/capsicum/) capability 框架定義約 60 種細粒度權限,讓 fork + exec 繼承的 capability 可精確遮罩,介於純訊息傳遞與 MAC 之間
這些替代架構說明 fork 的語意可以在不同層次被拆解或重組,Unix 的特定實作並非唯一可能。
### 實務社群的反駁
儘管學術批評聲浪高漲,fork 在核心開發社群中仍有堅定的捍衛者。glibc 前核心維護者 [Ulrich Drepper](https://en.wikipedia.org/wiki/Ulrich_Drepper) 針對 Baumann 等人的論文提出反駁:多行程模型在穩定性與安全性方面優於多執行緒,因為 fork 讓程式能衍生具備獨立定址空間的工作行程 (worker process),無須承擔複雜的執行緒同步負擔。
對於小型工具、shell 與傳統 Unix 管線 (如 `make`),fork 的開銷可忽略,操作簡潔遠大於其對核心維護者帶來的技術債。核心社群的觀點是:除非替代方案能完整保留 fork 在 fork-to-exec 間執行任意程式碼的表達能力,否則貿然廢棄此介面缺乏實務正當性。
fork 的歷史告訴我們:一個因硬體限制而誕生的權宜之計,若恰好契合軟體設計的需求,仍能歷久彌新數十年;但若忽視其歷史脈絡,終將難以為繼。
## 結語
```graphviz
digraph timeline {
rankdir=LR;
node [shape=box style=rounded fontsize=10];
edge [arrowsize=0.7];
c63 [label="1963\nConway\nfork-join 理論"];
g65 [label="1965\nBerkeley\nTimesharing\nSystem"];
u69 [label="1969\nKen Unix\n覆疊 (overlay)"];
u71 [label="1971\nUnix V1\nfork 系統呼叫"];
v79 [label="1979\n3BSD\nvfork"];
c96 [label="1996\nLinux 2.0\nclone"];
n03 [label="2003\nLinux 2.6\nNPTL"];
s08 [label="2008\nPOSIX\n移除 vfork"];
h19 [label="2019\nHotOS'19\nfork() in\nthe road"];
p52 [label="2019\nLinux 5.2\nCLONE_PIDFD"];
c3 [label="2019\nLinux 5.3\nclone3"];
c63 -> g65 -> u69 -> u71 -> v79 -> c96 -> n03 -> s08 -> h19 -> p52 -> c3;
}
```
Conway 在 1963 年提出 fork 時,著眼的是多處理器平行層面,也就是如何把一條執行流程拆分成可獨立排程、可再度會合的工作單元。六十餘年後回頭看,fork 在 Unix 與 Linux 中早已不只是理論上的 fork-join 節點,而是賦予建立行程、繼承狀態、銜接 exec、等待回收等一整套作業系統語意。也正因如此,fork 一方面成就 shell、管線、重導向與行程控制這些 Unix 世界最經典的抽象,另一方面也把記憶體管理、檔案描述子、安全模型與多執行緒同步等複雜問題,一併帶進現代核心設計之中。
儘管 fork 招致許多議題,Linux 從未拋棄 fork,而是在維持相容性的前提下,持續以 clone、clone3、pidfd、posix_spawn 等機制補強它,把原本隱含在單一呼叫中的粗粒度語意,逐步拆解成更可控、更易於描述的資源共享與行程建立介面。從這個角度看,今日的 Linux 一方面仍承繼 Conway 當年關於並行與排程的核心思想,另一方面也清楚顯示,建立行程這件事在大型定址空間、多執行緒的執行環境與高度安全需求的環境裡,已不再適合完全仰賴 1970 年代那種簡潔但高度隱含的原語。
fork 是極具歷史意味的介面:它既是 Unix 設計哲學最耀眼的成果之一,也是現代核心工程不斷修補、重述與重新界定之標的。真正延續至今的,不只是 fork() 這個系統呼叫本身,而是 Conway 當年設想的根本問題,也就是系統該如何在有限的硬體之上,優雅地拆分、安排並回收執行中的工作。只是今天,這個問題的答案,已遠比當年複雜得多。