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

多道程式設計、多工與分時

一顆 CPU 如何同時讓許多程式保持「活著」,又如何靠飛快的切換,把單一處理器變成一台彷彿同時服侍每個人的機器。

為什麼一顆 CPU 幾乎總是閒著

你已經知道,一支正在執行的程式就是一個 行程,而 核心 就像把硬體分配出去的大樓管理員。現在要講一個尷尬的事實,它形塑了整部作業系統史:大部分時間裡,單一程式都讓 CPU 無所事事。只要一支程式說「從磁碟讀這個檔案」或「等使用者按鍵」,它就得停下來等,往往一等就是數百萬個 CPU 週期。處理器——這台機器裡最貴的零件——就在慢吞吞的裝置爬行時乾坐著發呆。

這種「算一點點、然後等 I/O、然後再算」的節奏太普遍了,普遍到有個專門名字:CPU–I/O 爆發週期。每支程式其實都是一連串短暫的計算爆發,中間夾著長長的等待。文字編輯器算個一微秒,就得整整等上幾秒鐘你的下一次按鍵。掀起現代計算革命的洞見其實很簡單:既然一支程式反正要花那麼多時間等待,何不讓另一支程式在這個空檔裡用 CPU 呢?

多道程式設計:同時載入好幾個工作

第一個答案是 多道程式設計(multiprogramming)。與其載入一個工作、跑到結束、再載入下一個——也就是老式的 批次 做法——作業系統乾脆同時把好幾個工作都留在記憶體裡。每當正在跑的工作停下來等 I/O,作業系統就挑另一個已載入的工作,把 CPU 交給它。這裡的目標純粹是效率:讓那顆昂貴的處理器盡可能逼近百分之百地忙著。多道程式設計 講的是吞吐量,而不是讓你感覺被快速服侍。

想像一位廚師(CPU)守著三個爐口上的三個鍋子(三個已載入的工作)。廚師攪了攪 A 鍋;A 鍋現在得燉上十分鐘(一次 I/O 等待);與其站在旁邊盯著,廚師轉去 B 鍋開始切菜;等 B 鍋也得靜置時,廚師再移到 C 鍋。沒有任何一個爐口因此燒得更快,但廚師從不空站著。這就是多道程式設計:不是讓某一個工作更早完成,而是讓整台機器每小時辦成的事多上許多。

分時:切換到快得能騙過所有人

多道程式設計只在某個工作「自己選擇」要等 I/O 時才切換。對批次工作來說這沒問題,但對坐在終端機前的人就不行了:一個跑著漫長計算的工作會霸佔 CPU,把其他所有人都凍住。分時(time-sharing,又稱多工 multitasking) 補上了缺的那味料——就算正在跑的程式不想放手,它也照切。作業系統發給每支程式一小片時間,等那一片用完,就強行把 CPU 收回來,交給下一位。

作業系統怎麼從一個正埋頭計算、又不發任何系統呼叫的程式手上強行收回 CPU?靠一個硬體鬧鐘。在放程式去跑之前,作業系統會把一顆計時器晶片設定成過幾毫秒就發出一次 中斷——把它想成一個排好時間、會準時響的門鈴。計時器一響,CPU 就放下程式手邊的一切、跳進核心,核心此時便能決定接下來換誰跑。這就是 分時 的心跳:一聲滴答、一次切換的機會,再一聲滴答、再一次切換的機會。

並行不等於平行

這是整個主題裡最重要的一個分別,而初學者老是搞錯。並行(concurrency) 指的是好幾件工作在同一段時間內都「進行中」,輪流推進——就像我們那位一人顧三鍋的廚師。在單核心的機器上,這些程式從來不曾真正在同一瞬間執行;作業系統只是把它們交錯得極細,細到每一個都在前進。平行(parallelism) 則是好幾件工作「真的在同一瞬間執行」,這需要不只一個核心——廚房裡有兩位廚師,各自守著自己的爐子。

所以單核心機器上的分時帶給你並行,但帶不來平行。你可以讓十支程式在一個核心上並行地進行中;卻沒辦法讓其中兩支在同一奈秒裡執行。一台有八個核心的現代筆電兩者都能:它讓八件事真正 平行 地跑,同時又在每個核心上把另外幾十件事並行地分時。並行講的是「應付」很多事;平行講的是「同時做」很多事。把這兩者在腦中分清楚,往後能省下你無數的困惑。

輪詢:人人一片公平的時間

最簡單的公平分時方法就是 輪詢(round-robin) 排程。把所有就緒的行程排成一條佇列。給隊伍最前面那一個固定一片 CPU 時間,叫做 時間量子——比方說 4 毫秒。等它的量子用完(計時器中斷一響),作業系統就把它移到隊伍最後面,再把 CPU 交給下一個行程。就這樣一圈一圈轉下去,像老師讓圍成一圈的每個孩子都輪流發言一次,才回到第一個。

我們來追蹤三個行程 P1、P2、P3,每個都需要 10 毫秒的 CPU,量子為 4 毫秒。P1 跑 0–4 毫秒,然後被擠到隊尾,還剩 6 毫秒。P2 跑 4–8 毫秒(剩 6)。P3 跑 8–12 毫秒(剩 6)。接著回到 P1:跑 12–16 毫秒(剩 2),P2 跑 16–20(剩 2),P3 跑 20–24(剩 2)。最後一圈:P1 跑 24–26(完成),P2 跑 26–28(完成),P3 跑 28–30(完成)。沒有人為了輪到第一次而等到無法忍受——這份很快就有回應的特性,正是互動式系統鍾愛這套方法的原因。

time (ms):  0    4    8    12   16   20   24   26   28   30
running:   [P1] [P2] [P3] [P1] [P2] [P3] [P1] [P2] [P3]
quantum:    4    4    4    4    4    4    2    2    2
           (P1,P2,P3 each need 10 ms; quantum = 4 ms)
三個等量工作的輪詢排程;佇列裡的每一位都先拿一個量子,再讓給下一位。

選量子是一場拿捏,而且值得誠實地面對這個取捨。設得太大,輪詢就退化回讓某個工作霸佔 CPU——回應變差。設得太小,CPU 就把可惜的一部分時間花在切換這套機制本身,而不是花在真正的工作上。這就帶我們去看那一次切換究竟要付出什麼代價。

切換的代價:上下文切換

每當作業系統把 CPU 從一個行程手上收走、交給另一個行程,它就必須做一次 上下文切換(context switch)。一個行程的「上下文」是 CPU 替它保管的一切:程式計數器(下一道指令在哪)、所有暫存器的值,以及關於它記憶體的記帳資料。要切換,作業系統得把離開的行程整個上下文先存起來,好讓它日後能從中斷的地方一模一樣地接續;再把進來的行程先前存下的上下文載入。想像一名學生離開書桌,仔細地把課本夾上書籤、把筆記收進口袋,好讓下一名學生攤開自己的;之後第一名回來時,發現自己的那一頁正停在他離開時的地方。

  1. 某個觸發發生了——可能是計時器中斷響了(量子用完),也可能是正在跑的行程發出了一個必須等待的系統呼叫。CPU 跳進核心。
  2. 核心把離開的行程的上下文——程式計數器、暫存器、記憶體記帳——存進那個行程的紀錄裡,讓它的確切狀態不致遺失。
  3. 排程器挑出下一個要跑的行程(在輪詢裡,就是現在排在佇列最前面的那一個)。
  4. 核心把選中行程先前存下的上下文載回 CPU,並讓 CPU 切換成指向那個行程的記憶體。
  5. CPU 從選中行程上次停下的地方一模一樣地接著跑——它甚至從來不會察覺自己曾被暫停過。

這裡有個誠實的代價:上下文切換是 純粹的額外開銷。在那些存取與載入之間,沒有任何使用者程式在前進——機器忙著的只是雜務管理。一次切換很快(往往遠不到一微秒),但一秒切上幾千次,代價就累積起來了。這正是量子不能太小的真正原因,也是為什麼調校良好的排程器會試著切得夠勤、勤到感覺有回應,卻又切得夠少、少到把開銷壓低。切換,就是你為了「一顆 CPU 同時服侍所有人」這份錯覺而繳的稅。