JOVANA
Explore Library Glossary Getting Started Three Levels Fields How it works Mission
Join the mission
All guides

行程狀態與情境切換

一個行程在它存活的每一刻,其實很少真的在執行。本篇介紹它會穿梭的那一小組狀態、把它們串起來的狀態圖,以及核心用來把 CPU 從一個行程切換到另一個行程時,那個安靜卻昂貴的把戲。

活著不等於正在執行

到目前為止你已經知道,行程是一支被賦予生命的程式,擁有自己的位址空間,以及一個保存著核心對它所知一切的行程控制區塊。但有個事實常讓初學者吃驚:在任何一個瞬間,你機器上幾乎沒有任何行程真的在使用 CPU。打開一台跑著數百個行程、卻只有幾個 CPU 核心的筆電,真相是:絕大多數行程除了等待之外什麼也沒做。行程就像一道正在烹煮的食譜,但大多數食譜的時間都花在等水燒開,而不是真的在被翻炒。

那麼,既然行程並非總是在執行,它到底在做什麼?事實證明,一個行程的整段生命,都能用區區幾個狀態組成的小小詞彙來描述。掌握這些狀態,以及在它們之間移動的規則,正是理解「一顆 CPU 如何讓上百支程式都產生『機器歸我獨享』這種以假亂真錯覺」的關鍵。

五個狀態與串起它們的狀態圖

經典的行程狀態模型使用五個狀態。新建(New)是正在被建立、尚未完全就緒的行程。就緒(Ready)表示它已載入、完整、隨時願意執行——它只差一顆 CPU,於是和其他處境相同的行程一起在就緒佇列裡等候。執行中(Running)表示它此刻正在某顆 CPU 上執行指令;在單核心的情況下,同一時間恰好只能有一個行程處於執行中。等待(Waiting,又稱被阻塞)表示它要求了某個還沒準備好的東西——最常見的是像 read(fd, buf, n) 這類 I/O 請求的結果——在那東西到來之前,它無法繼續推進。終止(Terminated)表示它已結束,正在被拆除。

      admitted          dispatch            exit
  NEW ---------> READY -----------> RUNNING -----------> TERMINATED
                  ^   ^               |  |
     I/O complete |   | preempt /     |  | I/O or wait request
     or event     |   | quantum end   |  |
                  |   +---------------+  |
                  |    (back to READY)   v
                  +--------------------WAITING
五狀態圖。注意「執行中」可以經由三條不同的箭頭離開。

箭頭比方框更重要。行程從新建被「核准」進入就緒。從就緒它只能前往執行中,而且只有分派器能把它送過去。從執行中則有三條出路:它可能被搶占回到就緒(它的回合被提前中斷)、向下阻塞到等待(它請求了 I/O),或退出到終止(它完成了)。還有一條初學者常漏看的箭頭:當一個等待中的行程終於拿到它的 I/O,它「不會」直接跳回執行中。它回到就緒,必須重新排隊。CPU 此刻正忙著別人的事;它得像大家一樣等自己的回合。

共享一顆 CPU:一段輪詢排程的追蹤

讓我們用最簡單的公平排程器——輪詢排程(round-robin)——來讓這些狀態活起來。每個執行中的行程都被分到一段固定的時間——稱為時間量子,比方說 4 毫秒。當這段時間用完,一個計時器中斷觸發,核心搶占該行程,就緒佇列中的下一個就輪到它了。假設三個行程 A、B、C 全都就緒,而且各自只想連續運算 10 毫秒。追蹤一下:A 跑 4 毫秒(還需要 6 毫秒,到隊伍尾端),B 跑 4 毫秒(還需 6 毫秒,到尾端),C 跑 4 毫秒(還需 6 毫秒,到尾端),接著 A 再跑 4 毫秒(還需 2 毫秒),B 跑 4 毫秒(還需 2),C 跑 4 毫秒(還需 2),然後 A 跑完它最後的 2 毫秒並結束,B 結束,C 結束。

注意每個行程經歷了什麼。在每一個量子的邊界上,它都一次又一次地走過「執行中 → 就緒 →(等自己的回合)→ 再度執行中」。從外部看,這三者彷彿一起在推進——然而在任何單一瞬間,它們之中都沒有兩個真的在執行。這就是那個關鍵區別:並行,而非平行。並行是交錯進行——許多工作同時在進行中、輪流上場,甚至可能只在一顆核心上。平行則是字面意義上「同一瞬間」的執行,那確實需要多顆核心。我們這段追蹤純粹是並行:一顆 CPU、三個行程,靠飛快的輪流上場製造出同時進行的錯覺。

情境切換:CPU 如何易手

每一次這樣的輪流上場,背後都藏著一套精密的機件:情境切換(context switch)。一顆 CPU 只有一組暫存器——一個程式計數器、一個堆疊指標、一組通用暫存器。行程 A 一直在用著它們全部。要讓行程 B 執行,核心必須先把 A 完整的暫存器狀態「拍照」並妥善收好,再把 B 先前儲存的狀態載入到原處。這份暫存器快照存放在每個行程的PCB裡。可以把它想成一張共用的工作檯:在下一位工人上場前,你把目前這位工人的工具掃進他貼好標籤的抽屜,再把下一位工人的工具,原封不動地擺回他上次離開時的位置。

  1. 一個觸發抵達——通常是計時器中斷(量子用盡),或一個會阻塞的系統呼叫。CPU 切換進核心模式,並跳到核心的處理常式。
  2. 核心把目前行程 A 的完整暫存器狀態(程式計數器、堆疊指標、通用暫存器)儲存進 A 的 PCB,並更新 PCB 中 A 的狀態(為就緒或等待)。
  3. 排程器從就緒佇列中挑出下一個行程 B;分派器被告知要切換到它。
  4. 核心從 B 的 PCB 載入 B 儲存的暫存器狀態,把記憶體對映切換到 B 的位址空間,並把 B 標記為執行中。
  5. 控制權回到使用者模式,正好停在 B 上次即將執行的那一條指令上。B 完全察覺不到自己曾被暫停——它只是直接繼續下去。

第四步裡還有一個會耗掉實際時間的微妙之處。切換位址空間可能意味著舊行程快取下來的位址轉譯都不再有效,於是TLB——核心為最近用過的分頁查詢所貼的便利貼——可能必須被清空。切換之後,新行程會經歷一陣TLB 未命中,因為那些便利貼得重新寫過,而它的工作集也必須在 CPU 快取裡重新熱身。看得見的那次切換很快;看不見的這段後遺症,往往才是更大的代價。

切換有代價,所以作業系統不愛切換

這裡有個誠實而重要的提醒:情境切換純粹是額外負擔。在儲存與還原狀態的那幾微秒裡,「沒有」任何使用者行程在完成有用的工作——CPU 忙著當舞台工作人員,而不是演員。從觸發到新行程真正開始執行的整段時間稱為分派延遲,作業系統會努力把它壓低。這正是輪詢時間量子是一場拿捏的真正原因:太長,系統會顯得遲鈍(你要等好久才輪到);太短,你每一秒都會把痛苦的一大塊比例純粹花在切換上,中間幾乎沒做什麼真正的工作。

這也解釋了一個你日後在執行緒上會再次遇到的設計張力:在「同一個」行程的兩個執行緒之間切換,比在兩個「不同」行程之間切換要便宜,因為這些執行緒共用一個位址空間——不必交換記憶體對映,要清空的 TLB 也少得多。你剛學到的這套狀態詞彙同樣適用於執行緒,而不只是整個行程;在現代系統裡,排程器在就緒、執行中、等待之間挪移的,通常是執行緒而非行程。行程愈來愈像是資源的容器,而執行緒才是真正在跑的那個單位。