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

執行緒與行程的差別

第 1 篇把並行與平行分了開來。現在來認識作業系統真正讓多件事同時跑的兩種方式——行程,各自活在自己私有的世界裡;以及執行緒,共用同一個世界。把這條界線弄清楚,正是讓這個章節後面所有東西(競爭、鎖、死結)變得講得通、而不是感覺像魔法的關鍵。

兩種「同時不只一個」

從行程那個章節你已經知道行程是什麼:一支正在執行的程式,擁有自己的位址空間——自己的程式碼、堆積、堆疊與全域變數——沒有別的行程能碰到。當你執行 fork() 時,子行程拿到的是所有東西的複本,所以一方的變數對另一方是看不見的。那種隔離是一項優點:一個行程崩潰,沒辦法弄壞另一個。但隔離是有代價的,而那個代價正是執行緒存在的理由。

一個執行緒是第二條(或第三條、第一百條)控制流——擁有自己的程式計數器與自己的堆疊——在一個行程內部執行,並與其他執行緒共用該行程那唯一的位址空間。把一個行程想成一間公寓。fork 給你的是第二間獨立的公寓,裡頭有自己一份家具的複本。而生出一個執行緒則是邀請另一個人住進同一間公寓:他有自己的床(自己的堆疊),但廚房、書架,以及每一個全域變數,都是任何人都能重新擺放的共用家具。就這一個差別——各自獨立的世界,相對於一個共用的世界——就是全部的分野,而幾乎其他一切都從它推導而來。

什麼是共用的,什麼是私有的

在這裡要精確,因為「共用/私有」的劃分,是每一個並行臭蟲、也是每一個並行收益的源頭。同一個行程裡的執行緒共用堆積(所以一個指向 malloc() 來的區塊的指標,在每個執行緒裡都有效)、全域與靜態變數、已開啟的檔案描述符,以及程式碼本身。每個執行緒各自私有的東西小卻關鍵:自己的堆疊(所以它的區域變數與呼叫框架是自己的)、自己的程式計數器與暫存器,以及一小塊執行緒區域儲存。透過共用指標能到達的一切都是共用的;只活在某執行緒自己堆疊上的區域變數,則是私有的。

一張圖會有幫助。把一個行程想成單一一塊:上面的區域裝著共用的東西——程式碼、全域與靜態變數、malloc() 建起的堆積,以及檔案描述符表——而每個執行緒看到的都正是那同一塊區域。它底下坐著每個執行緒一條的私有窄帶:執行緒 1 有自己的堆疊、程式計數器、暫存器與 TLS;執行緒 2 有自己的;執行緒 3 也有自己的。所以三個執行緒,意味著一塊共用的上層區域、與三條各自獨立的下層窄帶。當你在推敲一個執行緒臭蟲時,第一個問題永遠是某個變數住在哪一個區域——是共用的上層、兩個執行緒會在那裡相撞,還是某條私有窄帶、它們在那裡撞不到。

這正是為什麼執行緒同時既強大又危險。強大,是因為兩個執行緒能在共用的堆積裡,對同一個大型資料結構協同工作,不需複製、也不需傳訊息——它們只是都握著一個指向它的指標。危險,是因為那同一個共用資料結構,現在成了共用可變狀態:如果兩個執行緒在無法預測的時刻都去寫它,你就會得到一個資料競爭,那正是這個章節第 4、5 篇所圍繞的確切危害。讓執行緒有效率的那個共用位址空間,恰恰就是讓它們需要同步的那個東西。

為什麼執行緒比行程便宜

因為執行緒共用同一個位址空間,核心要造出一個所花的功夫少得多。建立一個行程(想想 先 fork 再 exec)意味著要架設一整個新的位址空間——分頁表、各個區段,全部都要——即便有寫入時複製幫忙也一樣。建立一個執行緒則重用既有的位址空間,大致上只配置一個新的堆疊與一小塊核心的記帳結構。粗略以數量級而論,生出一個執行緒往往比生出一個行程便宜十倍,而一個執行緒佔的空間主要由它的堆疊決定(常見的預設約是保留 8 MiB 的位址空間,不過只有被碰到的分頁才耗用真正的記憶體)。

在它們之間切換也比較便宜。在同一個行程的兩個執行緒之間做上下文切換,不必更換位址空間,所以記憶體管理單元保留它的分頁表,而轉譯後備緩衝區(最近位址轉譯的快取)也不必清空。在兩個行程之間切換,通常就會清掉那當中很大一部分,而事後重新填滿要花真正的時間。所以執行緒在建立成本、記憶體,以及切換成本上都贏——這正是為什麼一個要處理上千條連線的網頁伺服器,常常用執行緒、而不是每條連線一個行程。

你怎麼造出一個:pthreads 與 std::thread

在 Unix 上,經典的介面是 POSIX 執行緒,通常寫作 pthreads。你把一個指向函式的指標與一個引數交給 pthread_create(),它就啟動一個新執行緒去執行那個函式,與呼叫者並行;當那個函式回傳(或呼叫 pthread_exit())時,這個新執行緒就結束。父方接著呼叫 pthread_join() 來等待執行緒完成、並收回它的結果——這是你在行程那個章節認識的、行程版 wait() 在執行緒世界裡的表親。若不 join(或 detach),執行緒就被晾在那裡,大致等同於一個殭屍

#include <pthread.h>
#include <stdio.h>

void *worker(void *arg) {
    long id = (long)arg;
    printf("thread %ld running\n", id);
    return NULL;
}

int main(void) {
    pthread_t t;
    int rc = pthread_create(&t, NULL, worker, (void *)1L);
    if (rc != 0) {
        fprintf(stderr, "pthread_create failed: %d\n", rc);
        return 1;
    }
    pthread_join(t, NULL);   /* wait for the worker to finish */
    return 0;
}
一支最小的 pthreads 程式。注意 rc 有被檢查:pthread_create() 直接回傳一個錯誤號碼(不是透過 errno),而忽略它的程式會用到一個未初始化的 pthread_t。編譯指令:gcc -O2 -Wall main.c -pthread

更高階的語言,會把同一個 OS 執行緒包進一個比較友善的物件裡。C++ 給你 std::thread,它的建構子啟動執行緒、它的 .join() 等待執行緒;Rust 的 std::thread::spawn() 回傳一個你同樣 .join() 的把手,而且——既然是 Rust——借用檢查器會在編譯期強制你交給另一個執行緒的資料是被安全地共用的,把一整類資料競爭臭蟲變成建置錯誤。這些是同一個核心原語之上的便利,不是另一套機制:底下,每一個仍然是一個共用同一位址空間的 OS 執行緒,所有共用的危害原封不動。語法變了;物理沒變。

怎麼選,以及接下來是什麼

那你該選哪一個?當隔離比共用更重要時,伸手去拿各自獨立的行程:當某一部分的崩潰或資安破口絕不能拖垮、也不能讀到其他部分時(瀏覽器把每個網站放進自己的行程,正是為了這個理由),或者當這些片段幾乎不需要交談時。當工作緊密圍繞著共用資料、而你想要速度與低額外負擔時,伸手去拿執行緒——對一個大陣列做平行排序,或許多短命的請求處理者去碰同一個記憶體內快取。誠實的拇指法則:想共用狀態時用執行緒,想被保護而不去共用它時用行程。

在我們往下走之前,有一個誠實的提醒:執行緒與行程並不是唯一的模型,而且「更多執行緒」並不自動等於「更快」。如第 1 篇所強調的,並行不是平行——在一個核心上的八個執行緒仍然得輪流,而一條無法切分的工作鏈,多加執行緒也得不到任何好處。此外,行程仍然能在不共用位址空間的情況下協同工作,透過後面章節會探討的行程間通訊機制——管線、通訊端、共用記憶體。執行緒是一個強大的工具,不是唯一的那個。

你現在握住了整個章節據以轉動的那條界線:行程是一個私有的世界、執行緒是一個共用的世界,而執行緒之所以便宜,正是因為它們共用。下一篇〈用 pthreads 建立執行緒〉,會把上面那個 pthread_create() / pthread_join() 骨架變得具體、可執行。接著第 4、5 篇轉向那一切共用的黑暗面——為什麼來自兩個執行緒的 `count++` 會悄悄弄丟更新(競爭條件),以及為什麼一把互斥鎖是修好它的工具。