是時候淘汰對作業系統的 fork() 呼叫了
概述
一般觀點認為針對執行緒建立 Unix 的 fork() 與 exec() 的組合堪稱絕配,但微軟研究院與波士頓大學聯合發表的一篇論文則提出了相反的觀點。他們認為 fork 在當下早已過時,對作業系統和應用程式的設計弊大於利,並給出了一些替代 fork 的方案和未來的發展路線建議。
1 引言
當初人們在開發 Unix 的時候需要一種建立執行緒的機制,於是他們發明了一個特殊的系統呼叫:fork()。Fork 會建立一個與其父程序(fork 的呼叫者)相同的新程序,但系統呼叫的返回值除外。現在的開發者都習慣了 Unix 中用 fork() 加上 exec() 執行子程序中不同程式的用法,但相比非 Unix 系的作業系統來說,這種用法還是比較特立獨行的 [例如,1,30,33,54]。
50 年過去了,fork 仍然是 POSIX 上預設的執行緒建立 API:Atlidakis 等 [8] 發現有 1304 個 Ubuntu 包(佔總數的 7.2%)會呼叫 fork,相比之下更現代化的 posix_spawn() 只有 41 個包在用。幾乎所有的 Unix 核心系統、主流 Web 和資料庫伺服器(例如 Apache、PostgreSQL 和 Oracle)、Google Chrome、Redis 鍵值儲存甚至 Node.js 都在使用 fork。大家似乎認為 fork 是很好的設計。我們審查的幾本作業系統教科書 [4,7,9,35,75,78] 都對其持中立態度,甚至讚譽有加,而且常常會強調它相比其它方法的“簡單性”優勢。今天的專業課會教學生“fork 系統呼叫是 Unix 的偉大思想之一”[46],並且“設計建立執行緒的 API 有很多條路可選,而 fork() 和 exec() 的組合是其中既簡單又強大的一條路……Unix 的設計者選對了路”[7]。
如今我們就要糾正這種錯誤。Fork 是一種機制:它是上個時代遺留的產物,在現代作業系統中已經過時,甚至有很多害處。我們開發社群對 fork 很熟悉,但也會因此無視它的問題(§4,第 4 部分,下同)。公認 fork 存在的問題包括它沒有執行緒安全、低效且不可擴充套件,且帶來了安全問題。此外,fork 已經不再像當年一樣簡潔了;如今它會影響自己曾經正交過的其它所有作業系統抽象。此外,fork 面臨的一項根本挑戰在於,由於它將程序與其執行的地址空間混為一談,因此 fork 會阻礙作業系統功能的使用者模式實現,搞亂從緩衝 IO 到核心旁路網路的所有內容。也許最大的問題在於 fork 不支援 compose——但系統的每一層,從核心到最小的使用者模式庫都必須支援它。
我們使用在先前研究系統中獲得的經驗來說明 fork 對作業系統實現帶來的壞處(§5)。Fork 限制了作業系統研究者和開發者的創新能力,因為新的抽象都必須專門定做。有效支援 fork 和 exec 的系統被迫懶惰地複製每個程序狀態。這還促進了狀態的中心化,這是不用單核心構建的系統面臨的主要問題。另一方面,不支援 fork 的創新系統原型也無法執行大量需要 fork 支援的軟體。
我們最後討論了備選方案(§6)併發出了號召(§7):fork 應移除出我們系統的第一類原語,並用良好的模擬方法替換,為舊式應用程式提供相容性。僅向作業系統新增新原語是不夠的,fork 必須從核心中刪掉。
2 歷史起源:fork 最初是一種取巧
一般認為,最早實現 fork 操作的專案是 Project Genie 分時系統 [61]。Ritchie 和 Thompson [70] 聲稱 Unix fork“基本上和我們在 Genie 中實現的是一樣的”。但是,Genie 監視器的 fork 呼叫比 Unix 更靈活:它允許父程序為新的子程序指定地址空間和機器上下文 [49,71]。預設情況下,子程序共享其父程序的地址空間(有點像現代執行緒);根據需要也可以給子程序一個完全不同的記憶體塊的地址空間供使用者訪問;後者可能用來執行不同的程式。最重要的是,這裡沒有工具來複制地址空間,而是由 Unix 無條件完成的。
Ritchie [69] 後來指出“Unix 引入 fork 的主要原因可能是它比較容易實現,不用改變太多東西。”他接著講到了當年的 PDP-7 計算機如何用 27 行程式碼第一次實現了 fork,包括將當前程序複製到虛擬記憶體,並將子程序保留在記憶體中。Ritchie 還指出,Unix 的 fork-exec 組合“當其中的 exec 並不存在時,這個組合就會變得非常複雜;它的功能已經由 shell 使用顯式 IO 執行了。“
TENEX 作業系統 [18] 為 Unix 的路子提供了一個值得注意的反例。它也受到了 Project Genie 的影響,但它的發展和 Unix 互相獨立。它的設計者也為程序建立引入了 fork 呼叫,但與 Genie 更相似的是,TENEX fork 要麼共享了父程序之間的地址空間,要麼建立了具有空地址空間的子程序 [19]。它沒有 Unix 風格的地址空間複製,可能是因為它能用到虛擬記憶體硬體了。
Unix fork 不是一種“必然性”[61] 的產物。它只是一種權宜之計,照搬 PDP-7 中的實現而已;結果 50 年過去了,它卻已遍佈現代作業系統和應用程式了。
3 FORK API 的優點
當 Unix 為 PDP-11 計算機(其帶有記憶體轉換硬體,允許多個程序保留駐留)重寫時,只為了在 exec 中丟棄一個程序就複製程序的全部記憶體就已經很沒效率了。我們懷疑在 Unix 的早期發展階段,fork 之所以能倖存下來,主要是因為程式和記憶體都很小(PDP-11 上有隻 8 個 8 KiB 頁面),記憶體訪問速度相對於指令執行速度較快,而且它提供了一個合理的抽象。這裡有兩點很重要:
Fork 很簡單。除了易於實現之外,fork 還簡化了 Unix API。最明顯的是 fork 不需要引數,因為它為新程序的所有狀態簡單地提供了一個預設值:從父程序繼承過來。與之形成鮮明對比的是,Windows CreateProcess()API 採用顯式引數來指定子項核心狀態的所有細節——包括 10 個引數和許多可選 flag。
更重要的是,使用 fork 建立程序和啟動一個新程式是正交的,且 fork 和 exec 之間的空間有自己的用途。由於 fork 複製了父程序,因此允許程序修改其核心狀態的系統在發起呼叫後,可以在 exec 之前在子程序中複用:shell 在命令執行之前就可以開啟、關閉和重新對映文件描述符,並且程式可以減少許可權或更改子項的名稱空間以在受限上下文中執行它。
Fork 簡化了併發。在多執行緒或非同步 IO 流行之前的年代,不用 exec 的 fork 提供了有效的併發形式。在共享庫流行之前,它帶來了一種簡單的程式碼複用形式。程式可以初始化,解析其配置檔案,然後 fork 自身的多個副本,這些副本從相同的二進位制檔案中執行不同的函式,或處理不同的輸入。這種設計延續到了預 fork 伺服器中,我們會在§6 中再講。
4 現代的 fork
乍一看,fork 現在好像還是很簡潔。我們認為這是一個美麗的謊言,而且這種 fork 效應對現代應用來說弊大於利。
Fork 已經不再簡潔了。Fork 的語義已經影響了所有新的建立程序狀態的 API 設計。POSIX 規範列出了 25 個關於如何將父狀態複製到子程序 [63] 的具體情況:檔案鎖、定時器、非同步 IO 操作、跟蹤等等。此外,許多系統呼叫 flag 會控制 fork 的行為,如記憶體對映(Linux madvise()flag,MADV_DONTFORK/DOFORK/WIPEONFORK 等)、檔案描述符(O_CLOEXEC,FD_CLOEXEC)和多執行緒(pthread_atfork())。所有新式作業系統工具都必須用 fork 記錄其行為,並且必須準備好使用者模式庫,以便隨時 fork 它們的狀態。Fork 的簡潔性與正交性如今已蕩然無存。
Fork 不會 compose。因為 fork 複製了整個地址空間,所以它不適合在使用者模式下實現的作業系統抽象。緩衝 IO 就是一個典型的例子:使用者必須在 fork 之前顯式重新整理 IO,以免重複輸出 [73]。
Fork 是非執行緒安全的。今天的 Unix 程序支援多執行緒,但 fork 建立的子程序只有一個執行緒(呼叫執行緒的副本)。除非父程序對其他執行緒也逐個 fork,否則子地址空間最後可能會與父程序不一致。一個簡單但常見的情況是一個執行緒進行記憶體分配並持有堆鎖,而另一個執行緒 fork。任何在子程序中分配記憶體的嘗試(從而獲得相同的鎖)都將立即死鎖,等待永遠不會發生的解鎖操作。
程式設計指南建議不要在多執行緒程序中使用 fork,或者 fork 之後立即呼叫 exec [64,76,77]。POSIX 僅保證在 fork 和 exec 之間可以使用一小部分“非同步訊號安全”函式,特別是排除 malloc() 以及標準庫中其它可能分配記憶體或獲取鎖的標準庫中的內容。真正的多執行緒程式如果 fork,可能會在實踐中出現各種錯誤併為之困擾 [24-26,66]。
很難想象有哪位理智的核心維護者會在核心中加入一個有這麼多限制屬性的系統呼叫。
Fork 是不安全的。預設情況下,fork 出的子程序從其父程序繼承所有內容,並且程式設計師要負責顯式刪除子程序不需要的狀態:他要關閉檔案描述符(或將其標記為 close-on-exec)、從記憶體中清除機密 、使用 unshare()[52] 等隔離名稱空間。從安全形度來看,fork 的預設繼承行為違反了最小特權原則。此外,fork 但不執行的程式使地址空間佈局隨機化無效,因為每個程序都具有相同的記憶體佈局 [17]。
Fork 很慢。自 Thompson 首次應用 fork 以來的幾十年中,記憶體大小和相對訪問成本不斷增長。即使在 1979 年(當時第三個 BSD Unix 引入了 vfork()[15]),fork 已經有了效能問題,多虧了寫入時複製技術 [3,72] 才讓它的效能表現可以被接受。今天,就連建立寫時複製對映的時間也成了一個問題:Chrome 在 fork [28] 中的延遲長達 100 毫秒,並且在 exec 之前 fork 時,Node.js 應用會被阻塞幾秒鐘 [56] 之久。
Fork 現在太拖累效能了,所以 C 語言庫特意避免在 posix_spawn()[34,38] 中使用它,而 Solaris 將 spawn 用作了原生系統呼叫 [32]。但是,只要應用程式還是直接呼叫 fork,它們就會付出高昂的代價。圖 1 對比了在 3.6 GHz 的 Intel i7-6850K CPU 上,Ubuntu 16.04.3 下不同大小的程序 fork 和 exec 的時間。髒線顯示使用髒頁 fork 程序的開銷,必須將其降級為只讀來做寫入時複製對映。在碎片化的情況下,父物件只會汙染它的堆疊,但會通過交替分配只讀和讀寫頁面來模擬複雜應用的記憶體佈局,後者的複雜性體現在共享庫、隨機化地址空間和實時編譯等。相比之下,無論父程序的大小或記憶體佈局如何,posix_spawn() 需要相同的時間都一樣(大約 0.5 ms)。
Fork 無法擴充套件。在 Linux 中,設定 fork 的寫時複製對映所需的記憶體管理操作會損害可擴充套件性 [22,82],但真正的問題在更深的層次:正如 Clements 等人 [29] 所觀察到的,fork API 的規範本身就引入了一個瓶頸,因為(與 spawn 不同)它無法與程序上的其他操作通訊。其他因素進一步阻礙了 fork 的可擴充套件實現。直觀地說,擴充套件系統規模就要避免不必要的共享。Fork 程序啟動時就與其父程序共享所有內容。由於 fork 複製了程序作業系統狀態的所有方面,因此它鼓勵將該狀態集中在單體核心中,這樣複製和 / 或引用計數開銷較少。這樣就難以實現諸如用於安全性或可靠性的核心劃分了。
Fork 鼓勵記憶體過度使用。在考慮寫時複製頁面對映所使用的記憶體時,fork 的實現者面臨著一個艱難的選擇。這樣的頁面都代表了一個潛在的分配——如果頁面的任何副本被修改,將需要一個新的實體記憶體頁面來解決頁面錯誤。因此,保守的實現會讓 fork 呼叫失敗,除非有足夠的後備儲存來應對所有潛在的寫時複製錯誤 [55]。但是,當一個大程序執行 fork 和 exec 時,會建立許多寫時複製頁面對映但從不去修改,尤其是 exec 過的子程序很小時更是如此;並且因為最壞的分配情況(程序的虛擬空間加倍)無法實現就導致 fork 失敗是不可理喻的。
另一種方法,也就是 Linux 上的預設方法是過度使用虛擬記憶體:建立虛擬地址對映的操作(包括 fork 的地址空間的寫時複製克隆)無論是否存在足夠的後備儲存,都會立即成功。後續頁面錯誤(例如,對分 fork 頁面的寫入)可能無法分配所需的記憶體,而呼叫基於啟發式的“記憶體外殺手”來終止程序並釋放記憶體。
需要明確的是,Unix 並不需要過度使用,但我們認為寫入時複製 fork(而不是類似於 spawn 的工具)的廣泛應用讓這種現象氾濫了。現實應用程式並沒有準備好處理 fork [27,37,57] 中明顯虛假的記憶體不足錯誤。Redis 使用 fork 進行持久化,明確建議不要禁用記憶體過量提交 [67];否則,Redis 必須限制在總虛擬記憶體的一半用量,以避免在記憶體不足的情況下被殺死的風險。
總結。今天的 Fork 是適合單執行緒程序的 API,具有較小的記憶體佔用和簡單的記憶體佈局,需要對其子程序的執行環境進行細粒度控制,但不需要與它們完全隔離。換句話說,它是一個 shell。毫不奇怪,Unix shell 是第一個 fork [69] 的程式,fork 的支持者也會拿 shell 舉例作為其優雅的證明 [4,7]。但是,大多數現代程式都不是 shell。為了方便 shell 而去優化作業系統 API 現在還是個好主意嗎?
5 實現 fork
雖然很難量化在現有系統上實現 fork 的成本,但有明顯證據表明支援 fork 限制了作業系統體系結構的變化,並限制了作業系統適應硬體演進的能力。
Fork 與單個地址空間不相容。許多現代上下文將執行限制在單個地址空間,包括 picoprocess [42]、unikernels [53] 和 en- claves [14]。儘管有數量龐大作業系統研究者在使用並改進 Unix 系統,但如果研究者使用的是不基於 fork 的系統,那麼就更容易適應這些環境。
例如,Drawbridge libOS[65] 在隔離的使用者模式地址空間內實現二進位制相容的 Windows 執行時環境,稱為 picoprocess。Drawbridge 支援同一共享地址空間內的多個“虛擬程序”; CreateProcess() 是通過在地址空間的不同部分載入新的二進位制檔案和庫,然後建立一個單獨的執行緒來開始執行子程序,同時確保跨程序系統呼叫是按預期執行實現的。不用說,這些程序之間沒有安全隔離——主 picoprocess 負責提供安全邊界。然而,該模型已被用於在 SGX Enclave 內支援完整的多程序 Windows 環境 [14],使包含多程序和程式的複雜應用程式能夠部署在 enclave 中。
相比之下,fork 在單地址空間 [23] 中需要複雜的編譯器和連結調整 [81] 才能實現。因此,從 Unix 系統派生的 Unikernels 不支援內部多程序環境 [44,45],並且在 enclave 中執行多程序 Linux 應用程式要複雜得多。SCONE 和 SGX-LKL 僅支援單程序應用程式 [6,50]。Graphene-SGX [79] 通過在新的主機程序中建立一個新的介面來實現 fork,然後通過加密的 RPC 流複製父程序的記憶體;這套操作可能要花幾秒鐘的時間。
Fork 與異構硬體不相容。Fork 將程序的抽象與包含它的硬體地址空間混合在一起。實際上,fork 將程序的定義限制為單個地址空間,並且(如前所述)是在某個核心上執行的單個執行緒。
現代硬體和在其上執行的程式並不是這樣的機制。硬體越來越異構化,並且使用諸如帶核心旁路 NIC[12] 的 DPDK,或使用 GPU 的 OpenCL 的程序無法安全地 fork,因為作業系統無法複製 NIC/GPU 上的程序狀態。這種困境似乎已經困擾了 GPU 程式設計師至少十年 [58-60,74]。隨著未來的片上系統包含越來越多的有狀態加速器,這種情況只會變得更糟。
Fork 會感染整個系統。僅支援 fork 對系統的設計和執行時環境造成了很大的限制。任何層的高效 fork 都需要在其下的所有層上都有基於 fork 的實現。例如,Cygwin 是 Windows 的一個 POSIX 相容環境;它實現了 fork 以執行 Linux 應用程式。由於 Win32 API 缺少 fork,Cygwin 在 CreateProcess()[31,47] 之上模擬它:它建立一個新的程序,在恢復子程序之前執行與父程序相同的程式並複製所有可寫頁面(資料部分、堆、堆疊等)。這既不快也不可靠,並且可能由於多種原因而失敗,最常見的失敗是當父和子程序中的儲存器地址因地址空間佈局隨機化而不同時出現的。
諷刺的是,NT 核心本身支援 fork;只有 Cygwin 所依賴的 Win32 API 才沒有(使用者模式庫和系統服務不支援 fork,因此 fork 的 Win32 程序會崩潰)。作為一個抽象,fork 無法 compose:除非每個層都支援 fork,否則無法使用它。
在研究用作業系統中 fork:K42 的經驗
許多研究用作業系統都面臨著是否(以及如何)實現 fork 的困境,本文作者就親身經歷了 6 個這種案例 [13,36,41,48,51,80]。這種選擇至關重要。實現 fork 打開了支援大量 Unix 派生應用程式的大門,其中最先用到的是 shell 和構建工具,它們可以簡化整個系統的建立過程。然而 fork 也讓研究者束手束腳:但凡一個系統實現了 fork,尤其是想要高效實現 fork 或者在開發初期就引入 fork 的系統都會無可救藥地變成類 Unix 的設計。
K42 [48] 是基於我們在 Tornado [36] 的經驗上開發的系統,展示了對多處理器友好的面向物件方法、基於各個應用程式的可定製物件和微核心架構 [5] 的好處,以實現普遍的區域性性和併發優化。我們的目標是構建一個成熟的通用作業系統,在(可能)非常大規模的多處理器平臺上支援使用多作業系統特性的大量應用程式。最後,K42 與 POSIX 相容並且相容 Linux ABI,但是為 Linux 特性執行 fork 操作的設計導致 fork 語義顛覆了整個作業系統設計,對其它特性都帶來了負面影響。
我們開始以為我們能夠像 Cygwin 一樣實現 fork:作為使用者級庫函式,通過適當地構造必要的新物件例項來建立現有程序的子副本。這本身並不是個問題。相比之下,為了允許任何程序在任何時候都可 fork,並且在追求高效能表現的同時有效地做到這一點的努力最終失敗了——隨之而來的複雜性讓我們放棄了幾乎所有特性,只剩下對 Unix 的支援和對我們原生特性的支援了。
尤其嚴重的是,以下問題幾乎滲透到了系統的每個方面:
反模組化:只要是可能支援正在執行程序的物件實現就需要在程序 fork 時定義其行為。這讓實現專用元件變得非常複雜,這些元件的目的可能僅僅是為長期執行的並行科學計算或伺服器引入區域性優化而已,根本用不著 fork。
內在的懶惰需求:鑑於每個核心的資源,從記憶體區域和檔案到特定特性的抽象,諸如檔案描述符和訊號處理器都需要 fork 支援,我們只能實現懶惰寫時複製行為來緩解效能壓力。這不僅增加了單個物件中的複雜度,還需要物件互動來維護 fork 建立的層次關係。這與我們限制共享和同步的目標背道而馳,結果損害了局部性。
中心化:作業系統的可擴充套件性是通過避免中心化的策略和避免確切全域性知識的機制來實現的 [11]。因此,跨物件例項和伺服器的分解狀態和功能成為了我們的核心理念。但是,儘管 fork 是在庫程式碼中協調的,它還是需要與程序可能連線的所有伺服器和物件通訊。
可擴充套件性較差:除了違反我們的核心可擴充套件性原則之外,在 NUMA 系統中 fork 必須要麼訪問父程序位置的儲存器,要麼將子程序安排在系統的受控部分中;這些都是我們花費大量精力去解決的固有問題。
事後看來,我們犯了一個錯誤,沒有仔細評估 fork 的實際用例。如果我們將 K42 的 fork 侷限到單執行緒程序(例如 shell)上,我們就可以避免讓它的複雜性影響到核心物件了。
6 取代 fork
既然 fork 有這麼多問題,那麼該用什麼來取代它? 建立新程序會往往會引出混亂的 API 設計問題,因為任何選項都必須隱式或顯式地指定屬於新程序的所有作業系統資源的初始狀態。Fork 的應對很簡單:複製一切,結果如我們所見最後成為了 fork 的軟肋。為了取代 fork,我們提出了一個上層 spawn API 和一個底層微核心 API 的組合,以便在執行之前設定一個新程序。然後我們討論了無需 exec 的 fork 的替代方案。
上層:Spawn。在我們看來,fork 和 exec 的大多數功能最好改由 spawn API 提供。這種改動所需的重構工作可能會很棘手,尤其是當 fork 和 exec 在程式碼中的位置並不好找的時候;但正如我們在§4 中所示,這種方案的效能和可靠性有著顯著優勢,更不用說可移植性了。值得注意的是,使用 fork 的主流應用程式(例如,Apache,Chrome,PostgreSQL)的 Windows 埠並不支援 fork,因此 fork 顯然不是必需的。
posix_spawn()API 可以簡化這種重構。spawn 屬性不要求在單個呼叫站點上提供影響新程序的所有引數(如 CreateProcess() 的情況),而是由可擴充套件定義的輔助函式設定。例如,fork 之前的 close() 可以用預生成的呼叫替換,該呼叫記錄了在子程序中發生的“關閉動作”。不幸的是,這意味著 API 被指定為由 fork 和 exec 實現,儘管這實際上沒必要 [32]。
posix_spawn() 的主要缺點在於,它不是 fork 和 exec 的完整替代。它尚不支援一些不太常見的操作,例如設定終端屬性或切換到隔離的名稱空間;它還缺少有效的錯誤報告機制:在子程序開始執行之前發生的故障(例如無效的檔案描述符引數)是非同步報告的,並且與子程序的終止無法區分。這些缺點可以而且應該得到糾正。
替代方案:vfork()。這種流行的 fork 變體由 BSD 引入作為優化措施 [15];它建立了一個直到子程序呼叫 exec 前共享父地址空間的新程序,更像是原始的 Genie fork [71]。為了讓子程序能使用父程序的堆疊,它會阻止父程序執行,直到 exec 為止。這種程式設計風格類似於 fork,其中新程序在 exec 之前調整其核心狀態。但由於地址空間共享,vfork() 很難安全使用 [34]。雖然 vfork() 避免了克隆地址空間的成本,並且在難以重構程式碼使用 spawn 的場合可以用來替換 fork,但在大多數情況下最好別用它。
底層:跨程序操作。雖然大多數啟動新程式的例項都喜歡類似於 spawn 的 API,但為了完全通用,它需要一個 flag、引數或者輔助函式控制過程狀態的所有可能方面。單個作業系統 API 無法完全控制新程序的初始狀態。在今天的 Unix 中,高階用例的唯一後備仍然是在 fork 之後執行的程式碼,但是整潔狀態設計 [例如,40,43] 已經演示了一種替代模型,其中修改每個程序狀態的系統呼叫不僅限於當前程序,而可以操縱呼叫者能訪問的任何程序。這就帶來了 fork/exec 模型所有的靈活性和正交性,但避開了後者的大多數缺點:一個新的程序從一個空的地址空間開始,一個高階使用者可能以零碎的方式操作它,填充它的地址空間和核心執行前的上下文,無需克隆父項,也不需要在子項的上下文中執行程式碼。ExOS[43] 使用這種原語的頂層使用者模式實現了 fork。將跨程序 API 納入 Unix 看起來很有挑戰性,但也會對未來的研究有所幫助。
替代方案:clone()。這個系統呼叫是 Linux 上所有程序和執行緒建立的基礎。就像它之前的 Plan 9 的 rfork() 一樣,它需要單獨的 flag 來控制子程序的核心狀態:地址空間、檔案描述符表、名稱空間等。這避免了 fork 的一個問題:它的行為對於許多抽象是隱式的或未定義的。但是,對於每個資源都有兩個選項:在父項和子項之間共享資源,或者複製它。因此,clone 遇到了 fork 所面臨的大多數問題。
只使用 fork 的用例。一些特殊情況下,fork 後面並不會跟著需要複製父程序的 exec。
多程序伺服器。傳統上,構建併發伺服器的標準方法是 fork 程序。然而,推動多程序伺服器的動力早已不復存在:作業系統庫是執行緒安全的,並且困擾的多執行緒或事件驅動伺服器的可擴充套件性瓶頸已經消失 [10]。雖然從故障隔離的角度來看程序邊界可能還有其價值,但我們認為使用 spawn API 啟動這些程序更有意義。當大多數併發由多執行緒處理,且現代作業系統會對記憶體進行重複資料刪除的情況下,fork 帶來的共享初始狀態的效能優勢就沒那麼明顯了。最後,使用 fork 時所有程序要共享相同的地址空間佈局,並且容易受到盲目 ROP 攻擊 [17]。
寫時複製記憶體。fork 的現代實現使用寫時複製來減少複製很快會被丟棄的記憶體的開銷 [72]。由此以來許多應用程式只依賴 fork 來獲得對寫時複製記憶體的訪問許可權。一種常見模式是從預先初始化的程序中分離,以減少工作程序的啟動開銷和記憶體佔用,如 Linux 上 [4] 的 Android Zygote [39,62] 和 Chrome 站點隔離。另一種模式使用 fork 來捕獲正在執行的程序的地址空間的一致快照,允許父程序繼續執行;這包括 Redis [68] 中的永續性支援,以及一些反向偵錯程式 [21]。
POSIX 將受益於一個 API,它可以無需 fork 新程序就使用寫時複製記憶體功能。Bittau [16] 建議使用 checkpoint() 和 resume() 呼叫來獲取地址空間的寫時複製快照,從而減少安全隔離的開銷。最近,Xu 等人 [82] 觀察到 fork 花費的時間是影響 fuzzing 工具效能的主要因素,並提出了類似的 snapshot()API。這些設計尚不足以涵蓋上述所有用例,但也許可以作為新的起點。我們注意到,任何新的寫時複製記憶體 API 都必須解決§4 中描述的記憶體過度使用問題,但是將此問題與 fork 解耦應該處理起來會簡單些。
7 讓我的作業系統擺脫 fork!
我們已經解釋了為什麼 fork 已經是舊時代的老古董了,它會損害應用程式和作業系統的設計。我們必須做三件事來糾正這種情況。
棄用 fork。由於 Unix 的廣泛流行,未來的作業系統在很長時期內都需要支援 fork;但不管怎樣,50 年前的一種偏門技巧不應該決定未來作業系統的設計。因此,我們強烈建議不要在新程式碼中使用 fork,並嘗試將其從現有應用程式中刪除。一旦 fork 離開了效能關鍵路徑,它就可以從作業系統的核心中刪除,並根據需要重新實現。如果未來的系統僅在有限的情況下支援 fork,例如單執行緒程序 [2],那麼仍然可以在無需非必要的複雜性的情況下執行傳統軟體。
改進替代方案。很長一段時間裡,fork 已經成為類 Unix 系統上的通用程序建立機制,其他抽象層則疊在最頂層。值得慶幸的是這種情況已經開始改變 [32,38],但是還有更多工作要做(§6)。
修正我們的課本。顯然,學生需要學習 fork,但是目前大多數教科書(和教師)都使用 fork [7,35,78] 做例子來講解程序建立過程。這不僅會延長 fork 的生命期,而且是在灌輸過時的知識——這種 API 根本就不直觀。正如現代程式設計課程不會以 goto 開頭一樣,我們建議大家教授 posix_spawn() 或 CreateProcess(),然後將 fork 作為其歷史背景的特殊情況講一講就夠了(§2)。
本文的備註可在原始論文附註中檢視。
檢視英文原文: https://www.microsoft.com/en-us/research/publication/a-fork-in-the-road