跳到正文

Wiki

std::thread

約 10 分鐘閱讀

本文由簡體中文內容確定性轉換,並受版本化術語表保護。

std::thread 是 C++11 提供的標準執行緒管理類,定義在 <thread> 中。

這一節不只要學會“開一個執行緒”,更重要的是理解:

  • std::thread 物件和實際執行執行緒是什麼關係;
  • 建構函式裡第一個引數和後續引數分別表示什麼;
  • 引數預設是如何儲存的;
  • join()detach()joinable() 分別改變了什麼;
  • 為什麼執行緒物件的解構函式可能直接終止程式;
  • 為什麼 std::thread 不能複製,但可以移動。

1.std::thread 基礎

1.1.std::thread 物件不是“執行緒本身”

可以把 std::thread 理解成一個 管理執行緒執行實體的 C++ 物件

一個 std::thread 物件可能處於兩種重要狀態:

未关联任何执行线程

关联着一个可 join 的执行线程

這個狀態可以通過:

thread.joinable()

查詢。

例如:

#include <print>
#include <thread>

void work()
{
    std::println("Work Done!");
}

int main()
{
    std::thread t1;
    std::println("{}", t1.joinable());

    std::thread t2(work);
    std::println("{}", t2.joinable());

    t2.join();
    std::println("{}", t2.joinable());
}

執行結果:

false
true
Work Done!
false

注意:joinable() 並不是“執行緒現在是不是還在執行”。

即使執行緒函式已經執行完,只要還沒有對對應的 std::thread 呼叫 join()detach(),它仍然可能:

thread.joinable() == true

比如:

#include <print>
#include <thread>

using namespace std::chrono_literals;

void work()
{
    std::println("Work Done!");
}

int main()
{
    std::thread t1;
    std::println("{}", t1.joinable());

    std::thread t2(work);
    std::println("{}", t2.joinable());

    std::this_thread::sleep_for(100ms);
    std::println("{}", t2.joinable());

    t2.join();
    std::println("{}", t2.joinable());
}

執行結果:

false
true
Work Done!
true
false

這裡明顯任務早就結束了,但延時100ms後去看joinable()發現還是true

所以joinable()看的是他現在能不能被join()或者detach(),上面兩個例子中: 當這個執行緒物件使用預設構造,或者說沒有被給一個可呼叫物件的時候,這時候是false; 還有這個執行緒物件已經被join()或者detach()的時候,也會變成false

1.2.建構函式:第一個引數是什麼?後面的引數是什麼?

最常見的構造形式可以概念化為:

std::thread t(callable, arg1, arg2, ...);

其中:

  • 第一個引數:要線上程中執行的可呼叫物件;
  • 後續引數:傳給這個可呼叫物件的實參。

可呼叫物件可以是:

  • 普通函式;
  • Lambda;
  • 函式物件;
  • 成員函式指標。

執行緒建立成功後,新執行緒會開始呼叫這個可呼叫物件。

1.2.1.普通函式

#include <print>
#include <thread>

void print_sum(int a, int b)
{
    std::println("{}", a + b);
}

int main()
{
    std::thread t(print_sum, 10, 20);
    t.join();
}

執行結果:

30

這裡:

print_sum

是第一個引數,即執行緒函式;

10, 20

是傳給 print_sum 的引數。

1.2.2.Lambda

#include <print>
#include <thread>

int main()
{
    std::thread t([](int value) {
        std::println("{}", value);
    }, 42);

    t.join();
}

執行結果:

42

Lambda 本身就是第一個可呼叫物件引數,42 是它的實參。

1.2.3.函式物件

#include <print>
#include <thread>

struct Worker
{
    void operator()(int value) const
    {
        std::println("{}", value);
    }
};

int main()
{
    std::thread t(Worker{}, 7);
    t.join();
}

執行結果:

7

1.2.4.成員函式

呼叫成員函式時,除了成員函式指標,還必須告訴執行緒“在哪個物件上呼叫”。

#include <print>
#include <thread>

class Worker
{
public:
    void run(int value)
    {
        std::println("{}", value);
    }
};

int main()
{
    Worker worker;

    std::thread t(&Worker::run, &worker, 100);
    t.join();
}

執行結果:

100

這裡:

&Worker::run

是成員函式指標;

&worker

表示在哪個物件上呼叫;

100

才是 run(int) 自己的引數。

2.引數傳遞與生命週期

2.1.執行緒引數預設會儲存副本

這是 std::thread 最容易讓初學者誤解的地方之一。

例如:

void foo(int value);

int x = 10;
std::thread t(foo, x);

執行緒並不是簡單地“記住表示式 x”。標準庫會把傳入引數經過衰減後儲存進執行緒內部,再在新執行緒中呼叫。

因此很多情況下,執行緒拿到的是引數副本。

2.1.1.按值傳遞

#include <print>
#include <thread>

void change(int value)
{
    value = 100;
}

int main()
{
    int x = 10;

    std::thread t(change, x);
    t.join();

    std::println("{}", x);
}

執行結果:

10

因為執行緒修改的是自己的引數副本。

2.2.std::ref():真正按引用傳遞

如果執行緒函式要求:

void change(int& value);

並希望修改原物件,應使用:

std::ref(x)

例如:

#include <functional>
#include <print>
#include <thread>

void change(int& value)
{
    value = 100;
}

int main()
{
    int x = 10;

    std::thread t(change, std::ref(x));
    t.join();

    std::println("{}", x);
}

執行結果:

100

std::ref(x) 返回的是一個:

std::reference_wrapper<T>

它本身可以被複制,但呼叫可呼叫物件時會還原為對原物件的引用。

2.2.1.std::cref()

如果需要按 const T& 傳遞,可以使用:

std::cref(x)

例如:

void print(const std::string& text);

std::string message = "hello";
std::thread t(print, std::cref(message));

2.3.std::ref(x)&x 完全不是一回事

這兩個寫法經常被混淆。

std::ref(x)

表示“以引用語義傳遞原物件”。

而:

&x

是取地址,型別是指標。

例如:

void f1(int& value);
void f2(int* value);

int x = 10;

std::thread t1(f1, std::ref(x));
std::thread t2(f2, &x);

兩者對應的是不同的形參型別。

2.4.Lambda 捕獲也要注意值和引用

執行緒和 Lambda 經常配合使用。

按值捕獲:

int x = 10;

std::thread t([x] {
    std::println("{}", x);
});

Lambda 內儲存自己的 x 副本。

按引用捕獲:

int x = 10;

std::thread t([&x] {
    x = 20;
});

t.join();

執行緒直接訪問外部的 x

按引用捕獲不會延長 x 的生命週期,因此必須確保執行緒使用 x 時,x 仍然存在。

3.執行緒收尾與 joinable 狀態

3.1.join():等待執行緒結束

t.join();

表示:

當前執行緒在這裡阻塞,直到 t 所關聯的執行緒執行結束。

例如:

#include <chrono>
#include <print>
#include <thread>

using namespace std::chrono_literals;

void work()
{
    std::this_thread::sleep_for(1s);
    std::println("worker done");
}

int main()
{
    std::thread t(work);

    std::println("before join");
    t.join();
    std::println("after join");
}

執行結果:

before join
worker done
after join

執行關係是:

创建 t

work 与 main 并发执行

main 到达 join

等待 work 结束

join 返回

main 继续执行

呼叫成功後:

t.joinable() == false

3.1.1.join() 不會自動延長任意物件生命週期

join() 只是在它被呼叫的位置等待目標執行緒。

下面是安全的:

int x = 10;
std::thread t([&x] {
    ++x;
});

t.join();
// 到这里线程已经结束,而 x 还活着

但如果物件先離開作用域,再去等待執行緒,就已經太遲:

std::thread t;

{
    int x = 10;
    t = std::thread([&x] {
        // 可能访问 x
    });
} // x 在这里销毁

// 此时线程可能仍在访问已经销毁的 x
t.join();

3.2.detach():執行緒脫離管理

t.detach();

官方定義:呼叫 detach() 後,執行緒會繼續獨立執行,而當前 std::thread 物件不再與該執行緒關聯。

為了方便理解,也可以“民辦”地理解成: detach() 一呼叫,當前執行緒物件就會立刻變成不可 joinable(); 它有點像一個“不會阻塞的 join()”——區別在於,join() 會等待執行緒執行結束後再返回,而 detach() 不會等待,呼叫後立即返回,讓執行緒自己在後臺繼續執行。

因此:

t.joinable() == false

detach() 最大的問題是生命週期很難管理。

錯誤示例:

void start()
{
    int value = 42;

    std::thread t([&value] {
        std::println("{}", value);
    });

    t.detach();
} // value 已经销毁,但脱离线程可能仍在使用它

這會產生懸空引用和未定義行為。

初學和普通工程程式碼中,優先考慮明確 join(),不要為了“讓執行緒後臺跑”就習慣性 detach()

3.3.joinable():判斷能否 join

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

常見狀態變化:

操作 joinable()
預設構造 std::thread t; false
std::thread t(f); true
執行緒函式已經執行完,但尚未 join/detach true
呼叫 join() false
呼叫 detach() false
被 move-from 的 thread false

3.4.為什麼忘記 join/detach 會直接終止程式?

如果一個 std::thread 物件析構時仍滿足:

t.joinable() == true

程式會呼叫:

std::terminate()

這個std::terminate()會立刻終止整個程式。它不是“結束當前執行緒”,而是整個程序直接進入異常終止流程。

例如:

#include <thread>

void work()
{
}

int main()
{
    std::thread t(work);
} // 错误:t 析构时仍然 joinable

執行結果: 程式會因為 std::thread 物件析構時仍然 joinable() 而呼叫 std::terminate(),示例輸出類似:

terminate called without an active exception

可以把規則概念化成:

~thread()
{
    if (joinable())
    {
        std::terminate();
    }
}

這不是標準庫原始碼,只是幫助理解析構語義。

標準庫不會偷偷幫你自動 join(),因為自動等待可能讓程式在一個意料之外的位置長時間阻塞;也不會偷偷 detach(),因為那可能製造更嚴重的生命週期錯誤。

4.管理權與執行緒工具

4.1.std::thread 不可複製,但可以移動

執行緒的管理權不能被兩個 std::thread 物件同時擁有,因此它不可複製:

std::thread t1(work);

// std::thread t2 = t1; // 编译错误

但可以轉移所有權:

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

移動後:

t1.joinable() == false
t2.joinable() == true

最後應該由 t2 負責 join()detach()

這也是為什麼 std::vector<std::thread> 可以工作:容器通過移動而不是複製管理執行緒物件。

4.2.std::this_thread

std::this_thread 提供“操作當前執行緒”的工具。但他不是隻能在std::thread的物件內使用。

4.2.1.獲取執行緒 ID

std::this_thread::get_id()

示例:

#include <print>
#include <thread>

int main()
{
    std::println("{}", std::this_thread::get_id());
}

執行結果示例(執行緒 ID 每次執行可能不同):

139779631826496

4.2.2.休眠

std::this_thread::sleep_for(...)
std::this_thread::sleep_until(...)
函式 引數 含義
sleep_for(duration) 時間長度 再睡多久
sleep_until(time_point) 時間點 睡到什麼時候

例如:

//睡100ms
std::this_thread::sleep_for(std::chrono::milliseconds(100));
//到相对于now()之后的100ms
auto next = std::chrono::steady_clock::now();
next += 100ms;
std::this_thread::sleep_until(next);

4.2.3.主動讓出執行機會

std::this_thread::yield();

它只是向排程器提示“當前執行緒願意暫時讓出執行機會”,並不保證另一個指定執行緒一定馬上執行,也不保證下一個執行的執行緒不是自己。

比較形象一點的說法:當前執行緒主動告訴作業系統排程器:“我現在可以先讓一下 CPU,你可以考慮讓別的執行緒先執行。” yield() 只是“主動讓出這次 CPU 排程機會”,但馬上又被排程回來也是完全可能的。

比如:

while (!ready) 
{
    std::this_thread::yield();
}

這種比while (!ready) {}這種空轉要好很多,因為空轉會一直霸佔 CPU,而 yield() 會給其他執行緒更多執行機會。

4.3.hardware_concurrency()

std::thread::hardware_concurrency()

一般用來查詢:當前系統大概支援多少個執行緒可以並行執行。一般是CPU 的邏輯核心數 / 硬體執行緒數。

#include <print>
#include <thread>

int main()
{
    std::println("建议并发线程数:{}", std::thread::hardware_concurrency());
}

執行結果示例(數值取決於當前機器):

建议并发线程数:16

假設你的 CPU 是 8 核 16 线程,那麼 std::thread::hardware_concurrency() 通常會返回 16

但這並不意味著程式最多隻能建立 16 個執行緒。它表示的是當前硬體大約能夠支援多少個執行緒真正並行執行,也就是硬體併發能力的參考值。 即使只有 16 個邏輯處理器,只要系統資源允許,依然可以建立幾十個甚至上百個執行緒。

當執行緒數量超過硬體能夠真正並行執行的數量時,作業系統會通過時間片輪轉、執行緒排程等機制,讓這些執行緒交替獲得 CPU 執行時間,從宏觀上看起來像是在“同時執行”。雖然這不是真正意義上的完全並行,但在很多場景下,從程式執行效果上看和真正併發並沒有特別明顯的區別。(類似STM32上使用的FreeRTOS,用1個CPU核心來模擬多執行緒)

另外,如果函式返回 0,表示當前實現無法提供硬體併發資訊,因此不能把這個返回值當作絕對可靠的固定值。

4.4.多執行緒輸出順序是不確定的

#include <print>
#include <thread>

void print(char c)
{
    for (int i = 0; i < 5; ++i)
    {
        std::print("{}", c);
    }
}

int main()
{
    std::thread t1(print, 'A');
    std::thread t2(print, 'B');

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

執行結果示例(執行緒排程不同,順序可能不同):

BBBBBAAAAA

不能依賴輸出一定是:

AAAAA
BBBBB

完全有可能是

BBBBB
AAAAA

等很多可能性。

執行緒排程順序由作業系統和執行環境決定。如果執行順序本身是程式正確性的必要條件,就必須顯式同步。

5.常見錯誤

5.1.錯誤:忘記 join 或 detach

std::thread t(work);
// 离开作用域前没有处理 t

結果可能是 std::terminate()

5.2.錯誤:把引用形參誤認為 thread 會自動按引用傳遞

void foo(int& x);
int value = 10;

// std::thread t(foo, value); // 不要把它理解成自动引用传递

需要原物件引用語義時寫:

std::thread t(foo, std::ref(value));

5.3.錯誤:detach 後引用區域性變數

區域性變數可能線上程使用前就銷燬。

5.4.錯誤:重複 join

t.join();
// t.join(); // 此时已经不再 joinable

5.5.錯誤:認為執行緒函式結束後 joinable 自動變 false

執行緒執行完和執行緒物件解除關聯是兩件不同的事情。

6.C++20:優先了解 std::jthread

std::thread 的一個明顯風險是:必須手動確保所有控制路徑最終都正確 join()detach()

C++20 提供了:

std::jthread

它在析構時會自動請求停止並等待執行緒結束,而且支援 std::stop_token 協作式取消。

基礎原理理解完 std::thread 後,建議繼續學習本章後面的 std::jthreadstd::stop_token

7.小結

  • std::thread t(f, args...) 的第一個引數是可呼叫物件,後面的引數是傳給它的實參。
  • 執行緒引數預設會被儲存,引用語義通常要顯式使用 std::ref() / std::cref()
  • join() 等待執行緒結束,並讓執行緒物件變為 non-joinable。
  • detach() 讓執行緒脫離當前 std::thread 物件,生命週期風險較大。
  • joinable() 判斷物件是否仍關聯一個可 join 的執行緒,不等於執行緒此刻是否正在執行。
  • joinable 的 std::thread 析構會呼叫 std::terminate()
  • std::thread 不可複製,但可以移動。
  • 執行緒共享引用、指標或物件時,必須額外考慮物件生命週期和資料競爭。