CodeGym /課程 /C++ SELF /std::thread — 啟動執行緒、引數、join

std::thread — 啟動執行緒、引數、join

C++ SELF
等級 68, 課堂 1
開放

1. 最簡單的啟動執行緒:函式與 lambda

當你第一次聽到「建立執行緒」時,會覺得這像是某種抽象概念:好像「讓某段程式在另一個線程上執行」。實際上在 C++ 中執行緒是作業系統的資源,標準庫提供了一個物件來「擁有」這個資源。就像 std::unique_ptr:有所有者,就有責任。

std::thread 是一個物件,建立時會啟動新的執行緒並對其「負責」。從那一刻起,你在同一個程序內有兩條獨立的執行路徑:main 繼續執行,而新執行緒開始執行你給它的工作。重要的是:執行緒不是魔法,而是紀律。如果你建立了一個執行緒,你有責任透過 正確地等待其完成(至少在本講義的範圍內)— 通常透過 join()。

感受 std::thread 最簡單的方法是啟動一個短小的任務,觀察輸出順序可能會「浮動」。要用到的標頭是 <thread>。輸出用 <iostream>。

用普通函式啟動執行緒

執行緒會在 std::thread 的建構子中啟動:你傳入一個 callable(例如函式),它就開始執行。

#include <iostream>
#include <thread>

void say_hello() {
    std::cout << "hello from worker\n";
}

int main() {
    std::thread t(say_hello); // 執行緒在此啟動
    t.join();                 // 等待結束
    std::cout << "done\n";    // done
}

請注意這點:join() 不是「可選項」,而是擁有合約的一部分。我們建立了執行緒 — 我們必須等待它完成。

用 lambda 啟動執行緒

lambda 很方便,因為不需要額外宣告函式,尤其是工作很小時。

#include <iostream>
#include <thread>

int main() {
    std::thread t([] {
        std::cout << "worker: hi\n";
    });

    std::cout << "main: hi\n"; // 可能在 worker 之前或之後印出
    t.join();
}

這裡我們再次看到不確定性:字串 "main: hi" 可能先印出,也可能後印出。這不是「編譯器的 bug」,而是執行緒的正常行為。

3. 執行緒引數:複製、參考與移動

當執行緒啟動函式時,通常要傳遞資料:字串、數字、容器、設定。新手常犯的經典錯誤是:「我傳了一個變數,但執行緒裡它沒被改變」。通常原因很簡單:你傳的是複本,但期望的是「操作原本的物件」。

重要的是記住模型:std::thread 內部會儲存引數的複本/被移動的值(這常被描述為 decay-copy)。標準對此甚至有討論,暗示:引數的轉換/複製行為是設計的重要部分。

以值傳遞

傳遞字串與數字:

#include <iostream>
#include <string>
#include <thread>

void repeat(std::string text, int n) {
    for (int i = 0; i < n; ++i) {
        std::cout << text << '\n';
    }
}

int main() {
    std::thread t(repeat, std::string{"Hi"}, 2);
    t.join();
}

這裡 text 是字串的複本(或依你傳入的狀況被移動)。這樣做的好處是:執行緒不依賴於 main 中變數的生命週期。

「為什麼變更沒回來?」— 因為是複本

#include <iostream>
#include <thread>

void inc(int x) {
    ++x; // 增加的是複本
}

int main() {
    int value = 10;
    std::thread t(inc, value);
    t.join();

    std::cout << value << '\n'; // 10
}

邏輯很簡單:value 被複製成參數 x。執行緒把 x 增加了,但 value 保持不變。

以參考傳遞:std::ref 與 std::cref

如果你真的想讓執行緒裡的函式操作原始變數,你需要明確表明「傳參考」。通常使用 std::ref(可變參考)和 std::cref(const 參考)。它們定義在 <functional>。

非常重要:以參考傳遞幾乎自動提升生命週期與競爭條件的風險。因此在本講義中我們採取簡單做法:執行緒寫入 → 我們呼叫 join() → 之後主線程再讀結果。

#include <functional>
#include <iostream>
#include <thread>

void inc_ref(int& x) {
    ++x;
}

int main() {
    int value = 10;

    std::thread t(inc_ref, std::ref(value)); // 注意:std::ref
    t.join();

    std::cout << value << '\n'; // 11
}

如果去掉 std::ref,又會是複本,value 不會改變。

迷你表:引數如何傳遞

你啟動執行緒時寫的東西 函式實際收到的是 典型效果
t(f, x)
複本 x 執行緒不會改變原本
t(f, std::ref(x))
對 x 的參考 執行緒可以改變原本
t(f, std::cref(x))
const-參考到 x 執行緒在不複製的情況下讀取

把這張表記在腦中,當你看到異常行為時可以作為「急救」參考。

傳遞只可移動或「昂貴」的物件:使用 std::move

有時你想把一個很大的 std::vector 傳給執行緒,或傳入不能複製的物件。這時需要移動語義。

#include <iostream>
#include <thread>
#include <utility>
#include <vector>

void process(std::vector<int> v) {
    std::cout << "size = " << v.size() << '\n'; // size = 3
}

int main() {
    std::vector<int> data{1, 2, 3};

    std::thread t(process, std::move(data)); // 將資料移入執行緒
    t.join();

    std::cout << "after move, data.size() = " << data.size() << '\n';
}

經過 std::move 之後,main 中的物件仍然有效,但其「移動後的狀態」不應被當作原本內容仍在的保證。對 vector 而言通常會變成 size() == 0,但不要把這當作嚴格保證。

4. join(): 放在哪裡以及它保證什麼

如果啟動執行緒是「開啟第二條生產線」,那麼 join() 就是「等生產線完成」。聽起來簡單,但在程式碼中養成習慣很重要:你應該目視可見地看到執行緒在哪裡結束。

join() 做兩件實務上的事:一是阻塞目前的執行緒(通常是 main),直到工作執行緒完成;二是將 std::thread 物件轉為「不再擁有活動執行緒」。這點很重要,因為不能對同一個執行緒物件呼叫兩次 join()。

std::thread 的生命週期流程圖

flowchart TD
    A["建立了 std::thread t(...)"] --> B["t 變成 joinable()"]
    B --> C["工作執行緒執行該函式"]
    C --> D["main 呼叫 t.join()"]
    D --> E["main 等待執行緒完成"]
    E --> F["執行緒完成,t 不再是 joinable()"]

圖的意思是:joinable 表示責任狀態——只要執行緒是 joinable,就不能隨意「丟棄」它。

用 joinable() 當作避免重複 join 的保險

有時你的邏輯比較複雜(例如提早 return),你可以在呼叫 join 前安全地檢查。

#include <iostream>
#include <thread>

int main() {
    std::thread t([] { /* work */ });

    if (t.joinable()) {
        t.join();
    }

    std::cout << "ok\n"; // ok
}

如果在此之後再嘗試呼叫 join(),會在執行時發生錯誤(通常拋出 std::system_error)。實務上:join 就是一次性的。

忘記 join() 會發生什麼

如果 std::thread 物件在仍擁有正在執行的執行緒時被銷毀,標準要求程式關閉並呼叫 std::terminate。這是刻意設計:避免你不小心寫出「無責任的背景執行緒」。

在實際執行時看起來會是:程式突兀地結束,有時沒有明確訊息(視環境/IDE 而定)。因此今天的規則很嚴格:建立 std::thread → 一定要到 join()。

std::thread 無法複製,但可以移動

某個時刻你可能會嘗試:

std::thread t1([]{});
std::thread t2 = t1; // 這樣不行

編譯器會告訴你:複製已被刪除。這是合理的:執行緒表示所有權。如果允許複製,兩個物件就會同時擁有同一個執行緒,最終兩邊都會嘗試依照合約結束它——後果可想而知。

因此 std::thread 是只能移動的(move-only):可以被 移動(像 std::unique_ptr 一樣)。

#include <iostream>
#include <thread>
#include <utility>

int main() {
    std::thread t1([] { /* work */ });

    std::thread t2 = std::move(t1);

    std::cout << std::boolalpha
              << "t1.joinable(): " << t1.joinable() << '\n'
              << "t2.joinable(): " << t2.joinable() << '\n';

    t2.join();
}

移動之後責任會「搬到」t2。而 t1 會變成空(不 joinable)。這也是喜歡使用 joinable() 的另一個原因:它幫助你辨認擁有者。

5. TextStats 小範例:計算文字統計

現在我們做一個小示範程式,方便在後續範例中擴充。想法是:從使用者讀一行字串,然後計算統計(字元數與詞數)。計算工作會放到執行緒裡。今天的安全規則是:主線程在沒有做 join() 前不會讀取結果。

資料模型:struct Stats

#include <cstddef>

struct Stats {
    std::size_t chars = 0;
    std::size_t words = 0;
};

沒什麼玄機:就是個結果容器。

計算詞數:簡單的函式

為了不陷入 Unicode 與標點複雜性,我們把詞定義為「不含空白字元的連續序列」。

#include <string_view>

std::size_t count_words(std::string_view s) {
    std::size_t words = 0;
    bool in_word = false;

    for (char c : s) {
        const bool is_space = (c == ' ' || c == '\t' || c == '\n');
        if (!is_space && !in_word) { ++words; in_word = true; }
        if (is_space) { in_word = false; }
    }
    return words;
}

是的,這是個「玩具級」的分詞方式,但非常適合今天讓執行緒有清楚可理解的工作。

工作函式:把結果寫入 Stats

這裡我們使用對 Stats 的參考,所以啟動執行緒時需要用到 std::ref。

#include <string>
#include <string_view>

void compute_stats(std::string_view text, Stats& out) {
    out.chars = text.size();
    out.words = count_words(text);
}

main:啟動執行緒並 join()

#include <functional>
#include <iostream>
#include <string>
#include <thread>

int main() {
    std::cout << "Enter a line:\n";

    std::string line;
    std::getline(std::cin, line);

    Stats stats;

    std::thread worker(compute_stats, std::string_view{line}, std::ref(stats));

    std::cout << "Computing...\n"; // 計算中...
    worker.join();                 // 等待

    std::cout << "chars = " << stats.chars << '\n';
    std::cout << "words = " << stats.words << '\n';
}

請注意生命週期契約的謹慎:line 的生命延續到 main 結束,而我們在所有情況下都在 join() 之前等待。這保證了 std::string_view{line} 在執行緒中不會變成懸 dangling。就今天而言,這是個很好的「正確」範本:傳遞參考/視圖 → 保證擁有者活得夠久 → 在擁有者銷毀前 join()。

兩個執行緒、兩個結果:不共用寫入的分工

想要真正的平行性:兩個執行緒分別在字串的不同半段計算詞數。這裡我們避免共用可變狀態:每個執行緒寫自己的結果,主線程只在 join() 後讀取。

這是重要的教學策略:我們尚未討論共用資料保護機制,因此設計上避免同一可變記憶體被同時存取。

函式「計算範圍內的詞數」並回傳數字

為簡單起見,我們在 string_view 上計算詞數,並把結果放到輸出參數;直接從執行緒回傳結果的機制(像 future/promise)是另一個主題。

#include <cstddef>
#include <string_view>

void count_words_into(std::string_view s, std::size_t& out) {
    out = count_words(s);
}

啟動兩個執行緒並相加結果

#include <functional>
#include <iostream>
#include <string>
#include <thread>

int main() {
    std::string line;
    std::getline(std::cin, line);

    const std::size_t mid = line.size() / 2;

    std::size_t w1 = 0;
    std::size_t w2 = 0;

    std::thread t1(count_words_into, std::string_view{line}.substr(0, mid), std::ref(w1));
    std::thread t2(count_words_into, std::string_view{line}.substr(mid),     std::ref(w2));

    t1.join();
    t2.join();

    std::cout << "approx words = " << (w1 + w2) << '\n';
}

這裡的 approx(大約)是誠實的:如果分割點恰好落在一個詞的中間,簡單的計數會有誤差。但作為學習並行化的模型非常好:每個執行緒處理自己的獨立部分,我們在 join() 後匯總結果。

6. 使用 std::thread、引數與 join() 時的典型錯誤

錯誤 1:建立了執行緒,卻忘記呼叫 join()
這是新手最常見的情形:只是想試試,結果程式崩潰。此處要接受 C++ 的哲學:如果物件擁有資源,你必須有明確且可讀的釋放/結束路徑。對 std::thread 而言,今天的唯一含義是:執行緒必須活到 join() 被呼叫。

錯誤 2:以為 t(f, x) 會傳參考,而不是複本。
很容易寫出 std::thread(inc_ref, value) 並期待 value 被增加。但預設情況下引數會被「打包」到執行緒中作為獨立值,你在執行緒裡操作的是複本。如果需要參考,請使用 std::ref(value)(或 std::cref 用於讀取)。這不是庫的任性,而是為了避免懸 dangling 參考與非預期地共享可變資料。

錯誤 3:傳遞參考到一個在執行緒結束前就不再存活的物件。
用捕獲參考的 lambda、把參考傳給執行緒、或對本地字串使用 string_view——這些都在擁有者超出作用域時變得危險。因此基本的生存規則是:如果傳遞參考或視圖,確保擁有者比執行緒活得更久,並在擁有者被銷毀前呼叫 join()。

錯誤 4:嘗試用 sleep_for 同步。
誘惑很大:「我 sleep_for(10ms),執行緒一定會來得及完成寫入」。這幾乎總是導致脆弱程式,在你的機器上或許能運作,但在不同核心數、不同負載、或不同環境下就會失敗。延遲可用於視覺觀察執行緒交替,但不應作為正確的資料存取協定。

錯誤 5:對同一物件呼叫 join() 兩次,或在 std::move 後對舊物件呼叫 join()。
join() 是一次性的。呼叫後該執行緒已完成,物件不再是 joinable()。另一個容易出錯的情況是將 std::thread 移動到另一個物件之後仍然在舊物件上呼叫 join。解法簡單且實用:移動後務必檢查誰是 joinable(),且盡量讓執行緒的移動操作稀少且顯眼(以便於程式閱讀)。

1
任務
C++ SELF, 等級 68, 課堂 1
上鎖
數字序列
數字序列
1
任務
C++ SELF, 等級 68, 課堂 1
上鎖
快速修補
快速修補
1
任務
C++ SELF, 等級 68, 課堂 1
上鎖
搬移箱子
搬移箱子
1
任務
C++ SELF, 等級 68, 課堂 1
上鎖
搜尋數字
搜尋數字
留言
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION