在上一篇文章中,我們詳細介紹了在 Linux 平台下如何進行線程管理,包括線程的創建、等待與退出等操作。具體而言,主要是通過調用 Linux 原生 pthread 線程庫提供的接口,例如 pthread_createpthread_join 等。

需要注意的是,pthread 線程庫所提供的接口遵循 POSIX 標準,因此主要適用於 Linux 及其他類 Unix 系統,例如 Unix 和 macOS。然而,在 Windows 平台下,我們無法直接使用 pthread 線程庫來創建或管理線程,因為 Windows 提供了自己的一套線程管理接口。

C++ 作為一門跨平台語言,其編寫的源代碼應當能夠在包括 Linux 和 Windows 在內的多種操作系統上編譯和運行。無論在哪個平台,多線程編程都是常見的需求。自 C++11 標準起,C++ 正式在語言層面引入了對多線程的支持,這為我們提供了一套不依賴特定操作系統的標準線程庫。本文將重點介紹 C++ 線程庫的基本用法與特性。

thread

我們知道,C++ 是一門面向對象的語言,因此可以將大多數事物視為對象。C++ 線程庫的設計也遵循面向對象的思想,將線程本身抽象為一個對象。具體來説,C++ 標準庫中定義了 thread類,該類封裝了線程的關鍵屬性作為成員變量,並提供了線程相關操作作為成員函數,包括常見的線程等待(join)和線程分離(detach)等功能。

前文已提到,C++ 是一門跨平台的語言。這意味着 C++ 代碼不僅應在 Windows 平台上編譯和運行,也應支持在 Linux 等平台上正確執行。對於程序員而言,編寫代碼時通常不希望針對不同平台分別實現。也就是説,我們不應為每個平台單獨編寫一套代碼,而應使同一份代碼能在多種平台上運行。

此時可能有讀者會提出疑問:既然 C++ 採用面向對象的設計,將線程封裝為類,其線程的相關操作對應類的成員函數(如線程等待對應thread::join ),那麼這些成員函數的底層實現必然會封裝操作系統提供的線程接口。例如在 Linux 平台下,join 會封裝pthread1_join ,而在 Windows 平台下,可能需要封裝類似WaitForSingleObject 接口。因此,thread 類在不同平台下必然有不同的實現方式。因為 Linux 和 Windows 操作系統所提供的線程管理接口確實存在根本差異。

C++ 線程庫確實會為不同平台提供 thread 類的多套實現。關鍵在於,線程庫必須能夠識別當前代碼運行所在的平台,從而選擇對應的實現,以實現跨平台能力。這一機制是通過條件編譯 來實現的。

部分讀者可能聽説過條件編譯 這一術語,但對其原理不太熟悉。下面我們簡要介紹這一概念,已熟悉的讀者可跳過此部分。

條件編譯在邏輯上與我們熟知的if-else 條件分支語句相似,但並不完全相同。if-else 語句在程序運行時根據條件判斷決定執行哪個分支,但所有分支的代碼都會被編譯到程序中。

而條件編譯則不同,它在預處理階段就根據預定義的條件決定是否編譯某段代碼。滿足條件的代碼段會被保留並編譯,不滿足條件的部分則不會被編譯到目標文件中。

條件編譯的基本語法使用預處理指令#if#elif#else#endif 組成。其結構如下:

#if 條件1
    // 代碼段1
#elif 條件2
    // 代碼段2
#else
    // 代碼段3
#endif

這裏的 #if 相當於if#elif 相當於 else if#else 相當於 else#endif 標記條件編譯塊的結束。每個#if 必須對應一個 #endif

條件判斷通常基於宏定義。可以使用defined 運算符檢查某個宏是否被定義:

#if defined(EXITCODE)
    // 如果宏 EXITCODE 被定義,則編譯本段
#endif

也可以判斷宏的具體取值:

#if EXITCODE == 1
    // 如果 EXITCODE 的值為 1,則編譯本段
#endif

需要注意的是,若條件編譯指令出現在 main 函數之外,則所包含的代碼只能是全局/靜態變量聲明、類型定義或函數定義等非執行語句,而不能是如 printfstd::cout 這樣的可執行語句。因為 C++ 程序的執行入口是 main 函數,所有可執行語句必須位於函數體內。如果需要在條件編譯中包含可執行語句,應將這些語句置於 main函數或其他函數內部。

需要注意的是,條件編譯支持類似if-else 語句的嵌套結構。通常的嵌套邏輯是:最外層使用defined 運算符檢查某個宏是否被定義;若已定義,則內層進一步判斷該宏的具體取值,從而決定保留並編譯哪一段代碼。實現嵌套條件編譯時,必須注意:每個#if 預處理指令都必須有且僅有一個對應的#endif 指令,用以標記該條件編譯塊的結束。在編寫內層條件編譯塊時,務必在結尾處添加#endif,以便編譯器能夠正確區分內層與外層的條件編譯範圍,從而進行準確編譯。

基於上述內容,我們可以編寫一個條件編譯的示例。其基本邏輯是構建三個嵌套的條件編譯結構:每個條件編譯結構的最外層判斷宏是否被定義,內層則根據宏的取值進行分支選擇。某個條件塊被編譯,則會執行其中的打印語句。由於打印語句屬於可執行代碼,這裏將整個條件編譯塊置於 main 函數內部:

#include<iostream>
#define VERSION 0

int main()
{
#if defined(VERSION)
#if VERSION == 0
    std::cout << "This is version1" << std::endl;
#elif VERSION == 1
    std::cout << "This is version2" << std::endl;
#elif VERSION == 2
    std::cout << "This is version2" << std::endl;
#else
    std::cout << "unknown version" << std::endl;
#endif
#endif
#if defined(PLATFORM)
#if PLATFORM == "windows"
    std::cout << "This is windows" << std::endl;
#else 
    std::cout << "This is Linux" << std::endl;
#endif
#endif
#if defined(DEBUGMODE)
#if DEBUGMODE == 1  
    std::cout << "mode1" << std::endl;
#else 
    std::cout << "mode2" << std::endl;
#endif
#endif
    return 0;
}

深入剖析C++11線程庫std::thread,邁入多線程編程的大門_#數據結構

通過運行結果,我們可以進一步理解條件編譯的工作機制。

理解條件編譯的原理後,我們回到最初的問題:C++ 線程庫是如何實現跨平台的?如前所述,C++ 線程庫會為不同平台提供不同的thread 類實現,但這些實現對外提供統一的接口。這意味着無論在 Windows 還是 Linux 平台,thread 類都具有一致的joindetach 等接口,但其底層實現因平台而異,封裝了各自平台的線程相關接口,這些細節對用户是透明的。

C++ 線程庫識別當前運行平台的方式正是通過條件編譯。不同平台的編譯器(例如 Linux 下的 GCC,Windows 下的 MSVC 等)在編譯 C++ 代碼時,會隱式定義一個平台檢測宏,用於標識代碼所運行的平台。線程庫在實現thread 類時,會先檢查當前程序中定義的平台宏,再通過條件編譯選擇保留對應平台的實現代碼。這正是 C++ 線程庫實現跨平台兼容的核心機制。

// 在thread類的實現文件中

#ifdef _WIN32
#include <windows.h>
#include <process.h>
#elif defined(__linux__) || defined(__unix__)
#include <pthread.h>
#elif defined(__APPLE__)
#include <pthread.h>
#include <mach/mach.h>
#endif

class thread {
private:
#ifdef _WIN32
    HANDLE native_handle;
    DWORD thread_id;
#elif defined(__linux__) || defined(__APPLE__)
#endif

public:
    void join() {
    #ifdef _WIN32
        WaitForSingleObject(native_handle, INFINITE);
        CloseHandle(native_handle);
    #elif defined(__linux__) || defined(__APPLE__)
        pthread_join(native_handle, nullptr);
    #endif
    }
};
void detach() {
#ifdef _WIN32
    CloseHandle(native_handle);
#elif defined(__linux__) || defined(__APPLE__)
    pthread_detach(native_handle);
#endif
}

在瞭解了 C++ 線程庫實現跨平台的原理之後,接下來的內容我們將重點討論 Linux 平台下 thread 類的具體實現。

前文已提到,C++ 是一門面向對象的語言,其核心思想是“一切皆對象”。因此,C++ 為線程也設計了對應的 thread 類。在 thread 類出現之前,在 Linux 平台下編寫多線程代碼時,我們通常調用 Linux 線程庫提供的接口來管理線程的生命週期,例如使用pthread_create 創建線程,使用pthread_join 等待線程結束。由於 Linux 操作系統本身使用 C 語言編寫,其線程管理接口自然也遵循 C 語言風格,採用的是面向過程的管理方式。

C++ 實現線程的方式是將線程封裝為類。原本我們需要調用pthread_create 函數來創建線程,而現在只需直接創建一個thread 對象即可。創建線程時必須為其提供執行上下文。如果使用 pthread_create ,由於其接口是 C 風格的,我們只能傳遞一個函數指針,該指針所指向的全局函數或靜態函數作為線程的執行入口。

而在 C++ 中,創建線程時同樣需要提供執行上下文及相應參數,這部分功能由 thread 類的構造函數實現。該構造函數內部會封裝 pthread_create 接口。值得注意的是,自 C++11 標準引入之後,線程的上下文不再侷限於全局函數或靜態函數,任何可調用對象(callable object)均可作為線程的執行入口,包括函數指針、仿函數(functor)、lambda 表達式以及函數包裝器(如std::function )等。因此,C++ 的線程庫不僅保留了 Linux 平台下線程的基本屬性,還在此基礎上進行了擴展,融入了諸多 C++ 特性。如果僅使用 Linux 原生線程接口編寫多線程代碼,代碼風格將是純粹的 C 風格,無法充分利用 C++ 的強大特性。由此可見,C++ 的
thread 類功能十分強大。在學習 thread 類的過程中,我們將不斷對比 Linux 原生線程庫的使用方式。一旦熟練掌握 thread 類的使用,你將會發現其便利性,並在今後的多線程開發中更傾向於使用 C++ 線程庫,畢竟其具備跨平台支持。

thread 類的強大之處不止於此。除了支持多種可調用對象作為線程執行上下文之外,更重要的是,傳統的 pthread_create 接口對線程函數的原型有嚴格限制:返回值必須為 void* ,參數必須為 void*。如果希望向線程函數傳遞多個參數,在 C 語言中通常需要將參數打包成結構體,並將結構體指針傳遞給線程函數,在線程函數內部再進行類型轉換和解析。而在 C++ 中,我們可以直接將一個返回類型為 void,參數列表任意(包括任意類型、任意數量)的函數作為線程的執行上下文。

這意味着,在 C++ 中傳遞參數不再需要像傳統方式那樣間接進行,而是可以直接將目標函數以及其參數傳遞給 thread 的構造函數。從上層理解,thread 的構造函數會接收可調用對象及其參數,並將參數依次傳遞給可調用對象。

#include<iostream>
#include<thread>

void print(size_t i, size_t j)
{
    for (; i < j; i++)
    {
        std::cout << "I am a thread" << std::endl;
    }
}

int main()
{
    std::thread thread1(print, 0, 2);
    thread1.join();
    return 0;
}

深入剖析C++11線程庫std::thread,邁入多線程編程的大門_#數據結構_02

在上面的代碼中,函數名print 會退化為函數指針。我們也可以利用 C++11 引入的 lambda 表達式作為線程的執行上下文,通過引用捕獲的方式傳遞參數,實現與上述代碼相同的效果:

#include<iostream>
#include<thread>

int main()
{
    size_t i = 0;
    size_t j = 2;
    std::thread thread1([&]() {
        for (; i < j; i++)
        {
            std::cout << "I am a thread" << std::endl;
        }
    });
    thread1.join();
    return 0;
}

深入剖析C++11線程庫std::thread,邁入多線程編程的大門_#c++_03

從上述示例可以看出,C++ 線程庫的強大之處在於:它不僅不限制線程執行上下文函數的參數類型和數量(儘管返回值類型仍有限制),還支持多種形式的可調用對象作為執行上下文,如函數指針、仿函數和 lambda 表達式,極大提升了多線程編程的靈活性和表達力。

thread 類的這一特性十分關鍵。現在讀者可能會好奇其底層實現原理。我們知道,thread 類底層必然會封裝 Linux 線程庫提供的一系列接口,而構造函數的主要功能是創建線程。因此,構造函數最終必定會調用pthread_create 函數。無論構造函數允許我們以何種方式創建線程,其底層實現最終仍會回到調用 pthread_create 這一核心步驟。

由於 pthread_create 函數只能接受返回類型為void* 、參數類型為 void* 的函數原型,Thread 的構造函數必然通過某種機制,將我們提供的線程執行函數(其返回類型為void ,參數類型任意)轉換為符合 pthread_create 要求的函數形式。接下來的內容將揭示 Thread 構造函數中所使用的類型轉換機制。

深入剖析C++11線程庫std::thread,邁入多線程編程的大門_#c++_04


thread 類的構造函數是一個模板構造函數,包含一個固定模板參數和一個可變模板參數。這種設計並不令人意外,因為 thread 類需要支持傳入任意數量和類型的參數,並將這些參數直接傳遞給線程執行函數。由於 thread 類無法預知用户提供的函數具體接收幾個參數、各是什麼類型,因此必須使用可變模板參數來接收任意參數。在理解可變模板參數的作用後,我們還需進一步探究構造函數的實現細節。

觀察thread 模板構造函數的聲明可知,其第一個參數為固定模板參數類型,其餘為可變參數。結合thread 的規定——第一個參數必須為可調用對象(callable object)——我們可以推斷,固定模板參數用於推導可調用對象的類型。例如,仿函數(functor)本質上是一個類,其中定義了operator() ,因此傳入仿函數時,固定模板參數將被實例化為該仿函數類的類型。Lambda 表達式在底層也被編譯為一個仿函數類,其捕獲的變量成為該類的成員變量,因而傳入 lambda 時,固定模板參數也會被實例化為對應的仿函數類型。

除第一個參數外,其餘傳入構造函數的參數會被推導其類型,並將類型與值分別打包到類型參數包和實參包中。接下來的關鍵在於,thread 構造函數如何將這些參數包傳遞給可調用對象。

thread 類內部定義了一個抽象基類,該類沒有成員變量,僅包含一個純虛函數:

class thread {
private:
   //......
    
struct threadData {
        virtual void run() = 0;
        virtual ~threadData() = default;
    };
    //.....
};

作為抽象類,threadData 必須被派生類繼承並實現其純虛函數。Thread 使用一個派生類 threadDataImpl,它同樣具有固定模板參數和可變模板參數,並重寫基類的虛函數。該派生類包含兩個成員變量:一個是固定模板參數類型的可調用對象,另一個是std::tuple 容器,用於存儲參數包中的參數。

template<typename Function, typename... Args>
    struct threadDataImpl : threadData {
        Function func;
        std::tuple<Args...> args;
        //.....
    };

threadDataImpl 類充當了一個包裝器(wrapper)。為了理解其作用,我們需要簡要介紹 std::tuple 容器。

std::tuple 能夠存儲任意數量、任意類型的元素,其實現依賴於可變模板參數和遞歸繼承。由於 tuple 需要處理類型和數量不定的參數,其定義通常包含一個主模板(處理至少一個參數)和一個特化的空 tuple(作為遞歸終止條件):

// 空tuple特化 - 遞歸終止條件
template<>
class tuple<> {};

// 主模板 - 遞歸繼承
template<typename T, typename... Ts>
class tuple<T, Ts...> : private tuple<Ts...> {
private:
    T head;  // 存儲當前元素
    
public:
    // 構造函數:第一個參數初始化value,其餘傳遞給基類
    tuple(const T& first, const Ts&... rest) 
        : head(first), tuple<Ts...>(rest...) {}
    

// 獲取當前元素
T& get() { return value; }

// 獲取基類(剩餘元素的tuple)
tuple<Ts...>& get_rest() { return *this; }

};

tuple通過遞歸繼承實現存儲任意數量元素的魔法。其構造函數將第一個實參用於初始化當前類的成員變量,其餘參數遞歸傳遞給基類tuple的構造函數。當參數包為空時,遞歸終止於空tuple特化版。以下展示了tuple的內存佈局示例:

tuple<int, double, string>
    │
    ├── int head (42)
    └── tuple<double, string> (基類)
        │
        ├── double head (3.14)  
        └── tuple<string> (基類)
            │
            ├── string head ("hello")
            └── tuple<> (基類,空)

// 編譯器實例化過程:

tuple<int, double, std::string>  // 最外層
    : tuple<double, std::string>  // 第一次遞歸繼承
        : tuple<std::string>      // 第二次遞歸繼承  
            : tuple<>             // 遞歸終止

在 thread 構造函數中,會創建一個 threadDataImpl 對象,其中可調用對象用於初始化 func 成員,參數包則被存入 tuple 容器。由於構造函數參數使用萬能引用(universal reference)以保持值類別(value category),在初始化 threadDataImpl 成員時需使用std::forward進行完美轉發:

class thread{
public:
    // 模板構造函數
    template<typename Function, typename... Args>
    thread(Function&& func, Args&&... args) {
        auto data = new threadDataImpl<Function, Args...>(
            std::forward<Function>(func), 
            std::forward<Args>(args)...);
          //......        

}
    //.......
private:
template<typename Function, typename... Args>
    struct threadDataImpl : threadData {
        Function func;
        std::tuple<Args...> args;
        

  threadDataImpl(Function&& f, Args&&... a)
        : func(std::forward<Function>(f))
        , args(std::forward<Args>(a)...) {}
    

void run() override {
    std::apply(func, args);
}
         };
};

關鍵步驟在於,threadDataImpl 重寫的 run() 虛函數中,使用std::apply 將 tuple 中的參數解包並傳遞給可調用對象。std::tuple 的底層實現較為複雜,此處不展開討論,作為使用者,我們只需瞭解其功能為“將tuple 容器存儲元素作為參數依次傳遞給可調用對象”即可。

thread 構造函數最終仍需調用pthread_create 創建線程。由於 pthread_create要求函數簽名必須為 "void* ()(void * )",而類的非靜態成員函數隱含 this 指針,不能直接滿足要求。因此,thread 類定義一個靜態成員函數作為代理(proxy),該函數簽名符合要求,並通過傳入的 void 參數獲取 threadData 對象,調用其 run() 方法:

class thread {
private:
    pthread_t thread_id;
    

// 打包器基類
struct threadData {
    virtual void run() = 0;
    virtual ~ThreadData() = default;
};

// 具體打包器
template<typename Function, typename... Args>
struct threadDataImpl : threadData {
    Function func;
    std::tuple<Args...> args;
    

threadDataImpl(Function&& f, Args&&... a)
    : func(std::forward<Function>(f))
    , args(std::forward<Args>(a)...) {}

void run() override {
    std::apply(func, args);
}

};

// 靜態代理函數
static void* thread_proxy(void* arg) {
    threadData* data = static_cast<threadData*>(arg);
    data->run();
    delete data;
    return nullptr;
}

public:
    // 模板構造函數
    template<typename Function, typename... Args>
    thread(Function&& func, Args&&... args) {
        auto data = new threadDataImpl<Function, Args...>(
            std::forward<Function>(func), 
            std::forward<Args>(args)...);
        

  if (pthread_create(&thread_id, nullptr, thread_proxy, data) != 0) {
        delete data;
        throw std::runtime_error("創建線程失敗");
    }
}


// 禁止拷貝
Thread(const Thread&) = delete;
Thread& operator=(const Thread&) = delete;

};

以上代碼展示了 thread 類如何通過模板、類型擦除和代理函數等機制,將用户提供的任意可調用對象適配為符合 POSIX 線程庫要求的函數形式,從而實現類型安全的線程創建。

在理解了thread 構造函數的原理後,接下來我們繼續探討thread 類的其他構造細節。

創建線程後,通常需要等待其執行結束。在Linux平台下,等待線程通過調用pthread_join 函數實現,該函數接受一個pthread_t 類型的參數作為線程標識。類似地,當我們創建一個thread 對象時,其內部封裝的線程實際上在對象構造完成後即開始執行。要等待該線程,可調用tthread 類提供的join 方法,而該方法底層必然封裝了pthread_join 接口。

這意味着thread類內部必須保存所創建線程的ID。因此,該類會維護一個pthread_t類型的成員變量。在構造函數調用pthread_create創建線程時,該成員變量會被初始化。除了線程ID,thread類通常還會維護一個狀態字段,用於標識線程的當前狀態。

由於一個線程可被等待(joinable)也可被分離(detached),但一旦被分離,便不能再被等待;同樣,一旦被等待,也不能再被分離。因此,thread類需要藉助一個狀態字段來管理線程的生命週期。常見的實現方式是使用枚舉類型定義幾種狀態。例如:

enum ThreadState
{
    Joinable = 0,
    Detached,
    Joined
};

class thread {
private:
    pthread_t thread_id;
    ThreadState _state;
    // ...
};

這裏定義了三個枚舉常量:Joinable 表示線程可被等待,Detached 表示線程已分離,Joined 表示線程已被等待過。

基於此,我們可以理解join 方法的實現原理:首先檢查當前狀態字段是否為joinable ,若不是,則等待失敗,應拋出異常。此外,還需檢查thread_id 是否有效。因為即使線程已被成功等待(即已調用過join ),thread對象仍可能存在,若再次調用join ,將導致錯誤。例如:

#include <iostream>
#include <thread>

int main()
{
    size_t i = 0;
    size_t j = 2;
    std::thread thread1([&]() {
        for (; i < j; i++)
        {
            std::cout << "I am a thread" << std::endl;
        }
    });
    thread1.join();
    thread1.join();  // 錯誤:重複等待
    return 0;
}

為避免重複等待,線程在成功被等待後,不僅狀態應更新為joinedthread_id 也應被重置為默認值(如0),表示該thread對象不再關聯任何有效線程:

void thread::join() {
    if (!joinable() || thread_id_ == 0) {
        throw std::runtime_error("Thread is not joinable");
    }
    
    int result = pthread_join(thread_id_, nullptr);
    if (result != 0) {
        throw std::runtime_error("pthread_join failed");
    }
    
    state_ = ThreadState::JOINED;
    thread_id_ = 0;    // 標記為無效
}

類似地,detach 方法的實現也需先檢查狀態與thread_id 的有效性。若線程為Joinable 狀態且thread_id 有效,則調用pthread_detach 並更新狀態為Detached ,否則拋出異常:

void thread::detach() {
    if (!joinable() || thread_id_ == 0) {
        throw std::runtime_error("Thread is not detachable");
    }
    
    int result = pthread_detach(thread_id_);
    if (result != 0) {
        throw std::runtime_error("pthread_detach failed");
    }
    
    state_ = ThreadState::DETACHED;
    // 注意: detach後thread_id仍然有效, 但不能join
}

值得注意的是,thread類一般會禁用左值版本的拷貝構造函數和拷貝賦值運算符。這主要有兩個目的:一是防止同一線程被多次等待,二是避免多線程併發訪問導致的數據競爭問題。

然而,右值版本的移動構造函數和移動賦值運算符通常被允許。由於右值生命週期短暫,移動語義通過淺拷貝實現資源所有權的轉移。例如,可以通過臨時thread對象(右值)初始化另一個thread對象,此時調用移動構造函數,複製內部成員(如thread_id )並將原對象的thread_id 置為無效:

thread(thread&& other) noexcept 
    : thread_id(other.thread_id)  // 淺拷貝線程ID
{
    other.thread_id = 0;  // 將源對象置為"空線程"狀態
}

thread& operator=(thread&& other) noexcept {
    if (this != &other) {
        // 先檢查當前對象是否有關聯線程
        if (joinable()) {
            std::terminate();  // 或執行其他清理邏輯
        }
        
        // 轉移資源所有權
        thread_id = other.thread_id;
        other.thread_id = 0;
    }
    return *this;
}

在移動賦值過程中,若當前對象已關聯線程,通常需要先終止該線程,再接管右值對象的線程資源。

接着需要介紹的是thread 類的析構函數。thread 類的析構函數會檢查當前對象所關聯的線程是否已被正確管理,即通過判斷其內部狀態是否為可連接(Joinable)。如果線程處於可連接狀態,則析構函數會調用std::terminate
std::terminate 不僅會終止當前線程,還會導致整個進程終止,因此必須確保在thread 對象析構之前,其所關聯的線程已被正確等待(joined)或分離(detached)。

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

瞭解瞭如何創建和管理線程後,我們知道在 Linux 平台下,每個線程都有唯一的一個標識符用於區分。通過打印線程標識符,我們可以識別不同的線程。前文提到,std::thread 類內部維護了一個原生的線程標識符,即pthread_t 類型的成員變量。同時,thread 類提供了native_handle() 方法,用於返回該類所維護的原生線程 ID。

進一步觀察thread 類的設計,可以發現除了原生線程 ID 之外,其內部還維護了一個id 類的成員變量。這兩個成員變量的含義相同,均表示線程的標識符。有讀者可能會產生疑問:既然thread 類已持有一個pthread_t 類型的原生線程 ID,為何還要額外維護一個 id 類對象?這樣的設計是否會顯得冗餘?

深入剖析C++11線程庫std::thread,邁入多線程編程的大門_#數據結構_05

下面我們來分析 id 類。 thread 類內部定義了一個嵌套類——id 類,其成員變量正是原生線程 ID,即 pthread_t 類型變量。不過,我們關注的重點並不在於 id 類的成員變量,而在於其成員函數。可以發現,id 類提供了大量用於線程比較的運算符重載函數。前文已強調,C++ 是一門跨平台語言,需支持在 Windows、Linux 等不同平台上正確運行。

class thread
{
    private:
    Id _id;
    pthread_t native_handle_
    public:
   class Id {
    private:
        pthread_t native_id;
        
    public:
        Id() noexcept : native_id(0) {}
        explicit Id(pthread_t id) noexcept : native_id(id) {}
        
        bool operator==(const Id& other) const noexcept {
            return pthread_equal(native_id, other.native_id) != 0;
        }
        
        bool operator!=(const Id& other) const noexcept {
            return !(*this == other);
        }
        
        bool operator<(const Id& other) const noexcept {
            return native_id < other.native_id;
        }
        
        std::string to_string() const {
            std::ostringstream oss;
            oss << native_id;
            return oss.str();
        }
        
        //.....
     };
    //.........
};

在不同平台下, thread 類內部維護的原生線程 ID 類型可能存在差異。C++ 引入 id 類的目的,正是為了屏蔽底層類型的差異,以統一的視角處理線程 ID。通過 id 類,可以抽象表示各平台下的線程 ID。在需要比較線程 ID 時,我們不應直接使用原生線程 ID 進行比較,而應調用 id 類所提供的運算符重載函數。

thread 類提供了 get_id() 方法,其返回值即為內部維護的 id 類類型變量。通過該方法,我們可以獲取線程 ID,並進一步調用相應的運算符重載函數完成各種比較操作。

此外,id 類還重載了流插入運算符( operator<< ),從而能夠正確輸出 ID 的值。

#include<iostream>
#include<thread>

int main()
{
    std::thread thread1([]() {
        int a = 1;
        int b = 2;
        a + b;
    });
    std::thread thread2([]() {
        int a = 1;
        int b = 2;
        a + b;
    });
    if (thread1.get_id() > thread2.get_id())
    {
        std::cout << "thread 1 id :" << thread1.get_id() << " > " << "thread 2 id :" << thread2.get_id() << std::endl;
    }
    else
    {
        std::cout << "thread 2 id :" << thread2.get_id() << " > " << "thread 1 id :" << thread1.get_id() << std::endl;
    }
    thread1.join();
    thread2.join();
    return 0;
}

深入剖析C++11線程庫std::thread,邁入多線程編程的大門_#c++_06

瞭解 id 類的作用後,我們知道可以調用 get_id() 方法獲取 thread 對象所關聯的線程 ID。不過,這種方式僅限於創建線程的上下文中調用,因為需要持有 thread 對象。如果希望在線程執行的上下文中獲取當前線程的 ID,則需要調用 this_thread 命名空間下的 get_id() 方法。

this_thread 是一個命名空間,提供了一系列與當前線程相關的函數,其中包括 get_id() 。該方法的底層實現較為簡單:調用線程庫的 pthread_self() 接口獲取 pthread_t 類型的線程 ID,再通過該原生 ID 構造一個 id 類對象並返回。

// this_thread命名空間 - 提供線程相關操作
namespace this_thread {
    
    // 獲取當前線程ID
    inline Thread::Id get_id() noexcept {
        return Thread::Id(pthread_self());
    }
    
    //....
}

除了 get_id() 方法,this_thread 命名空間中還有一個重要方法—— yield() 。我們只需理解其含義及用法,無需掌握具體實現細節。 yield() 方法的作用是讓出當前 CPU 時間片。需要注意的是,調用 yield() 後,線程並不會被移出就緒隊列進入阻塞狀態,而是主動釋放當前佔用的時間片,使 CPU 能夠切換到其他線程執行。yield() 常與自旋鎖結合使用。由於自旋鎖需要通過忙等待(busy-waiting)不斷檢查鎖狀態,在單核 CPU 環境下,若某個線程持有鎖但在執行過程中被切換,而調度到另一個正在自旋等待鎖的線程,此時會出現無效自旋:因為單核 CPU 同一時刻只能執行一個線程,自旋線程佔用 CPU 時間,卻無法使持有鎖的線程獲得執行機會以釋放鎖。這種情況下,在自旋過程中,若檢測到鎖仍未釋放,可調用 yield() 主動讓出 CPU,以便持有鎖的線程得以執行並釋放鎖。

std::mutex _mutex;

void threadfun()
{
    while(!_mutex.try_lock())
    {
        std::this_thread::yield();
    }
    //臨界區代碼
    _mutex.unlock();
}

mutex

我們知道,當多個線程併發訪問同一個共享資源且訪問操作非原子時,會出現數據不一致的問題。解決方案是確保對共享資源的訪問是互斥的,因此需要引入互斥鎖(mutex),以保證線程對臨界區代碼段的訪問是串行執行的。

在 Linux 平台下,實現互斥訪問通常通過定義 pthread_mutex_t 類型的互斥鎖變量,並調用線程庫提供的相關接口,如 pthread_mutex_lockpthread_mutex_unlock 進行加鎖與解鎖。C++ 標準庫也提供了對互斥鎖的支持。正如前文所述,C++ 是一門面向對象的語言,因此其將互斥鎖設計為類,將對 mutex 的相關操作封裝為類的成員函數。

與之前討論的 thread 類相比,C++ 中 mutex 類的實現要簡單許多。其設計思路類似於 STL 中棧和隊列所採用的容器適配器模式:mutex 類內部封裝一個 pthread_mutex_t 類型的變量,其成員函數如lock()unlock() 等直接調用底層線程庫的對應函數。構造函數中調用 pthread_mutex_init 完成初始化,析構函數中調用pthread_mutex_destroy 進行資源釋放。

class mutex {
private:
    pthread_mutex_t m_mutex;  // 底層 POSIX 互斥鎖
    
public:
    // 構造函數 - 初始化互斥鎖
    mutex() {
        pthread_mutex_init(&m_mutex, nullptr);
    }
    

// 析構函數 - 銷燬互斥鎖
~mutex() {
    pthread_mutex_destroy(&m_mutex);
}

// 加鎖
void lock() {
    pthread_mutex_lock(&m_mutex);
}

// 解鎖
void unlock() {
    pthread_mutex_unlock(&m_mutex);
}

// 嘗試加鎖(非阻塞)
bool try_lock() {
    return pthread_mutex_trylock(&m_mutex) == 0;
}

// 禁止拷貝(重要!)
mutex(const mutex&) = delete;
mutex& operator=(const mutex&) = delete;

};

需要注意的是,與thread 類類似,mutex 類也應禁止拷貝構造和賦值操作,以避免互斥鎖被複制導致重複釋放或未定義行為。

在瞭解了 mutex 類的基本用法後,當多個線程需要併發訪問同一共享資源時,我們可以定義一個 mutex 對象,並調用其 lock 函數對臨界區代碼段進行加鎖。但在 C++ 編程中,如果代碼中引入了異常處理邏輯,就需要額外的考慮。

假設我們創建一個線程,在該線程上下文中調用函數 fun ,而 fun 內部需要訪問臨界區。為了保證互斥訪問,應在 fun 函數中對臨界區加鎖。若在加鎖之後、解鎖之前,fun 函數拋出異常,則必須通過 try-catch 語句塊捕獲該異常。一旦異常拋出,程序會首先檢查當前函數作用域是否定義了匹配的 try-catch 塊;若沒有,則沿着函數調用鏈向上查找。如果異常拋出後,fun 函數或其外層函數未定義 try-catch 塊,或 catch 塊中未進行解鎖操作,將導致死鎖問題。

void fun()
{
    mutex.lock();
    // ...
    throw exception;
    //...
    mutex.unlock();
}

void threadfun()
{
    try {
        fun();
    } catch (exception& a) {
        std::cout << a.what() << std::endl;
    }
}

解決上述問題的最佳方式是採用 RAII(Resource Acquisition Is Initialization)機制。RAII 的核心思想是將資源的生命週期與對象綁定,實現自動管理。具體到鎖的管理,即加鎖與解鎖操作由對象的構造和析構自動完成,從而避免手動管理可能出現的遺漏。為此,C++ 提供了基於 RAII 思想的 lock_guard 類。

深入剖析C++11線程庫std::thread,邁入多線程編程的大門_#c++_07

lock_guard 類的設計簡潔明確:其內部維護一個 mutex 對象,僅包含構造函數和析構函數,不提供其他成員函數。構造函數中調用 mutex 的 lock 方法進行加鎖,析構函數中調用 unlock 方法進行解鎖。當函數執行結束或因異常退出時,棧幀中的局部對象會按照構造順序的逆序析構。因此,即使臨界區代碼拋出異常,
lock_guard 對象的析構函數也會被調用,確保鎖被正確釋放,從而避免死鎖。

然而,lock_guard 也存在一定的侷限性:其加鎖和解鎖的時機是固定的——加鎖發生在構造函數執行完畢時,解鎖發生在析構函數被調用時(通常是函數退出時)。但在某些場景下,我們可能需要更靈活地控制加鎖與解鎖的時機。為此,C++ 提供了unique_lock 類,同樣基於 RAII 思想,但在鎖的管理上提供了更高的可控性。

深入剖析C++11線程庫std::thread,邁入多線程編程的大門_#java_08

unique_lock 的實現相對複雜,主要體現在其構造函數支持多種加鎖策略。具體而言,unique_lock 提供以下三種典型的加鎖策略:

  1. 立即加鎖策略:在構造 unique_lock 對象時立即加鎖,通過只接受一個 mutex 參數的構造函數實現,功能與lock_guard 類似。
  2. 延遲加鎖策略:構造時不加鎖,後續通過調用 lock 方法手動加鎖。
  3. 接管已加鎖的 mutex:適用於 mutex 已被當前線程鎖定的場景,unique_lock 對象直接接管該鎖的所有權,並在析構時負責釋放。

為了區分不同的加鎖策略,unique_lock 的構造函數支持傳入第二個參數,類型為特定的標記類(空結構體),包括:

  • defer_lock_t :表示延遲加鎖;
  • adopt_lock_t :表示接管已加鎖的 mutex。

C++ 標準庫提供了這些標記類的全局常量對象(如std::defer_lockstd::adopt_lock ),用於明確指定加鎖策略。

// 標記類定義示例
struct defer_lock_t { explicit defer_lock_t() = default; };
struct adopt_lock_t { explicit adopt_lock_t() = default; };

// 全局常量標記對象
inline constexpr defer_lock_t defer_lock{};
inline constexpr adopt_lock_t adopt_lock{};

在實際使用中,可根據需求選擇適當的加鎖策略:

std::mutex mtx;

void safe_operation() {
    // 使用延遲加鎖策略
    std::unique_lock<std::mutex> lock(mtx, std::defer_lock);
    

// 執行不需要加鎖的預處理
preprocess_data();

// 手動加鎖
lock.lock();
// 臨界區代碼
lock.unlock();  // 可提前解鎖

// 執行其他操作...

}

unique_lock 內部除維護一個 mutex 對象指針外,還包含一個狀態標誌own_lock ,用於指示當前對象是否擁有鎖的所有權。其成員函數(如lockunlock )和析構函數均會檢查該狀態標誌,確保加鎖和解鎖操作的正確性。例如,lock 函數會檢查當前未加鎖且 mutex 有效時才執行加鎖,並將 owns_lock 置為true ;析構函數則在owns_locktrue 時自動釋放鎖。

class unique_lock {
private:
    mutex_type* mutex_ptr;
    bool owns_lock;  // 狀態標誌:是否擁有鎖的所有權
    
public:
    // 立即加鎖構造函數
    unique_lock(mutex_type& m) : mutex_ptr(&m), owns_lock(true) {
        mutex_ptr->lock();
    }
    

// 延遲加鎖構造函數
unique_lock(mutex_type& m, std::defer_lock_t) 
    : mutex_ptr(&m), owns_lock(false) {}

// 接管已加鎖的構造函數
unique_lock(mutex_type& m, std::adopt_lock_t) 
    : mutex_ptr(&m), owns_lock(true) {}

~unique_lock() {
    if (owns_lock && mutex_ptr) {
        mutex_ptr->unlock();
    }
}

void lock() {
    if (mutex_ptr && !owns_lock) {
        mutex_ptr->lock();
        owns_lock = true;
    }
}

void unlock() {
    if (mutex_ptr && owns_lock) {
        mutex_ptr->unlock();
        owns_lock = false;
    }
}

};

通過上述機制,unique_lock 在保持 RAII 安全性的同時,提供了更靈活的鎖管理方式,適用於複雜的同步需求。

在介紹完C++中最基本的互斥鎖以及基於RAII思想的lock_guardunique_lock 之後,接下來我們將探討C++標準庫提供的其他類型的鎖。

深入剖析C++11線程庫std::thread,邁入多線程編程的大門_#linux_09

首先要介紹的是遞歸鎖(recursive mutex)。從名稱可以推測,這種鎖適用於遞歸調用的場景。假設某個線程執行的上下文是一個遞歸函數,且該函數需要訪問臨界區代碼段,那麼就需要進行加鎖。但如果臨界區內的代碼又會遞歸調用該函數,則在遞歸進入下一層時,會再次嘗試獲取鎖。然而此時鎖已被上一層的調用持有,導致本次加鎖失敗,線程陷入阻塞,最終形成死鎖。

std::mutex _mutex;
void fun(size_t n) {
    if (n == 0) {
        return;
    }
    _mutex.lock();
    // ... 臨界區代碼
    fun(n - 1);  // 遞歸調用,再次嘗試獲取已持有的鎖,導致死鎖
    // ...
    _mutex.unlock();
}

為了解決這個問題,可以使用遞歸鎖。遞歸鎖在使用接口上與普通互斥鎖基本一致,都提供了lockunlock 等方法。其原理在於內部維護了當前持有鎖的線程標識、加鎖次數(引用計數)以及一個條件變量。每次加鎖時,如果當前線程與鎖的持有線程相同,則增加引用計數並立即返回;否則線程將阻塞,直到引用計數降為零後被喚醒。解鎖時則減少引用計數,當計數歸零時釋放鎖,並喚醒等待中的線程。

// 偽代碼實現
class recursive_mutex {
private:
    std::thread::id owner_thread;  // 當前持有鎖的線程ID
    int recursion_count = 0;       // 遞歸計數
    std::mutex internal_mutex;     // 內部互斥鎖
    std::condition_variable cv;    // 條件變量,用於線程等待

public:
    void lock() {
        auto this_thread = std::this_thread::get_id();
        std::unique_lock<std::mutex> lock(internal_mutex);
        

if (recursion_count > 0 && owner_thread == this_thread) {
    // 同一線程重複加鎖,增加計數
    recursion_count++;
} else {
    // 等待其他線程釋放鎖
    while (recursion_count > 0) {
        cv.wait(lock);
    }
    owner_thread = this_thread;
    recursion_count = 1;
}

}

void unlock() {
    std::unique_lock<std::mutex> lock(internal_mutex);
    if (--recursion_count == 0) {
        owner_thread = std::thread::id(); // 清空持有線程
        cv.notify_one(); // 喚醒一個等待線程
    }
}

};

除了遞歸鎖,C++還提供了定時鎖(timed mutex)。與普通互斥鎖不同,當一個線程競爭互斥鎖失敗時,它會被移出就緒隊列並進入阻塞狀態。而定時鎖在競爭失敗時不會立即阻塞,而是會在指定時間內重複嘗試獲取鎖。與自旋鎖(spinlock)不同的是,自旋鎖會持續佔用CPU進行忙等待,而定時鎖允許設置一個超時時間。若在超時時間內未成功獲取鎖,線程將主動放棄,避免長時間空轉。

深入剖析C++11線程庫std::thread,邁入多線程編程的大門_#數據結構_10

定時鎖通過try_lock_fortry_lock_until 兩個成員函數實現超時機制。這兩個函數需傳入一個std::chrono::duration 對象(表示時間間隔)或std::chrono::time_point 對象(表示時間點)。
std::chrono::duration 是一個模板類,可用於構造不同精度的時間間隔:

#include <chrono>

int main() {
    using namespace std::chrono;
  

milliseconds ms(500);     // 500毫秒
seconds sec(2);           // 2秒
microseconds us(100000);  // 100毫秒(即100,000微秒)

auto total_time = ms + sec;  // 可進行算術運算,結果為2.5秒
return 0;

}

構造時間間隔對象後,可將其傳入try_lock_for() ,指定線程嘗試獲取鎖的最大時長。若在期間內成功獲取鎖,函數返回true ,否則返回false

#include <mutex>
#include <chrono>
#include <thread>

std::timed_mutex tmtx;

void worker(int id) {
    using namespace std::chrono;
    milliseconds timeout(50);
    

if (tmtx.try_lock_for(timeout)) {
    std::cout << "線程 " << id << " 獲取鎖成功" << std::endl;
    // 執行臨界區代碼
    tmtx.unlock();
} else {
    std::cout << "線程 " << id << " 獲取鎖超時" << std::endl;
}

}

另一種方式是使用try_lock_until ,它接受一個時間點參數,表示獲取鎖的截止時刻。時間點可通過當前時間加上時間間隔得到。C++的<chrono> 庫提供了三種時鐘類型:

  • system_clock :系統時鐘,可能隨用户調整而變化,反映實際時間。
  • steady_clock :穩定時鐘,保證單調遞增,不受系統時間調整影響。
  • high_resolution_clock :高精度時鐘,提供最小 tick 間隔。
#include <chrono>
#include <mutex>

void example() {
    using namespace std::chrono;
    std::timed_mutex tmtx;
    

// 使用穩定時鐘,當前時間 + 500毫秒
auto steady_timeout = steady_clock::now() + milliseconds(500);

if (tmtx.try_lock_until(steady_timeout)) {
    // 臨界區代碼
    tmtx.unlock();
}

}

總體而言,定時鎖在互斥鎖的阻塞機制和自旋鎖的忙等待之間提供了一種折衷方案。它通過超時機制有效降低了死鎖風險,適用於對響應時間有要求的併發場景。

condition_variable

接下來是關於C++條件變量實現的説明。我們知道C++標準庫將條件變量封裝為condition_variable類,該類內部封裝了一個原生條件變量(如pthread_cond_t )。其構造函數通過調用pthread_cond_init 完成初始化,析構函數則通過pthread_cond_destroy 進行資源釋放。

condition_variable 類的核心成員函數包括notify_onenotify_all。其中,notify_one 用於喚醒在該條件變量等待隊列中的一個線程,而notify_all則喚醒所有等待線程。它們的底層實現分別對應於
pthread_cond_signalpthread_cond_broadcast

wait 函數用於在條件不滿足或資源未就緒時,使當前線程釋放鎖並進入阻塞狀態,同時將其加入條件變量的等待隊列。其底層通過調用pthread_cond_wait 實現。

class condition_variable {
private:
    pthread_cond_t _M_cond;  // 封裝的原生條件變量
    
public:
    // 構造函數
    condition_variable() {
        int __e = pthread_cond_init(&_M_cond, nullptr);
        if (__e) {
            // 錯誤處理邏輯
        }
    }
	
condition_variable (const condition_variable&) = delete;
    
void wait(std::unique_lock<std::mutex>& lock) {
    pthread_cond_wait(&_M_cond, lock.mutex()->native_handle());
}

void notify_one() noexcept {
    pthread_cond_signal(&_M_cond);
}

void notify_all() noexcept {
    pthread_cond_broadcast(&_M_cond);
}

// 析構函數  
~condition_variable() {
    pthread_cond_destroy(&_M_cond);

}

};

需要注意的是,觀察condotion_variable::wait 函數的原型可知,它僅能接受std::unique_lock<std::mutex> 類型的鎖。若希望條件變量能夠與任意類型的鎖配合使用,應使用std::condition_variable_any 類。其wait 函數為模板函數,可適配滿足BasicLockable要求的任何鎖類型。

std::condition_variable_any cv_any;  // 通用條件變量

// 1. 與 unique_lock<mutex> 配合
std::mutex mtx1;
std::unique_lock<std::mutex> lock1(mtx1);
cv_any.wait(lock1);  // ✅ 正確

// 2. 與 unique_lock<timed_mutex> 配合  
std::timed_mutex tmtx;
std::unique_lock<std::timed_mutex> lock2(tmtx);
cv_any.wait(lock2);  // ✅ 正確

// 3. 與自定義鎖類型配合(需實現 lock()/unlock() 接口)
class MyLock {
    std::mutex mtx;
public:
    void lock() { mtx.lock(); }
    void unlock() { mtx.unlock(); }
};

MyLock my_lock;
cv_any.wait(my_lock);  // ✅ 正確

應用

上文介紹了C++中的線程、互斥鎖和條件變量的基本概念,接下來我們將應用這些知識解決一個常見的面試問題:創建兩個線程,使其交替打印10以內的奇數和偶數。

仔細分析題目,實現的關鍵在於"交替"二字。我們將創建兩個線程,一個負責打印奇數,另一個負責打印偶數。這裏我們定義一個初始值為0的全局整型變量作為共享資源,供兩個線程併發訪問。

問題的難點在於,兩個線程是獨立的執行流。一旦某個線程被調度,在其時間片內會持續執行,導致共享變量被連續遞增。但我們需要保證交替打印:例如,當打印偶數的線程發現當前值為偶數時,應打印並將值遞增為奇數,然後讓出執行權,等待另一個線程打印奇數。

因此,我們可以確定基本實現思路:

  1. 兩個線程需要互斥地訪問共享變量,因此需要一把互斥鎖
  2. 需要同步機制確保線程在條件不滿足時等待,條件滿足時繼續執行。具體來説:
  • 打印偶數的線程獲取鎖後,若發現當前值為奇數(條件不滿足),應釋放鎖並阻塞
  • 當打印奇數的線程將值遞增為偶數後,應通知等待的偶數線程
  • 奇數線程的邏輯與此對稱

需要注意的是,在值遞增後,無論另一個線程是否處於等待狀態,都應進行通知。若另一個線程尚未被調度(即未因條件不滿足而阻塞),則通知操作不會有實際影響;若其已被調度並處於等待狀態,則會被喚醒並重新競爭鎖。

基於上述分析,我們給出第一種實現方案:使用一把互斥鎖和兩個條件變量,分別對應奇數線程和偶數線程的等待條件。每個線程的執行邏輯如下:

  1. 獲取互斥鎖
  2. 檢查條件是否滿足(偶數線程檢查當前值是否為偶數,奇數線程檢查是否為奇數)
  3. 若條件不滿足,則釋放鎖並阻塞在對應的條件變量上
  4. 若條件滿足,則打印當前值並遞增
  5. 通知另一個線程(通過對應的條件變量)
  6. 釋放鎖

以下是具體實現代碼:

#include <iostream>
#include <thread>
#include <mutex>
#include <condition_variable>

std::mutex mutex;
std::condition_variable cond_even;  // 偶數線程條件變量
std::condition_variable cond_odd;   // 奇數線程條件變量
int counter = 0;                    // 共享計數器

void print_even(size_t max_num) {
    while (true) {
        std::unique_lock<std::mutex> lock(mutex);
        // 等待條件滿足:計數器未超過最大值且為偶數
        while (counter < max_num && counter % 2 != 0) {
            cond_even.wait(lock);
        }
        if (counter >= max_num) {
            cond_odd.notify_one();
            break;
        }
        std::cout << "Even thread: " << counter << std::endl;
        ++counter;
        cond_odd.notify_one();  // 通知奇數線程
    }
}

void print_odd(size_t max_num) {
    while (true) {
        std::unique_lock<std::mutex> lock(mutex);
        // 等待條件滿足:計數器未超過最大值且為奇數
        while (counter < max_num && counter % 2 != 1) {
            cond_odd.wait(lock);
        }
        if (counter >= max_num) {
            cond_even.notify_one();
            break;
        }
        std::cout << "Odd thread: " << counter << std::endl;
        ++counter;
        cond_even.notify_one();  // 通知偶數線程
    }
}

int main() {
    size_t max_num = 10;
    std::thread t1(print_even, max_num);
    std::thread t2(print_odd, max_num);
    

t1.join();
t2.join();
return 0;

}

深入剖析C++11線程庫std::thread,邁入多線程編程的大門_#數據結構_11

需要注意的是,這裏使用std::unique_lock 管理互斥鎖,其析構函數會自動釋放鎖,避免了因異常路徑導致的死鎖問題。

第二種實現方案使用一個條件變量和一個布爾狀態標誌。當標誌為true 時,允許偶數線程執行;為false 時,允許奇數線程執行。線程在打印並遞增後,需要切換標誌狀態並通知等待的線程。執行邏輯如下:

偶數線程: 加鎖 → 檢查標誌為true → 打印偶數 → 遞增 → 標誌置false → 通知 → 解鎖
奇數線程: 加鎖 → 檢查標誌為false → 打印奇數 → 遞增 → 標誌置true → 通知 → 解鎖

實現代碼如下:

#include <iostream>
#include <thread>
#include <mutex>
#include <condition_variable>

std::mutex mutex;
std::condition_variable cond;
int counter = 0;
bool is_even_turn = true;  // 標誌:當前是否應打印偶數

void print_even(size_t max_num) {
    while (true) {
        std::unique_lock<std::mutex> lock(mutex);
        // 等待條件滿足:當前應打印偶數
        while (!is_even_turn) {
            cond.wait(lock);
        }
        if (counter >= max_num) {
            is_even_turn = false;
            cond.notify_one();
            break;
        }
        std::cout << "Even thread: " << counter << std::endl;
        ++counter;
        is_even_turn = false;
        cond.notify_one();
    }
}

void print_odd(size_t max_num) {
    while (true) {
        std::unique_lock<std::mutex> lock(mutex);
        // 等待條件滿足:當前應打印奇數
        while (is_even_turn) {
            cond.wait(lock);
        }
        if (counter >= max_num) {
            is_even_turn = true;
            cond.notify_one();
            break;
        }
        std::cout << "Odd thread: " << counter << std::endl;
        ++counter;
        is_even_turn = true;
        cond.notify_one();
    }
}

int main() {
    size_t max_num = 10;
    std::thread t1(print_even, max_num);
    std::thread t2(print_odd, max_num);
    

t1.join();
t2.join();
return 0;

}

深入剖析C++11線程庫std::thread,邁入多線程編程的大門_#數據結構_11

以上兩種方案均能正確實現交替打印的需求。第一種方案邏輯直接,易於理解;第二種方案通過狀態標誌減少了條件變量的數量,代碼更為簡潔。在實際應用中,可根據具體場景選擇合適的實現方式。

atomic

我們知道,當多個線程併發訪問同一個共享資源,且訪問操作不具備原子性時,就必須通過互斥機制來保證線程安全。為了實現線程間的互斥訪問,通常需要為臨界區加互斥鎖。考慮以下併發訪問場景:

#include<iostream>
#include<thread>
#include<mutex>

std::mutex _mutex;
int count = 0;

void add1(size_t num)
{
    for(int i = 0; i < num; i++)
    {
        std::unique_lock<std::mutex> lock(_mutex);
        count++;
    }
}

void add2(size_t num)
{   
    for(int i = 0; i < num; i++)
    {
        std::unique_lock<std::mutex> lock(_mutex);
        count++;
    }
}

int main()
{
    size_t num = 10000;
    std::thread thread1(add1, num);
    std::thread thread2(add2, num);
    thread1.join();
    thread2.join();
    return 0;
}

在這個場景中,兩個線程都會對共享變量 count 進行遞增操作。為避免數據競爭,兩個線程需要競爭同一把互斥鎖,以確保對 count 的遞增是原子的。雖然代碼邏輯正確,但存在一個潛在問題:如果某個線程成功獲取鎖,而臨界區代碼執行時間很短,那麼在該線程的時間片未耗盡的情況下,它會頻繁地進行加鎖與解鎖操作。這會導致另一個線程被反覆無效喚醒,從而引起大量的上下文切換,降低系統效率。

針對這種情況,有兩種常見的優化策略。一種是在鎖持有時間較短的場景下使用自旋鎖:當鎖被釋放時,正在自旋的線程可以立即獲取鎖,避免了線程掛起和喚醒帶來的上下文切換開銷。

另一種更推薦的做法是將遞增操作原子化。C++ 提供了 atomic 類模板來實現這一目的。當多個線程併發訪問共享資源時,若操作不具備原子性,就可能引發數據不一致問題。理論上,只要將共享資源的訪問操作原子化,即可避免這類問題。

深入剖析C++11線程庫std::thread,邁入多線程編程的大門_#java_13

那麼要理解 atomic 的實現原理,首先需要補充一些計算機組成原理的相關知識。

我們知道,導致數據不一致的原因在於對共享資源的訪問操作不具備原子性。以上文提到的場景為例,多個線程對共享資源進行遞增操作,而遞增操作之所以不具原子性,是因為其在編譯後會映射為三條基本指令:從內存讀取數據到寄存器、執行算術運算、將結果寫回內存。如果多個線程併發執行該操作,假設它們都執行了前兩條指令後被切換,那麼當這些線程依次執行寫回內存的指令時,各個線程在重新被 CPU 調度時,其寄存器中保存的值可能已經過時,從而導致前一個線程的寫入結果被後一個線程覆蓋。

因此,當多個線程執行遞增操作時,該操作會存在“執行中”的狀態。也就是説,線程在執行遞增對應的基本指令時可能發生切換。而我們知道,CPU 執行單條基本指令是具備原子性的。所謂原子性,指的是指令只存在“已執行”和“未執行”兩種狀態,不存在“執行中”的狀態。CPU 在執行每一條指令期間是不可中斷的,不會響應任何硬件中斷。只有在指令執行結束或開始之前,CPU 才能響應中斷。

需要注意的是,造成數據不一致性 的核心原因並非訪問操作映射為多條基本指令本身,而是因為這些指令中包含了內存的讀和寫指令。如果內存的讀取和寫入操作不能連續完成,中間可能被中斷,就會引發數據不一致性 問題。因此,一種解決思路是:如果能夠將內存的讀和寫操作合併為一條原子指令,使其能連續完成,即可避免數據不一致。

我們知道,CPU 需要與底層硬件(如各類 I/O 設備和內存)進行交互,這就涉及數據的傳輸。CPU 與這些硬件之間通過總線相連,數據通過總線進行傳輸。

總線可分為三類:地址總線、數據總線和控制總線。地址總線用於傳輸物理地址。當 CPU 訪問內存時,需通過地址總線將目標物理地址發送給內存。

深入剖析C++11線程庫std::thread,邁入多線程編程的大門_#開發語言_14

需要注意的是,計算機底層有多個 I/O 設備,每個設備在某一時刻都可能向 CPU 發出數據傳輸請求,但它們與 CPU 之間共享同一根數據總線。這種情況類似於多線程併發訪問共享資源,不過是發生在硬件層面。由於所有 I/O 設備(包括內存)共享數據總線,同一時刻只能有一個設備通過數據總線傳輸數據。如果有多個設備同時向總線發送數據,必然會導致數據衝突。因此,每個設備在使用數據總線前必須“鎖定”總線。鎖定方式是通過控制總線向總線仲裁器發送一個控制信號。總線仲裁器會接收多個設備的控制信號,並通常 按照“先到先得”的原則分配總線的獨佔權(取決於具體的調度策略),即控制信號最先到達仲裁器的設備獲得總線使用權。

瞭解設備傳輸數據前需先鎖定總線的機制後,我們來看現代多核 CPU 的情況。每個 CPU 核心均可運行獨立的線程上下文,因此多核 CPU 能夠實現真正的併發執行。在某一時刻,可能存在多個核心同時發起內存訪問請求。由於地址總線只有一根,且為所有核心所共享,這些核心必須競爭地址總線的獨佔權。競爭方式仍是通過控制總線向仲裁器發送控制信號,最先到達的信號對應的核心將鎖定地址總線,其他核心則必須等待該核心完成地址傳輸後才能依次獲取總線使用權。

在 x86 架構下,CPU 支持一些原子操作,如遞增、遞減和位運算等。所謂原子操作,是指這些高級操作在編譯後只對應一條基本指令。由於單條指令具備原子性,因此能保證這些操作不會出現數據不一致問題。這些指令通常帶有lock 前綴,其原理是讓對內存的讀和寫操作連續執行,中途不被中斷。

部分讀者可能對“架構”這一術語感到陌生。架構可理解為 CPU 設計的藍圖,定義了 CPU 所能理解和執行的指令集合(即指令集)、寄存器結構、內存訪問方式等。目前 Intel/AMD 公司生產的 CPU 主要採用 x86 架構,其支持的指令集為 CISC(複雜指令集)。CISC 的特點是指令長度可變,且一條指令可能包含多個底層操作(如同時涉及讀內存和寫內存)。與之相對的是 RISC(精簡指令集),其指令長度固定,一條指令通常只對應一個基本操作。

帶有lock 前綴的原子指令在執行時,由於必然涉及內存訪問,CPU 會通過控制總線發送一個帶有 lock 標識的控制信號。即使該信號之前已有其他信號到達總線仲裁器,那麼一旦仲裁器響應了 lock 的信號,那麼該核心會一直鎖定總線的控制權,再此期間,仲裁器不會響應其他信號的請求,從而能夠連續完成內存的讀取和寫入。

// x86架構的原子指令示例

// 原子加法
lock add dword ptr [rax], 1
lock add dword ptr [mem], imm32    ; 原子加法
lock sub dword ptr [mem], imm32    ; 原子減法  
lock and dword ptr [mem], imm32    ; 原子與
lock or  dword ptr [mem], imm32    ; 原子或
lock xor dword ptr [mem], imm32    ; 原子異或
lock xadd dword ptr [mem], reg     ; 原子交換並加
lock cmpxchg dword ptr [mem], reg  ; 原子比較並交換
xchg reg, dword ptr [mem]          ; 原子交換(隱含lock)

但在現代 CPU 中,lock 前綴通常不再優先鎖定總線,而是優先鎖定對應的緩存行。這是因為每個 CPU 核心都有自己的一塊緩存,其訪問速度遠高於內存。根據局部性原理,CPU 訪問內存時不僅會加載目標數據,還會將其相鄰數據(通常為一個緩存行的大小,一般為 64 字節)加載到緩存中。在訪問內存前,CPU 會先檢查緩存是否已有所需數據;如果未命中,才會按前述流程競爭總線。

由於每個核心均有緩存,大多數情況下 CPU 會直接訪問緩存而非內存,這就引入了內存可見性問題。在多線程併發訪問共享資源時,該資源很可能已被加載到多個核心的緩存中。此時,若某個線程競爭到鎖並修改了共享資源(包括更新緩存並將結果寫回內存),而其他核心的緩存中仍是舊值,那麼當其他線程隨後訪問該資源時,由於緩存未更新,仍會讀取到過期數據,從而導致數據不一致。

因此,現代 CPU 會實現一套緩存一致性協議,即 MESI 協議。所謂緩存一致性協議,是指每個緩存行(Cache Line)都會對應一個狀態標記。在 MESI 協議中,狀態主要分為四種: E (獨佔, Exclusive )、 M (修改, Modified )、 I (無效, Invalid )和 S (共享, Shared )。

獨佔( E )狀態表示當前緩存行中的數據與主內存中的數據一致,並且其他核心沒有該數據的副本,僅當前核心持有該緩存行。無效( I )狀態表示該緩存行已過時(例如因其他核心修改了對應數據),或者該緩存行尚未加載有效數據。共享( S )狀態表示多個核心共同持有同一緩存行的副本,且數據一致。修改( M)狀態則表示該緩存行已被當前核心修改,與內存中數據不一致,且只有當前核心持有該緩存行,其他核心均無副本。

在執行帶有 lock 前綴的指令進行內存寫操作時,會觸發緩存一致性協議的執行。其基本原理是:首先檢查當前核心中目標緩存行的狀態。若狀態為 EM ,説明僅有當前核心持有該緩存行,因此可直接修改緩存中的數據,並將狀態置為 M (若原狀態為 E )。若當前緩存行狀態為 S ,説明多個核心均持有最新數據副本,此時需向其他核心發出信號,使其將對應緩存行狀態無效化(即從 S 改為 I ),之後當前核心再修改自身緩存行數據,並將狀態改為 M 。若緩存行狀態為 I ,表明數據已失效或未加載,此時不區分具體情形,處理流程一致:先檢查其他核心是否持有有效緩存行(即狀態為 EM )。若有,則直接從該核心緩存行讀取數據,無需訪問內存;若其他核心中存在狀態為 S 的緩存行,則讀取其中任意一個副本到本地,接着使其他所有 S 狀態的緩存行無效化(改為 I ),再修改數據並將當前狀態設為 M ;若所有核心中該緩存行狀態均為 I ,則需通過總線鎖定機制,訪問內存加載數據至緩存行,並將其狀態設為 E

同樣, lock指令進行內存讀操作也會觸發緩存一致性協議。具體而言,若當前核心緩存行狀態為 EMS ,則可直接從本地緩存讀取。若狀態為 I ,則先查詢其他核心:如有核心緩存行狀態為 E,則讀取其數據,並將雙方狀態均改為 S ;如有核心狀態為 M ,則先將該髒數據寫回內存,再將所有相關緩存行狀態設為 S ;如有核心狀態為 S ,則直接讀取其數據。若所有核心中該緩存行均為 I ,則需鎖定總線、訪問內存,將數據加載至緩存行後設為 E 狀態。

掌握了緩存一致性協議之後,我們便可以進一步理解 atomic的底層實現原理。我們知道,CPU 硬件層面提供了一些原子指令,支持諸如遞增、遞減、算術運算和邏輯運算等操作。atomic 是一個模板類,它通過模板特化的方式為多種內置類型(如intbool 等)提供了對應的原子版本。在這些特化類中,遞增和遞減等運算符被重載,其底層實現通常使用 lock前綴的指令,確保內存的讀寫操作連續且不可分割,並藉助緩存一致性協議和總線鎖定機制來保證多核環境下的內存可見性,從而避免數據不一致的問題。

深入剖析C++11線程庫std::thread,邁入多線程編程的大門_#linux_15

如果直接對普通內置類型(如int )進行遞增或算術運算,編譯後通常會對應多條機器指令,這些指令在執行過程中可能被中斷,無法保證原子性。而使用atomic 類型進行相同操作時,實際上調用的是對應特化類中重載的運算符(如 operator++)或成員函數(如 fetch_add)。這些函數內部通過lock 類指令將操作映射為 CPU 支持的原子指令。

需要注意的是,原子指令的支持程度與操作對象的類型有關。對於內置類型,通常可以映射到單條原子指令;但對於自定義類型(如包含年、月、日三個字段的Date 類),由於涉及多個內存區域的修改,CPU 無法通過一條指令實現原子操作。在這種情況下,atomic類會採用備用方案——使用互斥鎖來模擬原子行為。我們可以通過is_lock_free() 函數判斷當前原子對象是否真正無鎖。因此,對於自定義類型的“原子”操作,本質上是一種基於鎖的原子性模擬。

那麼,在實際開發中應如何選擇atomic 還是互斥鎖呢?這取決於共享資源的類型和操作方式。如果資源是內置類型,且操作(如遞增、算術運算)有對應的原子指令支持,則優先使用atomic 類;如果資源是自定義類型,或操作較為複雜(特別是需要同步機制),則可能仍需使用互斥鎖。簡言之,atomic 適用於可映射到硬件原子指令的簡單場景,而複雜或非內置類型的同步仍需依賴鎖機制。

瞭解了atomic的原理後,我們可以將其應用於上文提到的場景。假設此處我們不使用任何互斥機制,則可以明顯觀察到數據不一致的問題:

#include<iostream>
#include<thread>
#include<atomic>
#include<mutex>
int var = 0;
void print1(size_t num)
{
	int i = 0;
	while (i<num)
	{
		var++;
		i++;
	}
}
void print2(size_t num)
{
	int i = 0;
	while (i<num)
	{
		

var++;
i++;

}

}
int main()
{
	int num = 100000;
	std::thread thread1(print1, num);
	std::thread thread2(print2, num);

thread1.join();
thread2.join();
std::cout << "num :" << num << std::endl;
return 0;

}

深入剖析C++11線程庫std::thread,邁入多線程編程的大門_#java_16

由於本例中的共享資源為內置類型,且僅進行簡單的遞增操作,不涉及複雜的同步機制,因此可以將遞增操作原子化,無需使用互斥鎖:

#include<iostream>
#include<thread>
#include<atomic>
#include<mutex>
std::atomic<int> var = 0;
void print1(size_t num)
{
	int i = 0;
	while (i<num)
	{
		var++;
		i++;
	}
}
void print2(size_t num)
{
	int i = 0;
	while (i<num)
	{	
     var++;
     i++;
     }

}
int main()
{
	int num = 100000;
	std::thread thread1(print1, num);
	std::thread thread2(print2, num);
    thread1.join();
    thread2.join();
    std::cout << "var :" << var << std::endl;
     return 0;
}

深入剖析C++11線程庫std::thread,邁入多線程編程的大門_#c++_17

在此場景下,使用原子操作能更有效地保證數據一致性。需要補充説明的是,atomic類提供了 loadstore 成員函數。其中, load 用於讀取並返回當前存儲的值, store則用於原子性地更新存儲值。

實際上,store 函數和load 函數在底層實現上直接對應一條原子指令,甚至無需使用lock 前綴。這是因為像簡單的賦值和存儲操作,在硬件層面通常只需一條mov 指令即可完成,並且這類操作本身在特定條件下是天然原子的。那麼,既然基礎的讀取和寫入操作已具備原子性,為何還需要專門的 storeload 函數呢?這就涉及到指令重排(Instruction Reordering)的問題。

可能部分讀者對指令重排(Instruction Reordering)概念尚不熟悉。簡單來説,指令重排是指編譯器或CPU為了優化性能,可能會打亂代碼的執行順序,但保證最終結果與順序執行一致。例如以下代碼:

int main()
{
    int a=1;
    int b=4;
    int c=a+b;
    return 0;
}

從代碼邏輯看,執行順序應為 a=1b=4c=a+b 。但實際執行時,CPU可能先執行 b=4 ,再執行 a=1 ,最後計算 c 。由於操作獨立性,重排後結果依然正確。這種優化對上層不透明,且單線程下無法感知。

然而在多線程環境下,指令重排可能導致意外後果。考慮以下場景:

bool flag=false;
int data=0;
void threadfun1()
{
    data+=10;
    flag=true;
    std::this_thread::sleep_for(std::chrono::seconds(10));
}
void threadfun2()
{
    while(!flag);
    std::cout<<"data :"<<data<<std::endl;
}

線程threadfun2 試圖打印 threadfun1 修改後的 data 值。在單線程模型中, flagdata 修改後被置為 true ,邏輯正確。但若發生指令重排, threadfun1 中可能先執行 flag=true ,再執行 data+=10 。這將導致 threadfun2data 未完成修改時便跳出循環,讀取到錯誤的數據值。

原子操作的 loadstore 函數通過引入內存屏障(Memory Barrier)防止此類重排,確保多線程環境下的語義正確性。因此,在編寫無鎖併發程序時,應優先使用原子操作而非直接操作共享變量。

而 atomic 中的 load 和 store 函數的主要作用,正是為了在必要時對指令執行順序施加約束,防止編譯器或處理器進行可能影響程序正確性的重排。因此, load 和 store 函數通常會接受一個 mem_order 參數,該參數為編譯時常量,用於指定所需的內存順序語義。在大多數情況下,我們可以直接使用其默認參數,即順序一致性( memory_order_seq_cst ),它能夠提供最嚴格的內存順序保證,從而確保指令的執行順序與代碼書寫順序一致。

namespace std {
    typedef enum memory_order {
        memory_order_relaxed,   // 寬鬆順序
        memory_order_consume,   // 消費順序
        memory_order_acquire,   // 獲取順序
        memory_order_release,   // 釋放順序
        memory_order_acq_rel,   // 獲取-釋放順序
        memory_order_seq_cst    // 順序一致性(默認)
    } memory_order;
}

最後,我們來介紹CAS(Compare-And-Swap)操作。CAS 是一種用於保證原子性的常見機制。那麼,什麼是 CAS?

CAS 可以理解為一種包含兩個步驟的原子操作:比較(Compare)和設置(Set)。正如其縮寫所示,CAS 能夠以原子方式完成比較和寫入操作,整個過程不會被中斷。我們仍以多線程併發訪問整型共享資源並執行遞增操作為例。遞增操作本身不具備原子性,會導致數據不一致問題,其根本原因在於內存的讀取和寫入操作不是連續完成的,中間可能被中斷。

第一種解決方案,如之前所述,是確保內存的讀取和寫入連續完成,即通過底層的一條原子指令(如 lock add )來實現。

第二種方案則是使用 CAS。其設計思想不同於第一種嚴格保證原子性的方式,而是允許讀取和寫入操作不連續,中間可以被調度切換。但在執行最後的寫入操作之前,會進行一次檢查:比較當前內存中的值與一個期望值(即之前從內存中讀取的值),同時提供一個新值作為寫入目標。如果內存中的當前值與期望值相等,説明從讀取到寫入的期間沒有其他線程修改該內存位置,此時將新值寫入內存並返回成功;否則,説明在此期間內存已被其他線程修改,當前寫入操作會失敗並返回錯誤,從而避免覆蓋其他線程的修改結果。

// 偽代碼表示CAS操作
bool CAS(內存地址, 期望值, 新值) {
    if (*內存地址 == 期望值) {
        *內存地址 = 新值;
        return true;  // 操作成功
    } else {
        return false; // 操作失敗
    }
}
// 整個CAS操作是原子的,不會被中斷

需要注意的是,現代處理器通常提供底層的 CAS 硬件指令。在 C++ 中, std::atomic 類提供了compare_exchange_weak 成員函數,該函數接收期望值和新值作為參數。由於是成員函數,它會隱式傳遞
this 指針,因此當前對象的內存地址將作為寫入目標。

compare_exchange_weak 函數底層直接調用硬件 CAS 指令,並通過檢查處理器標誌位來判斷操作是否成功。然而,如果要修改的對象大小超過機器字長(例如自定義類型),則無法直接通過單條指令實現原子操作。此時,compare_exchange_weak 可能會通過加鎖方式來模擬 CAS 行為,以保證操作的原子性。

CAS 的典型應用之一是自旋鎖(spinlock)的實現。自旋鎖的基本原理是不斷檢查鎖的狀態,而鎖狀態本質上是一個整型變量。通常,當該變量的值為 0 時表示鎖處於未鎖定狀態,值為 1 時表示鎖已被佔用。在使用 CAS 操作時,我們期望的舊值(expected value)為 0,表示當前鎖未被持有;而目標內存地址即為鎖狀態變量所在的內存地址。CAS 會檢查該內存中的值是否為 0:如果不是,則返回 false,此時線程將繼續自旋等待;若為 0,則成功將新值 1 寫入內存,表示當前線程已獲得鎖。

除了自旋鎖,CAS 還可用於實現無鎖(lock-free)編程。在多線程併發訪問共享資源時,若該訪問操作不具備原子性,常見的同步策略包括加鎖機制,另一種思路則是嘗試無鎖編程。以單鏈表的插入操作為例:假設存在一個由頭指針和尾指針維護的單鏈表,其中尾指針始終指向鏈表的最後一個節點。現有一個 insert 函數用於在鏈表尾部插入新節點,其基本步驟包括:創建新節點,通過尾指針定位當前尾節點,將尾節點的 next 指針指向新節點,最後更新尾指針指向新節點。顯然,這一系列操作並非原子操作,因此多線程併發調用 insert 函數時需進行同步。傳統方法是使用鎖,而另一種思路則是基於 CAS 實現無鎖插入。

在創建新節點、修改尾節點的 next 指針以及更新尾指針的過程中,可能會發生線程切換,導致數據競爭。為此,我們需要引入第一次 CAS 檢查:以尾節點的 next 指針作為操作對象,期望其值為 nullptr (即當前尾節點無後繼),目標值為新節點的地址。若當前尾節點的 nextnullptr ,則 CAS 操作成功,將新節點鏈接至鏈表尾部;否則,表示其他線程已修改了尾節點,本次操作失敗。成功鏈接新節點後,還需執行第二次 CAS 操作以更新尾指針,確保其指向新的尾節點。

以下為無鎖鏈表插入的示例代碼:

struct Node {
    int data;
    std::atomic<Node*> next;  // 原子指針
    

Node(int val) : data(val), next(nullptr) {}

};

class LockFreeLinkedList {
private:
    std::atomic<Node*> head{nullptr};
    std::atomic<Node*> tail{nullptr};
    
public:
    void push_back(int value);
};

void LockFreeLinkedList::push_back(int value) {
    Node* new_node = new Node(value);
    while (true) {
        Node* old_tail = tail.load();  // 讀取當前尾節點
        

// 第一次CAS檢查:確認尾節點的next指針是否為nullptr
if (old_tail->next.compare_exchange_weak(
    nullptr,           // 期望值:next應為nullptr(表示當前為尾節點)
    new_node           // 新值:將其設置為新節點
)) {
    // CAS操作成功,已將新節點鏈接至鏈表尾部
    // 第二次CAS檢查:更新尾指針指向新節點
    tail.compare_exchange_weak(old_tail, new_node);
    break;
} else {
    // CAS操作失敗,説明其他線程已修改尾節點的next指針
    // 協助完成尾指針更新,並重試當前操作
    tail.compare_exchange_weak(old_tail, old_tail->next.load());
}

}

}

智能指針的線程安全

智能指針基於RAII(Resource Acquisition Is Initialization)思想,將關聯資源的生命週期委託給對象管理。即構造函數接管資源,析構函數釋放資源,對於shared_ptr ,其允許多個智能指針對象共享同一資源。為確保析構函數正確釋放資源,shared_ptr 會維護一個引用計數,確保資源僅由最後一個關聯該資源的智能指針對象釋放(即引用計數降為0時)。當 shared_ptr 需要解除當前關聯的對象並綁定到新對象時(即調用賦值運算符重載函數),首先會對引用計數執行遞減操作。若此時引用計數為0,則表明當前對象是最後一個關聯該資源的對象,需釋放資源。

然而,在多線程環境下,若多個線程併發訪問同一 shared_ptr 對象(例如執行賦值操作),其引用計數的遞減操作並非原子性,可能引發數據競爭問題。由於引用計數通常為內置整數類型,且對其訪問的操作(如遞減)是簡單的非同步操作,缺乏線程安全保證。為解決此問題,可將引用計數實現為堆上分配的原子對象,從而確保其操作的原子性與線程安全。

// 不安全的操作!!
std::shared_ptr<MyObject> global_ptr = std::make_shared<MyObject>();

void thread_func() {
    // 多個線程同時讀寫同一個 global_ptr 對象
    global_ptr = std::make_shared<MyObject>(); // 賦值操作非原子,會導致數據競爭!
}

int main() {
    std::thread t1(thread_func);
    std::thread t2(thread_func); // 未定義行為!

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

}
// 示例:自定義線程安全智能指針的簡化設計
class ThreadSafeSharedPtr {
public:
    // 構造函數、析構函數及其他接口需實現原子操作
    // ...
private:
    T* _ptr;
    std::atomic<int>* count; // 使用原子類型包裝引用計數
    // ...
};

需注意的是,C++標準庫實現的 std::shared_ptr 已通過原子操作保證引用計數的修改是線程安全的,但其指向的對象本身的併發訪問仍需用户自行管理同步。