執行緒概觀、多核程式設計與多執行緒模型 (Threads Overview, Multicore Programming & Multithreading Models)
本系列文章內容參考自經典教材 Operating System Concepts, 10th Edition (Silberschatz, Galvin, Gagne)。本文對應章節:Section 4.1 Overview、4.2 Multicore Programming、4.3 Multithreading Models。
4.1 概觀 (Overview)
執行緒是什麼
在 Chapter 3 的進程模型中,每個進程只有一條執行路徑。但現代 OS 與應用程式幾乎都不滿足於此。執行緒 (Thread) 是 CPU 使用的基本單位,它讓一個進程內部可以同時維持多條執行路徑。
一個執行緒由四個核心元素組成:
| 元件 | 說明 |
|---|---|
| Thread ID | 執行緒的唯一識別碼 |
| Program Counter (PC) | 指向下一條要執行的指令 |
| Register Set(暫存器集合) | 儲存執行緒當前的計算狀態 |
| Stack(堆疊) | 儲存區域變數、函式呼叫紀錄(每個執行緒都有自己的呼叫堆疊) |
執行緒的獨特之處在於它與同一進程內的其他執行緒共享進程的資源:
- 共享:程式碼段 (code section)、資料段 (data section)、開啟中的檔案 (open files)、訊號 (signals)
- 不共享:PC、暫存器集合、Stack(這三者每個執行緒各自獨立,因為不同執行緒有各自的執行位置與呼叫堆疊)
下圖對比了傳統單執行緒進程與多執行緒進程的結構差異。左側的單執行緒進程只有一組 registers/PC/stack;右側的多執行緒進程共用 code、data、files,但每條執行緒擁有自己獨立的 registers、stack、PC,彼此互不干擾:
這張圖最核心的洞察是:多個執行緒共享記憶體(同一份 code 與 data),但各自獨立執行(各自的 PC 指向不同的指令位置)。這正是執行緒比進程更輕量的根本原因,建立一條新執行緒不需要複製整個位址空間,只需建立一組新的 PC、registers 與 stack 即可。
4.1.1 動機 (Motivation)
為什麼需要執行緒?來看一個具體場景:一台繁忙的網頁伺服器同時接受成千上萬個客戶端請求。
如果伺服器是一個傳統的單執行緒進程 (Single-Threaded Process),它一次只能服務一個客戶端。第二個客戶端必須等待第一個客戶端的請求完全處理完才能獲得回應,延遲極高。
有人會想:讓伺服器為每個請求 fork 一個新進程 (Process)。這個想法確實可行,而且在執行緒技術普及之前確實是常見做法。然而,建立一個新進程需要複製整個位址空間(代碼、堆積、堆疊、檔案描述符等),成本非常高。若新進程執行的工作與原進程相同,這些複製完全是浪費。
使用多執行緒 (Multithreading) 則完全不同。伺服器只需維持一個進程,並在其中建立一條新執行緒來服務每個請求。建立執行緒的代價遠比建立進程低,因為執行緒共享進程現有的資源,不需要複製。
下圖展示了多執行緒伺服器的運作流程:客戶端發出請求後,伺服器建立一條新執行緒來負責處理,同時繼續監聽下一個請求,兩件事可以並行進行:
這種設計的要點:
- (1) request:客戶端送出請求
- (2) create new thread:伺服器不建立新進程,而是在同一進程內建立一條新執行緒來服務該請求
- (3) resume listening:伺服器主執行緒立即回到監聽狀態,不需等待服務完成
除了伺服器,多執行緒在各類應用中都有實際用途:
- 影像縮圖應用:為每張圖片建立獨立執行緒同時產生縮圖,而非逐一處理
- 網頁瀏覽器:一條執行緒渲染頁面,另一條執行緒從網路下載資料
- 文書處理器:一條執行緒顯示畫面,一條執行緒接收鍵盤輸入,一條執行緒在背景進行拼字檢查
OS 核心本身也是多執行緒的。以 Linux 為例,系統開機時會建立多條 kernel threads,每條負責特定工作(裝置管理、記憶體管理、中斷處理等)。可以用 ps -ef 指令查看正在運行的 kernel threads,其中 kthreadd(pid=2)是所有 kernel threads 的父執行緒。
4.1.2 多執行緒的四大優勢 (Benefits)
多執行緒程式設計的優勢可以歸納為四個面向:
1. 回應性 (Responsiveness)
對互動式應用而言,如果某個操作耗時很長(例如下載大型檔案),單執行緒應用程式在操作期間會完全失去回應。多執行緒可以將耗時操作交給獨立執行緒,讓主執行緒繼續回應使用者互動,維持應用程式的即時反應性。這對 UI 設計尤為重要。
2. 資源共享 (Resource Sharing)
進程之間若要共享資料,必須明確使用共享記憶體或訊息傳遞等機制,這些都需要程式設計者額外安排。而執行緒預設就共享同一進程的程式碼與資料,不需要額外設定。這讓多條執行緒能在同一位址空間中並行工作,彼此輕易交換資訊。
3. 經濟性 (Economy)
建立進程需要配置記憶體、複製資源,成本高昂。建立執行緒只需配置少量的 PC、registers 與 stack,因為其他資源都已在進程中存在。同理,執行緒的 Context Switch(環境切換) 也比進程快,因為切換執行緒只需切換少量私有資源,不需要切換整個位址空間。
這裡容易有一個誤解:CPU 上的實體暫存器(Register)數量是固定的,OS 無法「新增」CPU 硬體。所謂「配置」,本質上全都是在 RAM 中劃分空間。
Stack 的配置:OS 在進程的虛擬記憶體空間(User Space)中找一塊空白區域(通常數 MB),標記為這條執行緒的專屬 Stack,用來存放區域變數與函式呼叫紀錄。
PC 與 Registers 的配置:OS 在核心記憶體(Kernel Space)中為這條執行緒建立一個 TCB(Thread Control Block,執行緒控制區塊) 資料結構,其中包含用來備份硬體暫存器值的欄位:
struct TCB {
uint64_t saved_pc; // 備份 Program Counter
uint64_t saved_rax; // 備份 RAX 暫存器
uint64_t saved_rsp; // 備份 Stack Pointer
// ... 其他暫存器的備份欄位
};
CPU 的實體 Registers 在任何時刻只能被一條執行緒使用。Context Switch 的本質,就是把當前執行緒的 Registers 值抄寫進它的 TCB(RAM 中),再把下一條執行緒的 TCB 備份值填回 CPU 的實體 Registers,讓 CPU 從那條執行緒上次停下的位置繼續執行。
| 元件 | 實際位置 | 說明 |
|---|---|---|
| Stack | User Space(RAM) | 每條執行緒的函式呼叫空間,建立時配置數 MB |
| TCB(含 PC/Registers 備份) | Kernel Space(RAM) | 執行緒不執行時,狀態暫存於此 |
| 實體 Registers + PC | CPU 硬體 | 同一時間只有一條執行緒佔用,切換時靠 TCB 備份/還原 |
這也解釋了為什麼執行緒的建立成本遠低於進程:OS 只需在 RAM 裡配置幾 MB 給 Stack、幾 KB 給 TCB 就完工,不需要像建立進程那樣複製整張分頁表 (Page Table) 和所有檔案描述符 (File Descriptors)。
建立一條新 Thread 時,OS 只需配置兩樣東西:
- TCB(Thread Control Block):數 KB,存放於 Kernel Space,用來備份 PC 與 registers
- Stack:數 MB,在進程既有的 User Space 中劃出一塊專屬區域
進程的 code section、data section、heap、open files 等資源由同進程內所有執行緒直接共享,不需重新配置。
建立一個新 Process 時,OS 的工作量則大得多:
- 建立獨立的 PCB(Process Control Block)
- 配置完整的獨立虛擬位址空間(text、data、heap、stack 各區段全部重新配置)
- 複製父進程的 Page Table(分頁表),建立獨立的記憶體映射
- 複製 File Descriptor Table(所有開啟中的檔案描述符)
- 複製訊號處理設定、環境變數等 OS 資源
| Thread 建立 | Process 建立 | |
|---|---|---|
| 虛擬位址空間 | 共享,不複製 | 全新獨立配置 |
| Page Table | 共享 | 複製一份 |
| File Descriptors | 共享 | 複製一份 |
| 私有控制結構 | TCB(數 KB) | PCB(含更多欄位) |
| 私有 Stack | 配置數 MB | 配置數 MB |
這正是執行緒被稱為「輕量級進程 (Lightweight Process)」的根本原因。
4. 可擴展性 (Scalability)
單執行緒進程無論系統有多少個 CPU,都只能在一個處理器上執行。多執行緒進程則可以讓不同的執行緒真正同時運行在不同的處理核心上,隨著硬體規模增加而自動獲得更高的吞吐量。
理解了執行緒的優勢後,自然會想到一個問題:在系統架構設計中,什麼時候應該選多執行緒 (Multi-threading),什麼時候應該選多進程 (Multi-processing)?核心考量維度有三個:任務性質、資源共享需求、對穩定性的要求。
選多執行緒的場景
- 頻繁共享大量數據:執行緒共享同一份 Heap,直接讀寫變數,不需要 IPC,速度快
- I/O 密集型任務:等待網路請求、讀寫資料庫時,一條執行緒阻塞,CPU 切換到其他執行緒繼續工作
- GUI 應用程式:主執行緒負責畫面,背景執行緒負責計算,讓 UI 保持回應性
- 資源受限環境:執行緒建立與切換的成本遠低於進程
選多進程的場景
- 穩定性要求極高:一個進程崩潰不影響其他進程。Chrome 瀏覽器為每個分頁開啟獨立進程,正是基於此考量
- CPU 密集型任務:影像處理、影片轉檔、大型矩陣運算等需要大量 CPU 計算的工作,多進程可以真正利用多核
- 安全性隔離:進程間記憶體完全隔離,有效防止一個模組意外或惡意修改另一個模組的數據
- 規避語言限制:Python 的 GIL(全域解釋器鎖)導致同一時間只有一條執行緒可以執行 Python 程式碼,必須用多進程才能在多核系統上達到真正的平行
對比
| 維度 | 多執行緒 (Threads) | 多進程 (Processes) |
|---|---|---|
| 記憶體 | 共享同一個位址空間 | 每個進程擁有獨立的記憶體 |
| 通訊成本 | 低(直接存取共享變數) | 高(需透過 IPC:Pipe、Shared Memory) |
| 切換開銷 | 小(只切換私有暫存器/Stack) | 大(需切換分頁表等整個進程狀態) |
| 穩定性 | 低(一條執行緒崩潰可能拖垮整個進程) | 高(進程之間互相隔離) |
| 常見案例 | Web Server 請求處理、UI 反應性 | 瀏覽器分頁、微服務、Python 並行計算 |
在現實的大型系統中,兩者通常混合使用。例如 Web Server 會先 fork 多個 Worker 進程(利用多核、提高穩定性),每個進程內再使用多執行緒(或非同步 I/O)來處理大量並發請求。
延伸:Node.js 的執行緒模型
上面的多執行緒伺服器模型(每個請求建立一條執行緒)直觀易懂,但不是唯一的解法。以前後端都廣泛使用的 Node.js 為例,它採用了一套截然不同的架構,卻同樣能撐起高並發的後端服務。理解它的設計,能讓我們更具體地感受到執行緒模型的選擇如何影響系統行為。
首先我們需要釐清一個問題:
Node.js 是「單執行緒」嗎?
說 Node.js 是「單執行緒」,既對也不對。所有 JavaScript 程式碼,無論是 if/else、for 迴圈、還是變數操作,永遠只在一個 Main Thread(主執行緒) 上執行,這個主執行緒由 V8 引擎驅動。如果在 JS 中寫一個死迴圈,整個 Node.js 伺服器會直接卡死,因為主執行緒被完全佔用,再也無法回應任何新請求。
但「Node.js 的底層」並不是單執行緒。Node.js 的 C++ 核心函式庫 libuv 內建了一個執行緒池 (Thread Pool)(預設 4 條執行緒)。當程式呼叫特定的底層 API 時,libuv 會把工作「外包」給執行緒池的背景執行緒,主執行緒繼續往下執行,不等待結果。
所以「Node.js 是單執行緒」描述的是 JS 程式碼的執行層,不是整個 Node.js runtime。JS 層是單執行緒的;libuv 層,背景已有執行緒池在運作。這就是 Node.js 能同時服務大量請求的基礎。不過這裡有個直覺上容易踩到的誤區:既然 libuv 有執行緒池,是不是所有「跑起來比較慢」的工作都能外包?一個跑很久的 for 迴圈,也算嗎?
那什麼工作才能外包給執行緒池?
libuv 裡有一條清楚的界線,判斷原則只有一個:「這個工作是在讓 CPU 持續運算,還是在等待硬體或 OS 回應?」
- CPU 密集型 (CPU Bound):需要 CPU 持續滿載計算,例如
for迴圈、排序演算法、矩陣運算、影像轉檔。這類工作無法外包,只能占著主執行緒跑。 - I/O 密集型 (I/O Bound):需要等待外部資源回應,例如
fs.readFile()、資料庫查詢、HTTP 請求、DNS 解析。這類工作 CPU 大部分時間都在「等」,可以外包給 libuv(Thread Pool 或 OS I/O),主執行緒繼續接受新請求。
這也解釋了為什麼 Node.js 特別擅長高並發的 I/O 密集型服務(HTTP API、聊天室、即時推播),而不適合 CPU 密集型任務。一個 CPU Bound 的操作會讓主執行緒停下來,讓其他所有請求都排隊等候。
確立了這個分工之後,出現了一個新問題:當背景的執行緒池或 OS 完成了一個 I/O 任務,主執行緒是怎麼被通知到的?誰在持續監視這些背景任務的狀態,又是誰在主執行緒有空的時候把 Callback 送上去執行?
答案就是 Event Loop 和 Non-blocking I/O
這兩個概念在不同層次上各自負責一件事:
-
非阻塞 I/O (Non-blocking I/O):這是 OS 層的機制(Linux 的
epoll、macOS 的kqueue)。程式發出 I/O 請求後,OS 不讓程式傻等,而是讓程式先去做別的事,等 I/O 完成再發通知。它解決的是 「要怎麼樣才能不等待?」 的問題。 -
事件迴圈 (Event Loop):這是 libuv 層用 C++ 實作的無限迴圈,扮演「大管家」角色。它持續巡邏,確認有哪些背景工作完成了(Thread Pool 做完了?OS 通知 I/O 好了?),一旦有結果就把對應的 Callback 放入佇列,等主執行緒有空時取出執行。它解決的是 「要怎麼樣才能追蹤並喚回執行?」 的問題。
下圖展示了從一次 async 呼叫到 Callback 被執行的完整五步流程,以及 Non-blocking I/O 和 Event Loop 各自在哪個步驟發揮作用:
兩者缺一不可:Non-blocking I/O 讓 I/O 工作不阻塞主執行緒;Event Loop 負責追蹤所有背景工作,完成後喚回主執行緒執行 Callback。這套機制讓 Node.js 在 I/O 密集的場景下幾乎不需要等待。
但等等,既然底層確實有 Thread Pool 裡的執行緒在跑,它們在執行 C++ 工作的同時,有沒有可能碰到主執行緒正在讀寫的 JS 變數?
換句話說,Node.js 底層有多執行緒,難道不會有競態條件的問題嗎?
Node.js 底層的背景執行緒確實在運行,但它們無法直接存取 V8 引擎中的 JS 物件。Thread Pool 裡的執行緒只做底層 C++ 工作(讀硬碟位元組、計算雜湊值),完成後把結果打包成事件放進佇列。真正讀寫 JS 變數的永遠只有主執行緒。
既然同一時間只有一條執行緒在操作 JS 資料,就不存在兩條執行緒互搶同一塊記憶體的問題,也不需要 Mutex 鎖。這是 Node.js 刻意選擇的設計取捨:用「JS 層永遠單執行緒」換來「不需要擔心競態條件」。
當然,如果使用現代 Node.js 的 Worker Threads(允許在 JS 層開啟額外執行緒處理 CPU Bound 計算),Worker Threads 之間交換資料就必須透過 Shared Memory 或 Message Passing,Race Condition 的問題也會隨之重新浮現。