Learning Objectives
- 說明同一個行程裡的執行緒共用什麼、各自有什麼,以及這帶來的好處與危險
- 用 pthread_create 與 pthread_join 傳參數、拿回傳值,並修好傳迴圈變數位址的錯誤
- 判斷 main 先結束、執行緒呼叫 exit 時整個行程會怎樣
- 區分並行與平行,用 Amdahl 定律估算加核心能快多少
為什麼有了行程還要執行緒
一支網頁伺服器要同時服務很多人。第 3 課學過用 fork 替每個連線開一個子行程,這樣可行,但有兩個問題:
- 建立比較慢,fork 要處理整個位址空間(就算用了寫入時複製,頁表還是要複製)。
- 很難共用資料,每個行程的記憶體互相獨立,要共用快取就得另外建管線或共享記憶體。
執行緒(thread)是同一個行程裡的另一條執行路徑。它們共用位址空間,只有執行需要的那一小部分是各自的。下面是同一支程式在真正的 Linux 上,建立並等待一個執行緒和一個子行程的平均時間:
第 1 次:thread 48.1 us, fork 459.5 us (each, 2000 times)
第 2 次:thread 51.9 us, fork 214.8 us (each, 2000 times)
第 3 次:thread 47.0 us, fork 306.0 us (each, 2000 times)
在真正的 Linux 上實際執行的結果(Compiler Explorer 的 Linux x86-64 環境,gcc 13.3,2026-11-04)。
每次的數字都會變,而且會隨機器和行程大小改變,但這個環境裡執行緒每次都比較快。這個子行程很小,行程越大,fork 要複製的頁表就越多。
代價是共用本身:任何執行緒都能改任何資料,寫錯一次整個行程一起遭殃。下一課要看的競爭條件,就是從這裡來的。
這一課會用到的 C
C 語言的語法不需要全部熟悉,看得懂這幾個就夠了(想深入可以看 C++ 軌):
| 寫法 | 意思 |
|---|---|
int *p |
指標:p 存的是某個 int 的位址 |
&x |
取得 x 的位址 |
*p |
讀寫 p 指到的那個值 |
void * |
不指定型別的指標,什麼位址都能放 |
*(int *)arg |
把 arg 當成 int 指標,再讀出它指到的 int |
pthread_t |
用來記住一個執行緒的型別 |
建立與等待執行緒只要兩個函數:
pthread_t t;
pthread_create(&t, NULL, worker, arg); // 新執行緒從 worker(arg) 開始跑
void *result;
pthread_join(t, &result); // 等 t 結束,拿到它的回傳值
worker 必須接受一個 void * 參數、回傳 void *。在真正的 Linux 上編譯要加 -pthread:gcc main.c -pthread。
傳參數的兩種正確寫法
問題的本質是參數要活得比執行緒讀取它的時間更久,而且不能被別人改。下面兩種都可以:
int ids[3]; // 每個執行緒有自己的一格
for (i = 0; i < 3; i++) {
ids[i] = i;
pthread_create(&t[i], NULL, worker, &ids[i]);
}
pthread_create(&t[i], NULL, worker, (void *)(long)i); // 把數值本身塞進指標
// worker 裡:int id = (long)arg;
看起來可行但其實不對的寫法,是在迴圈裡宣告 int x = i; 再傳 &x:每一圈的 x 都用同一個位址,問題完全沒有解決。
回傳值也有同樣的陷阱。pthread_exit(3) 特別警告,回傳值不應該放在這個執行緒的堆疊上,因為執行緒結束後堆疊的內容是未定義的,所以不要回傳區域變數的位址。要回傳大資料時用 malloc,由 join 的那一方負責 free。
真實系統上跑傳 &i 的版本(印出每個執行緒讀到的值):
第 1 次:got 1 got 3 got 3
第 2 次:got 1 got 3 got 3
第 3 次:got 1 got 3 got 3
第 4 次:got 1 got 3 got 3
第 5 次:got 3 got 3 got 3
第 6 次:got 1 got 3 got 3
在真正的 Linux 上實際執行的結果(Compiler Explorer 的 Linux x86-64 環境,gcc 13.3,2026-11-04)。
沒有一次是 0、1、2。
並行與平行
並行(concurrency)是同一段時間內有多件事在進行,一顆核心輪流跑就做得到。平行(parallelism)是同一個瞬間真的有多件事在執行,要有多顆核心。
執行緒可以帶來兩種好處:一顆核心時,等 I/O 的執行緒不會擋住其他執行緒;多顆核心時,CPU 工作可以真的同時做。第二種好處有上限。
Amdahl 定律(Gene Amdahl 1967 年的論文提出這個論點):如果程式有比例 s 一定要循序執行,用 n 顆核心的加速倍數是
加速倍數 = 1 / (s + (1 − s) / n)
n 再大,分母也不會小於 s,所以上限是 1/s。循序的部分不只是初始化,用同一把鎖保護的臨界區也算:一次只有一個執行緒能進去,就是循序的。
下面這個練習用教學核心驗證。同一支程式放在一顆核心上時,步數 3221、虛擬時間 3.221 秒,每個時間單位只走一步。這裡給的是兩顆核心。
實作:Amdahl 定律算一次
補完公式,看循序比例 5% 和 20% 時,核心加到很多會怎樣。100 萬核那一行就是上限。
確認一下
一個執行緒用 open 開了一個檔案,把回傳的 fd 存在全域變數。同一個行程的另一個執行緒可以用這個 fd 讀檔嗎?
執行緒 A 把區域變數的位址放進全域變數,執行緒 B 用這個位址讀值。A 還沒結束時,會怎樣?
main 建立一個執行緒之後沒有 join,直接 return 0。那個執行緒會怎樣?
下面的程式用 Amdahl 定律計算:程式有 10% 要循序執行,用 4 顆核心,加速倍數(四捨五入到小數第 2 位)是多少?
一支伺服器大部分時間都在等網路回應,機器只有一顆核心。用多個執行緒有幫助嗎?
重點整理
- 執行緒共用位址空間與 fd,各自有堆疊和暫存器。共用讓合作方便,也讓錯誤可以影響所有人。
pthread_create建立、pthread_join等待並拿回傳值。傳參數要確保值在對方讀取時還是對的,不要傳迴圈變數的位址,不要回傳區域變數的位址。- 任何執行緒呼叫
exit,或main回傳,整個行程都會結束。 - 並行是輪流、平行是同時。加核心的效果受循序比例限制(Amdahl 定律),鎖保護的範圍也算循序。
下一課把共用的代價攤開來看:兩個執行緒同時改同一個變數,會發生什麼。