National Central University
Uedu Main Site
Explore Uedu
Student Console
Register as Member/Login
Research Informed Consent Center
Survey Center
Educational Big Data Challenge
Claim or create a course
Try an AI Scenario Task
Support & Messages
Uptime Data

UeduGPTs

--

Jupyters

3

Local AI

--

Uedu Code

--

中央大學 AQI 80 24°C PM2.5 21
AI Reply Desktop Notifications

Show a desktop notification when the AI TA finishes replying

Chat Message Notifications

Notify me when classmates post messages in the forum

Sound notification

Play an alert sound whenever there is a new notification

Unit 07

執行緒

同一個行程裡的多條執行路徑:共用什麼、各自有什麼
Interaction實作 30 minute(s)

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。

互動練習:共用的與各自的

兩個執行緒跑同一個函數。一步一步走的時候可以看「記憶體」面板,程式結束之後記憶體就歸還了。教學核心不做位址隨機化,所以位址每次一樣;真實系統每次都不同。

真實系統的樣子

同樣的程式在真正的 Linux 上跑三次(這裡讓兩個執行緒同時活著,才看得到兩個不同的堆疊)。main stack 是主執行緒區域變數的位址:

第 1 次:main stack: 0x7fffb570752c
pid 2: &shared=0x404060 &local=0x7a44c49feebc
pid 2: &shared=0x404060 &local=0x7a44c41fdebc
main: shared=2
第 2 次:main stack: 0x7ffda845376c
pid 2: &shared=0x404060 &local=0x72afb03fdebc
pid 2: &shared=0x404060 &local=0x72afb0bfeebc
main: shared=2
第 3 次:main stack: 0x7ffd9d42a24c
pid 2: &shared=0x404060 &local=0x71901bbfdebc
pid 2: &shared=0x404060 &local=0x71901c3feebc
main: shared=2

在真正的 Linux 上實際執行的結果(Compiler Explorer 的 Linux x86-64 環境,gcc 13.3,2026-11-04)。

每次跑都不一樣,這是位址隨機化的結果,但全域變數位址相同、兩個 &local 不同的規律每次都在。

依 pthreads(7),同一個行程的執行緒共用與各自擁有的主要項目:

共用 各自擁有
位址空間(程式碼、全域變數、堆積) 堆疊(區域變數)、暫存器
行程編號(PID)、父行程編號 執行緒編號
開啟的檔案描述子 errno
訊號的處理方式 訊號遮罩
目前目錄、資源限制 即時排程的策略與優先權

堆疊雖然「各自擁有」,但它仍在同一個位址空間裡,別的執行緒拿到位址就能讀寫。所以「各自」是指不會自動互相干擾,不是硬體上的保護。

互動練習:傳迴圈變數的位址

每個執行緒應該拿到自己的編號,回傳編號乘以 10,所以答案是 0 + 10 + 20 = 30。但是把 &i 傳出去,新執行緒真的開始執行時,i 可能早就變了。按「隨機跑」看看。

傳參數的兩種正確寫法

問題的本質是參數要活得比執行緒讀取它的時間更久,而且不能被別人改。下面兩種都可以:

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。

互動練習:行程結束時,執行緒怎麼辦

pthread_create(3) 寫得很清楚:任何執行緒呼叫 exit,或主執行緒從 main 回傳,整個行程的所有執行緒都會結束。sleep 在這裡用的是虛擬時鐘,不必真的等。

並行與平行

並行(concurrency)是同一段時間內有多件事在進行,一顆核心輪流跑就做得到。平行(parallelism)是同一個瞬間真的有多件事在執行,要有多顆核心。

執行緒可以帶來兩種好處:一顆核心時,等 I/O 的執行緒不會擋住其他執行緒;多顆核心時,CPU 工作可以真的同時做。第二種好處有上限。

Amdahl 定律(Gene Amdahl 1967 年的論文提出這個論點):如果程式有比例 s 一定要循序執行,用 n 顆核心的加速倍數是

加速倍數 = 1 / (s + (1 − s) / n)

n 再大,分母也不會小於 s,所以上限是 1/s。循序的部分不只是初始化,用同一把鎖保護的臨界區也算:一次只有一個執行緒能進去,就是循序的。

下面這個練習用教學核心驗證。同一支程式放在一顆核心上時,步數 3221、虛擬時間 3.221 秒,每個時間單位只走一步。這裡給的是兩顆核心。

互動練習:兩顆核心真的比較快嗎

看畫面上的「步數」與「虛擬時間」。步數是總共執行了幾個機器指令,虛擬時間 0.001 秒是一個時間單位。

實作:Amdahl 定律算一次

補完公式,看循序比例 5% 和 20% 時,核心加到很多會怎樣。100 萬核那一行就是上限。

你的輸出
預期輸出

確認一下

1.

一個執行緒用 open 開了一個檔案,把回傳的 fd 存在全域變數。同一個行程的另一個執行緒可以用這個 fd 讀檔嗎?

2.

執行緒 A 把區域變數的位址放進全域變數,執行緒 B 用這個位址讀值。A 還沒結束時,會怎樣?

3.

main 建立一個執行緒之後沒有 join,直接 return 0。那個執行緒會怎樣?

4.

下面的程式用 Amdahl 定律計算:程式有 10% 要循序執行,用 4 顆核心,加速倍數(四捨五入到小數第 2 位)是多少?

5.

一支伺服器大部分時間都在等網路回應,機器只有一顆核心。用多個執行緒有幫助嗎?

第 1 次作答

重點整理

  • 執行緒共用位址空間與 fd,各自有堆疊和暫存器。共用讓合作方便,也讓錯誤可以影響所有人。
  • pthread_create 建立、pthread_join 等待並拿回傳值。傳參數要確保值在對方讀取時還是對的,不要傳迴圈變數的位址,不要回傳區域變數的位址。
  • 任何執行緒呼叫 exit,或 main 回傳,整個行程都會結束。
  • 並行是輪流、平行是同時。加核心的效果受循序比例限制(Amdahl 定律),鎖保護的範圍也算循序。

下一課把共用的代價攤開來看:兩個執行緒同時改同一個變數,會發生什麼。

這一課完成了!
Next lesson:競爭條件與臨界區
你目前沒有登入,學習進度只存在這個瀏覽器。 Login 後,進度會跟著帳號,換電腦也看得到。