行程結束的兩種方式
在上一篇你看著一個行程誕生:父行程呼叫 fork 複製出自己、子行程呼叫 exec 載入一支新程式,於是整台機器的行程樹上又多長出一片葉子。事實證明,誕生是愉快的那一段。把一個行程「好好地」結束,比看起來要講究得多,而且一旦弄錯,就會留下一些需要核心去收拾的小爛攤子。所以,讓我們陪一個行程走到它的最後一口氣。
一個行程停止執行,大致有兩種方式。整潔的那種是自願的:它做完了該做的事,呼叫 exit,並交回一個叫做結束狀態(exit status)的小整數——長久以來的慣例是 0 代表成功,其他任何值則表示某種失敗。粗暴的那種是非自願的:核心從外部把它殺掉,也許是因為它嘗試了非法的記憶體存取,或是因為另一個行程送來一個會使它終止的訊號(日常的 `kill` 指令做的正是這件事)。無論哪一種,「結束」這個動作都由行程終止來描述,而且無論哪一種,核心的第一件工作都一樣:回收資源。
有哪些東西會被回收?這個行程的整個位址空間——它的文字段、資料段、堆積與堆疊——都會被解除對映,實體框架也被釋放。開啟的檔案被關閉、鎖被釋放、網路連線被拆除。你會以為這樣就把事情徹底做完了。但有一樣東西核心還不能丟掉,而正是這唯一的殘留物,幾乎是本篇每一個意外的源頭。
那件殘留物:還沒有人讀過的結束狀態
這裡有條讓其他一切都豁然開朗的規則:一個子行程的結束狀態,屬於它的父行程。當子行程結束,核心不能就這樣把它忘掉,因為父行程有權在稍後詢問:「我的孩子做得如何——成功還是失敗?」父行程透過呼叫 wait 來詢問,這個呼叫會阻塞,直到有個子行程結束,然後把那個小小的結束狀態整數交回來。這種「收取已死子行程狀態」的動作叫做回收(reaping),而那正是子行程最後一絲痕跡終於被掃除的時刻。
parent: pid = fork() child: ... do the work ...
if pid > 0: exit(0) # status = 0 (success)
status = wait() |
# <-- child fully gone v
else (pid == 0): [ZOMBIE: dead, but status not yet read]
... be the child ... ^
| parent's wait() reaps it here於是一個已結束的子行程處於一種奇怪的中間狀態:它的記憶體沒了,但它在核心行程表裡的那一筆紀錄必須繼續留著,只保存結束狀態與 PID,直到父行程呼叫 wait 把它們收走。一個恰好處於這種狀態——已死、但尚未被回收——的行程,就是殭屍行程。這名字陰森得貼切:它不再是個活著的行程(它不執行任何程式碼、不擁有任何記憶體),卻也還沒完全離開。它只是表中的一行,等著被讀取與釋放。
當父行程先死:孤兒與 PID 1
現在把時間軸翻過來。如果父行程死了,而它的子行程還開開心心地在跑,會怎樣?子行程不會跟著一起死——一個正在執行的行程,不會只因為它的父行程離開了就被拆除。但它現在有個嚴重的問題:當它最終結束時,誰來呼叫 wait 回收它?它的父行程不在了。一個父行程已終止、自己卻還在執行的子行程,叫做孤兒行程,而如果沒有任何介入,每隻孤兒最終都會變成一隻永遠無法被回收的殭屍。Unix 用一條優雅的規則解決了這件事。
當一個行程死去,核心會逐一走過它的子行程,把每一隻孤兒都重新認領給樹根那個最頂端的特殊行程:init 行程,它的行程 ID 永遠是 1,是開機時啟動的第一個使用者行程。PID 1 在它眾多職責裡有一項雖卑微卻至關重要:它反覆地呼叫 wait,於是任何落到它身上的孤兒,一結束就會被回收。可以把 init 想成那位安靜的大樓管理員,他默默收養每一個家人都搬走了的孩子,純粹是為了讓現場永遠有一個人能簽下最後那份文件。
有個簡單的辦法可以把兩者分清楚:殭屍有一個還活著、只是尚未回收它的父行程;孤兒則失去了父行程,被 PID 1 收養。殭屍是一份還沒人讀過的狀態;孤兒是一個沒人在看顧的孩子。它們是同一份親子契約裡,方向相反的兩種失敗——而一旦 init 收養了一隻孤兒,那隻孤兒就再也不可能自己變成一隻卡住的殭屍,因為 PID 1 永遠都在等著回收。
為什麼行程需要交談——以及為什麼這很難
圍繞著誕生與死亡的這一切儀式之所以存在,是因為行程是被刻意隔離的。在位址空間那篇你學過,每個行程都擁有自己私有的記憶體;一個行程根本無法伸手進另一個行程的堆積或堆疊。這種隔離是一項優點——它阻止了有問題的程式去破壞鄰居——但它引出一個明顯的問題:如果行程彼此被牆隔開,那合作中的程式究竟要怎麼分工?一個網頁瀏覽器、一條 shell 管線、一個資料庫與它的客戶端——全都是必須協調合作的眾多行程。答案是行程間通訊,簡稱 IPC:核心提供的一組通道,讓被隔離的行程能夠刻意地把資訊越過牆傳遞過去。
IPC 機制分為兩大家族,而這個劃分值得內化。在共享記憶體裡,核心把同一批實體框架對映進兩個行程的位址空間,於是它們可以直接讀寫一塊共同的區域——很快,因為設定好之後核心就讓到一旁去了。在訊息傳遞裡,行程從不共享記憶體;而是一方呼叫類似 send 的東西、另一方呼叫 receive,由核心在它們之間把資料複製過去。管線——你在兩個 shell 指令之間打的那個 `|`——就是訊息傳遞的日常面孔:左邊程式的輸出,被透過核心複製進右邊程式的輸入。
這兩個家族各自把同樣的兩件事往相反方向取捨。共享記憶體很快,卻很危險:兩個行程一旦同時寫入同一塊區域,你就又回到了競爭條件的世界,而你必須用同步機制——一個號誌或一把鎖——來守護共享資料,就和你為執行緒做的完全一樣。訊息傳遞較慢(每則訊息都是一次穿過核心的複製),卻較安全:既然沒有共享區域,就沒什麼可競爭的,而且即使兩個行程位於跨越網路的不同機器上,它也照樣行得通。沒有哪一個單純地比較好;它們只是「速度對安全」這支刻度盤上的不同位置。
把它兜起來:一個會回報的子行程
讓我們追蹤一個小巧而完整的故事,它用上了本階梯裡的一切。一個父行程想把某件工作平行地完成,於是它 fork 出一個子行程,子行程跑一段運算、把結果寫進一塊共享記憶體區域,然後以狀態 0 結束;父行程 wait、回收子行程,再讀取結果。看看建立、通訊與終止是如何環環相扣成一個乾淨的生命週期。
- 父行程先設好一塊共享記憶體區域(讓雙方都看到同一批框架),然後呼叫 fork。現在有兩個行程在跑著幾乎相同的副本,只靠 fork 的回傳值來區分:子行程裡是 0,父行程裡則是子行程的 PID。
- 子行程進行它的運算。當它有了答案,就把答案寫進共享區域——並由一個號誌守護著,好讓父行程絕不會讀到只寫了一半的值。
- 子行程呼叫 exit(0)。它的記憶體被釋放,但它的結束狀態留了下來:在這短短一瞬間它是一隻殭屍,在行程表裡佔著一行。
- 原本阻塞在 wait 裡的父行程被喚醒:它回收子行程(清掉殭屍,並得知狀態是 0=成功),接著從仍對映著的共享區域裡,讀出那個算好的答案。
請注意這個順序並非偶然。如果父行程忘了 wait,子行程就會一直是隻殭屍。如果父行程在子行程結束前就先退出,子行程就會變成孤兒、被 PID 1 收養。而如果兩者共享那塊記憶體卻沒有號誌,父行程就可能在寫到一半時讀到一個值。三篇之前,行程還是一道正在烹煮的食譜;現在你能看見整桌菜了——許多廚師,依需求開工,透過精心挑選的通道交談,每一位在他的菜上桌後都被收拾乾淨、結清帳目。