書架上的食譜,與正在烹煮的食譜
在前面幾個階梯,你已經認識了作業系統,以及使用者程式與核心之間那條重要的界線。現在我們要談的,是整門學問賴以建立的核心概念。想像一本食譜靜靜躺在書架上:它只是紙與墨水——內容完整,卻毫無動靜,什麼事都沒發生。一旦廚師把它帶進廚房、備齊食材、開始切菜,一個全新的東西就誕生了:一份正在烹煮中的食譜,有切到一半的洋蔥、一鍋已經在煨煮的湯,還有一根手指按在目前進行到的那一步上。
這個分別,正好就是程式與行程的差異。程式是磁碟上一個被動的檔案——編譯好的指令,無論有沒有人去執行它,它都靜靜待在那裡。行程則是執行中的程式:它是活的、在運作、帶著「正在跑」所必需的所有進行中狀態。食譜是程式,烹煮才是行程。這就是程式與行程之分的核心,而把這一點弄清楚,後面的一切都會迎刃而解。
行程棲身之處:它的位址空間
當作業系統讓一支程式活起來時,會替它劃出一塊私有的記憶體區域,稱為位址空間——這是行程自己的廚房,與其他人的隔開來。每個行程都以為整台機器都歸它一個人用。在這塊空間裡,記憶體被劃分成幾個清楚的「街區」,下一篇導覽會帶你細看。現在先快速繞一圈:程式碼區段(text segment)存放真正的機器指令(也就是印出來的食譜步驟);資料區段(data segment)則存放整段執行期間都存在的變數(像是常備食材的儲藏室)。
有兩塊區域會隨著程式執行而伸縮。堆積(heap)像是開放的料理檯面,廚師需要更多空間時就隨手往那裡擴展——這是你在執行途中臨時要來的記憶體。堆疊(stack)則像一疊整齊的食譜卡,每張對應一件進行中的任務:程式每呼叫一個函式,就在最上面放一張新卡,函式回傳後就把那張卡丟掉。一般來說,堆積往高位址方向往上長、堆疊往低位址方向往下長,兩者之間留下一大片空隙,讓彼此都有伸展的餘地。
因此,對位址空間建立一張簡單的心智地圖,從低位址往上排是這樣:最底下是程式碼區段(text),上面是資料區段(以及一塊名為 bss、存放初值為零之變數的區域),再上去是往上生長的堆積,接著一大片空隙,最頂端則是由上往下生長的堆疊。中間那片空隙是刻意留的:它讓這兩塊會生長的區域,能盡可能久地各自擴張而不相撞。
核心的記事板:行程控制區塊
記憶體只說了故事的一半。要讓一個行程持續活著,並且能在稍後暫停、再恢復,核心必須記住關於它的大量管理資訊。它把這些全部存進一個行程控制區塊(PCB)——每個行程一份,就像廚房經理替每位上班中的廚師各保留一塊記事板。每塊記事板都記著這位廚師是誰,以及一旦經理把他暫時調離工作檯,他究竟停在哪一步。
記事板上記了哪些東西?一個獨一無二的行程識別碼(PID)(就是廚師的名牌);目前的狀態(正在烹煮、等某樣食材、或準備好可以繼續);存下來的 CPU 暫存器,其中最重要的是程式計數器,標記著下一條該執行的指令;指向該行程位址空間的指標;以及一份開啟檔案與其他資源的清單。核心把每一塊 PCB 都放進一份主清單——行程表(process table)——好讓它隨時一眼就能找到任何一個行程。
行程從來不是只有「正在跑」一種狀態
這裡有個讓初學者意外的事實:在任何一個瞬間,大多數行程其實都不在執行。單一 CPU 核心一次只能跑一條指令流,但你的機器卻同時要照料好幾百個行程。核心靠的是讓每個行程在一小組狀態之間移動來辦到這件事。其中最重要的三種是:就緒(ready)(可以跑,只是在等輪到它用 CPU)、執行中(running)(此刻正用著 CPU),以及等待(waiting)或受阻(卡住了,要等某件事發生,例如某個鍵被按下、或一次磁碟讀取完成)。
行程不停地在這些狀態之間來回穿梭。一個就緒的行程被排程器挑中,就變成執行中;一個正在執行的行程若向磁碟要資料,就會落到等待狀態、把 CPU 讓出來給別人用;等磁碟回覆了,這個行程就回到就緒(而不是直接變回執行中——它仍得排隊等輪到它)。所有就緒的行程排成一條隊伍,稱為就緒佇列(ready queue),而決定接下來換誰跑,正是CPU 排程的工作,這是後面某個階梯會深入探討的一整個主題。
admitted dispatch
[new] --------> [ready] -----------> [running] ---> [done]
^ ^ | |
I/O done | | quantum expires | | exit
(event ready) | +-----------------+ |
\ |
\---- [waiting] <------/
I/O or event wait換廚師上場:上下文切換
當核心把 CPU 從一個行程手中收回、交給另一個行程時,它執行的就是一次上下文切換(context switch)。想像廚房經理把廚師甲從爐子前調開,好讓廚師乙接手同一個爐口。經理得先把廚師甲究竟停在哪裡——每一個暫存器、那個程式計數器——統統寫進廚師甲的記事板,接著再讀廚師乙的記事板,把廚師乙還原到他上次離開時一模一樣的位置。做得乾淨俐落的話,兩位廚師都不會察覺自己被打斷過。
- 某件事觸發切換——計時器中斷響起,或正在執行的行程因 I/O 而受阻。
- 核心把正在執行的行程的狀態(暫存器、程式計數器)存進它的 PCB。
- 排程器從就緒佇列中挑出下一個要執行的行程。
- 核心把那個行程存在 PCB 中的狀態,重新載回 CPU 的暫存器。
- 從新行程的程式計數器繼續執行——彷彿它從未停過。
誕生、結親,與妥善終結
行程是從哪裡來的?在類 Unix 系統上,答案出人意料:一個行程是靠「複製」誕生的。某個既有的行程呼叫 fork,便會做出一份與自己幾乎一模一樣的副本——相同的程式碼、相同的資料,甚至連在程式裡的位置都相同。原本那個是父行程(parent),副本則是子行程(child),這層父子關係把每一個行程都連回到創造它的那一個。子行程接著通常會呼叫 exec,把複製來的程式換成另一支不同的程式——就像一位廚師複製出自己,然後遞給這個分身一份全新的食譜去煮。
因為每個行程都有父行程,所有行程便構成一棵單一的家族樹——行程樹(process tree)。樹根坐著一個特別的行程,PID 為 1,也就是init 行程,由核心在開機時啟動;其他所有行程都是它的後代。當一個行程結束時,它呼叫 exit 退場,而它的父行程呼叫 wait 來收取結束狀態、讓核心釋放剩下的殘餘。如果父行程一直沒去回收一個已結束的子行程,那個子行程就會以殭屍行程(zombie)的形態滯留(煮完了卻還掛在帳上);若父行程先死去,它那些仍在執行的子行程就變成孤兒行程(orphan),由 init 收養。這一切,你會在本階梯最後兩篇導覽中逐步追蹤。
最後,被隔開的行程仍然需要溝通。因為每個行程都住在自己私有的位址空間裡,一個行程沒辦法直接讀取另一個行程的變數。核心於是提供受控的管道——管線(pipe)、訊息佇列、共享記憶體等等——統稱為行程間通訊(IPC)。這就像分處不同工作檯的廚師,透過一個傳遞口互傳紙條和托盤,而不是直接闖進別人的廚房。這只是我們的初次一瞥;完整的菜單會在本階梯稍後端上桌。