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

用 pthreads 建立執行緒

第 2 篇用畫面畫出了行程與執行緒之間的界線;現在我們真的來造一條執行緒。你會認識 pthread_create() 與 pthread_join(),學會傳一個引數進去、再把結果拿回來的那個正確做法,看見經典的迴圈變數陷阱,並親眼看著一個小小的共享計數器悄悄出錯——那正是接下來兩篇要解開的懸念。

撐起整個 API 的四個呼叫

從上一篇你帶著一個事實:一條執行緒是一個行程裡的第二個跑者,共用它的位址空間。現在我們來造一條。經典的 Unix 介面是 POSIX 執行緒,pthreads,而日常工作其實濃縮成出乎意料地少的幾個呼叫。pthread_create() 啟動一條新執行緒;pthread_join() 等一條執行緒結束;pthread_exit() 提早結束目前這條執行緒;pthread_self() 把呼叫者自己的識別碼交回來。把 pthread_create() 與 pthread_join() 弄熟,你就已經能寫出真正的並行程式了——其餘都是細節。

仔細看 pthread_create()。它的型別簽章是 pthread_create(pthread_t *thread, const pthread_attr_t *attr, void *(*start)(void *), void arg)。第三個參數是它的核心:start 是一個函式指標——也就是新執行緒將開始執行的那個函式的位址。一條執行緒並不是從 main() 開始跑一整支程式;它跑的是你指名的那一個函式*,從頭到尾,而當那個函式回傳時,這條執行緒就結束了。那個函式的形狀必須剛好是 void *f(void *)——它收一個 void *、回傳一個 void *。正是那個僵硬的形狀,讓同一個 API 能啟動你想要的任何函式。

你的第一條真執行緒,從頭到尾

這是最小、卻值得實際跑一遍的完整程式。main() 本身就是一條執行緒——執行時系統幫你啟動的那條主執行緒——而它生出第二條來跑 worker()。接著它呼叫 pthread_join() 等待。沒有那個 join,main() 可能就回傳了、行程跟著結束,而那條剛出生的 worker 還沒印出半個字就被殺掉了。Join 不是可有可無的客套;它是父方用來保證子方真的跑完了的手段。

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

void *worker(void *arg) {
    long id = (long)arg;            /* unpack the argument */
    printf("worker %ld is running\n", id);
    return (void *)(id * 10);       /* this becomes the join result */
}

int main(void) {
    pthread_t t;
    int rc = pthread_create(&t, NULL, worker, (void *)7L);
    if (rc != 0) {
        fprintf(stderr, "pthread_create: %d\n", rc);
        return 1;
    }

    void *ret;
    rc = pthread_join(t, &ret);     /* wait, and collect the return */
    if (rc != 0) {
        fprintf(stderr, "pthread_join: %d\n", rc);
        return 1;
    }
    printf("worker returned %ld\n", (long)ret);   /* prints 70 */
    return 0;
}

/* build:  gcc -O2 -Wall main.c -pthread  &&  ./a.out */
一支完整、有檢查錯誤的 pthreads 程式。attr 給 NULL(採用預設的執行緒設定);引數 7 被當成 void * 偷渡進去,回傳值 70 也用同樣的方式偷渡出來。注意 -pthread 這個旗標——它連結執行緒函式庫、並設定正確的巨集定義。

追一遍這支程式的一生。main() 執行 pthread_create();現在存在兩條執行緒,作業系統可能以任一順序跑它們、或真正並排跑。worker 印出東西、算出一個值、回傳。與此同時,main() 把自己停在 pthread_join() 裡頭、睡著了,直到 worker 的函式回傳——這時 join 醒來、執行緒被拆掉、它的回傳值落進 ret。兩條控制流,一個乾淨的會合點。這是你必須永遠成對思考的那一對裡的 join 那一半:你建立的每一條執行緒,都應該不是被 join、就是被 detach。

傳進去、拿回來:全靠一個 void *

注意上面那個小技巧:我們把一個 long 轉型成 void * 傳進去,再把 void * 轉回 long 讀出結果。那之所以行得通,只是因為在一台典型的 64 位元平台上,一個 long 塞得進一個指標。要傳真正的資料——一個結構、一個陣列、好幾個值——誠實又可擴展的做法,是傳一個指向它的指標。你把某個它能讀的東西的位址交給執行緒,而當執行緒去看它的時候,那個東西必須還活著。這裡正住著一個尖銳又常見的臭蟲。

經典的陷阱,是在迴圈裡啟動執行緒、又把迴圈變數的位址交給每一條:pthread_create(&t[i], NULL, worker, &i)。每一條執行緒收到的都是同一個位址——那獨一無二的 i——而等到執行緒真正去讀它時,迴圈已經往前衝、i 已經變了。你本想給執行緒 0 值 0,它卻可能讀到 3。修法是給每一條執行緒它自己的、活著的儲存空間:傳一個每執行緒陣列的元素,或者用轉型小技巧把 i 以值傳遞。更深一層的教訓是所有權與生命期:被指向的資料,必須活得比執行緒對它的使用更久,而在一條執行緒自己的堆疊上,一個區域變數在它的函式回傳時就死了——所以絕不要把一個指向即將消失的堆疊框架的指標交給另一條執行緒。

Join 或 detach:每條執行緒都要有個結局

一條結束的執行緒不會就這樣蒸發掉。就像一個行程會變成殭屍、直到它的父方收割它一樣,一條結束了卻沒被 join 的執行緒,會留著一小塊核心與函式庫的記帳資料活著,好讓某個人還能呼叫 pthread_join() 去讀它的結果。如果你既不 join、也不 detach,那塊記帳資料就會隨你造的每一條執行緒洩漏——對一支只造三條執行緒的程式無傷大雅,對一台每個請求造一條、卻從不收割的伺服器,則是慢性死亡。

所以恰好有兩種誠實的結局,而 join 與 detach 就是這兩者的名字。當你需要它的結果、或需要知道它做完了,就join它——pthread_join() 會擋住呼叫者直到執行緒結束,然後釋放那塊記帳資料。當你永遠不會等它,就detach它——pthread_detach()(或從一開始就把它建成 detached 的)告訴系統在執行緒一結束就自動清理;一條 detached 的執行緒自顧自地跑到完成,而你事後永遠無法 join 它。每條執行緒挑一個。一條既沒被 join、也沒被 detach 的執行緒,是一個冠著你名字的資源洩漏。

它在哪裡壞掉:兩條執行緒、一個計數器

現在我們把這些零件拼起來——然後一頭撞上這個章節之所以存在、要去攀越的那道牆。啟動兩條執行緒,讓兩者都把同一個全域計數器各加一百萬次,join 它們,然後印出總和。你預期 2000000。你常常會拿到更少。每條執行緒都跑著一個整齊的 count++ 迴圈,但 count 住在共用的位址空間裡——它是共用可變狀態——而如第 1 篇所暗示的,count++ 不是一個不可分割的步驟,而是三個:載入、加一、存回。排程器有權交錯兩條執行緒的載入與存回,而當它這麼做時,遞增就悄悄不見了。

  1. 兩條執行緒都讀 count,它此刻是 41——各自把 41 載入自己的暫存器。
  2. 執行緒 A 加一(暫存器 = 42),把 42 存回 count。
  3. 執行緒 B 還握著它那個過時的 41,加一(暫存器 = 42),也把 42 存回去。
  4. 發生了兩次遞增,但 count 只從 41 變成 42——一次更新被無聲地吞掉了。

把那支程式跑五次,你可能會看到 2000000、1998412、1999999,每次一個不同的錯誤數字——答案取決於每跑一次都不一樣的時序。這就是一個競爭條件,它不是 pthreads 或你那個迴圈的瑕疵;它是兩條執行緒在毫無協調之下去碰同一個可變變數,所無可避免的後果。每條執行緒跑的那個函式不是執行緒安全的——它無法被兩條執行緒同時安全地在共用資料上執行。關鍵在於:建立執行緒,也就是這整篇的主題,是又容易又可靠的那部分。協調它們才是難的那部分,而那正是接下來兩篇要去的地方。

你現在會做什麼,以及前方的懸崖

退一步,認清你已經擁有的。你現在能用 pthread_create() 生出一條真正的 OS 執行緒、把它指向一個 void *(*)(void *) 的函式、透過一個 void * 把引數偷渡進去、把結果偷渡出來(小值用轉型、真資料用一個指向某個會活著的東西的指標)、檢查每一個回傳值,並用 join 或 detach 乾淨地結束每一條執行緒。這就是讓並行程式跑起來的、完整而誠實的機械原理。這真的沒多少 API——而那份極簡正是重點:pthreads 給你跑者,然後讓開。

但最後那個例子是一記警告射擊。執行緒跑得完美無瑕;錯的是答案,有時候、難以預測。沒有東西崩潰、沒有東西印出錯誤——程式只是在某些跑次、不在另一些跑次,對一個數字撒了謊。那就是並行令人不安的那張臉:難的部分從來不是啟動執行緒,而是它們共用記憶體、而那共用未經協調。第 4 篇〈競爭條件:當 count++ 撒謊時〉,會把那個壞掉的計數器一條指令、一條指令地完全拆開,直到你能精確預測一次更新會如何、在何時被弄丟。第 5 篇〈為什麼我們需要同步〉,接著把解藥交到你手裡——那個讓一次只有一條執行緒進入危險的「載入—加一—存回」的互斥。你已經造好了引擎;接下來,你要學的是它為什麼需要一把鎖。