跳到正文

Wiki

std::thread

約 9 分鐘閱讀

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

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

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

  • std::thread 對象和實際執行線程是什麼關係;
  • 構造函數裏第一個參數和後續參數分別表示什麼;
  • 參數默認是如何保存的;
  • join()detach()joinable() 分別改變了什麼;
  • 為什麼線程對象的析構函數可能直接終止程序;
  • 為什麼 std::thread 不能複製,但可以移動。

1.std::thread 對象不是“線程本身”

可以把 std::thread 理解成一個 管理線程執行實體的 C++ 對象

一個 std::thread 對象可能處於兩種重要狀態:

未关联任何执行线程

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

這個狀態可以通過:

thread.joinable()

查詢。

例如:

#include <iostream>
#include <thread>

void work()
{
    std::cout << "Work Done!" << std::endl;
}

int main()
{
    std::thread t1;
    std::cout << std::boolalpha << t1.joinable() << '\n';

    std::thread t2(work);
    std::cout << t2.joinable() << '\n';

    t2.join();
    std::cout << t2.joinable() << '\n';
}

輸出:

false
true
Work Done!
false

注意:joinable() 並不是“線程現在是不是還在運行”。

即使線程函數已經執行完,只要還沒有對對應的 std::thread 調用 join()detach(),它仍然可能:

thread.joinable() == true

比如:

#include <iostream>
#include <thread>

using namespace std::chrono_literals;

void work()
{
    std::cout << "Work Done!" << std::endl;
}

int main()
{
    std::thread t1;
    std::cout << std::boolalpha << t1.joinable() << '\n';

    std::thread t2(work);
    std::cout << t2.joinable() << '\n';

    std::this_thread::sleep_for(100ms);
    std::cout << t2.joinable() << '\n';

    t2.join();
    std::cout << t2.joinable() << '\n';
}

結果是

false
true
Work Done!
true
false

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

所以joinable()看的是他現在能不能被join()或者detach(),上面兩個例子中: 當這個線程對象使用默認構造,或者説沒有被給一個可調用對象的時候,這時候是false; 還有這個線程對象已經被join()或者detach()的時候,也會變成false

2.構造函數:第一個參數是什麼?後面的參數是什麼?

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

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

其中:

  • 第一個參數:要在線程中執行的可調用對象;
  • 後續參數:傳給這個可調用對象的實參。

可調用對象可以是:

  • 普通函數;
  • Lambda;
  • 函數對象;
  • 成員函數指針。

線程創建成功後,新線程會開始調用這個可調用對象。

2.1.普通函數

#include <iostream>
#include <thread>

void print_sum(int a, int b)
{
    std::cout << a + b << '\n';
}

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

這裏:

print_sum

是第一個參數,即線程函數;

10, 20

是傳給 print_sum 的參數。

2.2.Lambda

#include <iostream>
#include <thread>

int main()
{
    std::thread t([](int value) {
        std::cout << value << '\n';
    }, 42);

    t.join();
}

Lambda 本身就是第一個可調用對象參數,42 是它的實參。

2.3.函數對象

#include <iostream>
#include <thread>

struct Worker
{
    void operator()(int value) const
    {
        std::cout << value << '\n';
    }
};

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

2.4.成員函數

調用成員函數時,除了成員函數指針,還必須告訴線程“在哪個對象上調用”。

#include <iostream>
#include <thread>

class Worker
{
public:
    void run(int value)
    {
        std::cout << value << '\n';
    }
};

int main()
{
    Worker worker;

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

這裏:

&Worker::run

是成員函數指針;

&worker

表示在哪個對象上調用;

100

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

3.線程參數默認會保存副本

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

例如:

void foo(int value);

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

線程並不是簡單地“記住表達式 x”。標準庫會把傳入參數經過衰減後保存進線程內部,再在新線程中調用。

因此很多情況下,線程拿到的是參數副本。

3.1.按值傳遞

#include <iostream>
#include <thread>

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

int main()
{
    int x = 10;

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

    std::cout << x << '\n';
}

輸出仍然是:

10

因為線程修改的是自己的參數副本。

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

如果線程函數要求:

void change(int& value);

並希望修改原對象,應使用:

std::ref(x)

例如:

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

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

int main()
{
    int x = 10;

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

    std::cout << x << '\n';
}

輸出:

100

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

std::reference_wrapper<T>

它本身可以被複制,但調用可調用對象時會還原為對原對象的引用。

4.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));

5.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);

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

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

線程和 Lambda 經常配合使用。

按值捕獲:

int x = 10;

std::thread t([x] {
    std::cout << x << '\n';
});

Lambda 內保存自己的 x 副本。

按引用捕獲:

int x = 10;

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

t.join();

線程直接訪問外部的 x

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

7.join():等待線程結束

t.join();

表示:

當前線程在這裏阻塞,直到 t 所關聯的線程執行結束。

例如:

#include <chrono>
#include <iostream>
#include <thread>

using namespace std::chrono_literals;

void work()
{
    std::this_thread::sleep_for(1s);
    std::cout << "worker done\n";
}

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

    std::cout << "before join\n";
    t.join();
    std::cout << "after join\n";
}

執行關係是:

创建 t

work 与 main 并发执行

main 到达 join

等待 work 结束

join 返回

main 继续执行

調用成功後:

t.joinable() == false

7.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();

8.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::cout << value << '\n';
    });

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

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

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

9.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

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

如果一個 std::thread 對象析構時仍滿足:

t.joinable() == true

程序會調用:

std::terminate()

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

例如:

#include <thread>

void work()
{
}

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

可以把規則概念化成:

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

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

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

11.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> 可以工作:容器通過移動而不是複製管理線程對象。

12.std::this_thread

std::this_thread 提供“操作當前線程”的工具。但他不是隻能在std::thread的對象內使用。

12.1.獲取線程 ID

std::this_thread::get_id()

示例:

#include <iostream>
#include <thread>

int main()
{
    std::cout << std::this_thread::get_id() << '\n';
}

12.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);

12.3.主動讓出執行機會

std::this_thread::yield();

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

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

比如:

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

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

13.hardware_concurrency()

std::thread::hardware_concurrency()

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

#include <iostream>
#include <thread>

int main()
{
    std::cout << "建议并发线程数:" << std::thread::hardware_concurrency() << '\n';
}

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

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

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

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

14.多線程輸出順序是不確定的

#include <iostream>
#include <thread>

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

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

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

不能依賴輸出一定是:

AAAAA
BBBBB

完全有可能是

BBBBB
AAAAA

等很多可能性。

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

15.常見錯誤

15.1.錯誤 1:忘記 join 或 detach

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

結果可能是 std::terminate()

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

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

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

需要原對象引用語義時寫:

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

15.3.錯誤 3:detach 後引用局部變量

局部變量可能在線程使用前就銷燬。

15.4.錯誤 4:重複 join

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

15.5.錯誤 5:認為線程函數結束後 joinable 自動變 false

線程執行完和線程對象解除關聯是兩件不同的事情。

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

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

C++20 提供了:

std::jthread

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

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

17.小結

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