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 不會改變。
迷你表:引數如何傳遞
| 你啟動執行緒時寫的東西 | 函式實際收到的是 | 典型效果 |
|---|---|---|
|
複本 x | 執行緒不會改變原本 |
|
對 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(),且盡量讓執行緒的移動操作稀少且顯眼(以便於程式閱讀)。
GO TO FULL VERSION