史上最全的C++面試寶典(九)—— 多執行緒

2020-08-11 22:19:56

參考:https://www.runoob.com/cplusplus/cpp-tutorial.html

本教學旨在提取最精煉、實用的C++面試知識點,供讀者快速學習及本人查閱複習所用。

目錄

第九章  多執行緒

9.1  基本概念

9.2  C++執行緒管理

9.3  執行緒的同步與互斥

9.4  C++中的幾種鎖

9.5  C++中的原子操作

9.6  相關面試題


第九章  多執行緒

多執行緒是多工處理的一種特殊形式,一般情況下,有基於進程和基於執行緒的兩種型別的多工處理方式。

  • 基於進程的多工處理是程式的併發執行。
  • 基於執行緒的多工處理是同一程式的片段的併發執行。

9.1  基本概念

9.1.1  進程與執行緒

進程是資源分配和排程的一個獨立單位;而執行緒是進程的一個實體,是CPU排程和分配的基本單位。

同一個進程中的多個執行緒的記憶體資源是共用的,各執行緒都可以改變進程中的變數。因此在執行多執行緒運算的時候要注意執行順序。

9.1.2  並行與併發

並行(parallellism)指的是多個任務在同一時刻同時在執行。

併發(concurrency)是指在一個時間段內,多個任務交替進行。雖然看起來像在同時執行,但其實是交替的。

9.2  C++執行緒管理

  • C++11的標準庫中提供了多執行緒庫,使用時需要#include <thread>標頭檔案,該標頭檔案主要包含了對執行緒的管理類std::thread以及其他管理執行緒相關的類。
  • 每個應用程式至少有一個進程,而每個進程至少有一個主執行緒,除了主執行緒外,在一個進程中還可以建立多個子執行緒。每個執行緒都需要一個入口函數,入口函數返回退出,該執行緒也會退出,主執行緒就是以main函數作爲入口函數的執行緒。

9.2.1  啓動執行緒

std::thread的建構函式需要的是可呼叫(callable)型別,除了函數外,還可以呼叫例如:lambda表達式、過載了()運算子的類的範例。

#include <iostream>
#include <thread>

using namespace std;

void output(int i)
{
    cout << i << endl;
}

int main()
{
    for (uint8_t i = 0; i < 4; i++)
    {
        //建立一個執行緒t,第一個參數爲呼叫的函數,第二個參數爲傳遞的參數
        thread t(output, i);
        //表示允許該執行緒在後台執行
        t.detach(); 
    }
    
    return 0;
}

在多執行緒並行的條件下,其輸出結果不一定是順序呢的輸出1234,可能如下:

多執行緒並行
 

注意:

  • 把函數物件傳入std::thread時,應傳入函數名稱(命名變數,如:output)而不加括號(臨時變數,如:output())。
  • 當啓動一個執行緒後,一定要在該執行緒thread銷燬前,呼叫t.join()或者t.detach(),確定以何種方式等待執行緒執行結束:
    • detach方式,啓動的執行緒自主在後台執行,當前的程式碼繼續往下執行,不等待新執行緒結束。
    • join方式,等待關聯的執行緒完成,纔會繼續執行join()後的程式碼。
    • 在以detach的方式執行執行緒時,要將執行緒存取的區域性數據複製到執行緒的空間(使用按值傳遞),一定要確保執行緒沒有使用區域性變數的參照或者指針,除非你能肯定該執行緒會在區域性作用域結束前執行結束。

9.2.2  向執行緒傳遞參數

向執行緒呼叫的函數只需要在構造thread的範例時,依次傳入即可。

thread t(output, arg1, arg2, arg3, ...);

9.2.3  呼叫類成員函數

class foo
{
public:
    void bar1(int n)
    {
        cout<<"n = "<<n<<endl;
    }
    static void bar2(int n)
    {
        cout<<"static function is running"<<endl;
        cout<<"n = "<<n<<endl;
    }
};

int main()
{
    foo f;
    thread t1(&foo::bar1, &f, 5); //注意在呼叫非靜態類成員函數時,需要加上範例變數。
    t1.join();
    
    thread t2(&foo::bar2, 4);
    t2.join();
}

9.2.4  轉移執行緒的所有權

thread是可移動的(movable)的,但不可複製的(copyable)。可以通過move來改變執行緒的所有權,靈活的決定執行緒在什麼時候join或者detach。

thread t1(f1);
thread t3(move(t1));

將執行緒從t1轉移給t3,這時候t1就不再擁有執行緒的所有權,呼叫t1.join或t1.detach會出現異常,要使用t3來管理執行緒。這也就意味着thread可以作爲函數的返回型別,或者作爲參數傳遞給函數,能夠更爲方便的管理執行緒。

9.2.5  執行緒標識的獲取

執行緒的標識型別爲std::thread::id,有兩種方式獲得到執行緒的id:

  1. 通過thread的範例呼叫get_id()直接獲取;
  2. 在當前執行緒上呼叫this_thread::get_id()獲取。

9.2.6  執行緒暫停

如果讓執行緒從外部暫停會引發很多併發問題,這也是爲什麼std::thread沒有直接提供pause函數的原因。如果執行緒在執行過程中,確實需要停頓,就可以用this_thread::sleep_for。

void threadCaller()
{
    this_thread::sleep_for(chrono::seconds(3)); //此處執行緒停頓3秒。
    cout<<"thread pause for 3 seconds"<<endl;
}

int main()
{
    thread t(threadCaller);
    t.join();
}

9.2.7  異常情況下等待執行緒完成

爲了避免主執行緒出現異常時將子執行緒終結,就要保證子執行緒在函數退出前完成,即在函數退出前呼叫join()。

方法一:異常捕獲

void func() {
    thread t([]{
        cout << "hello C++ 11" << endl;
    });

    try
    {
        do_something_else();
    }
    catch (...)
    {
        t.join();
        throw;
    }
    t.join();
}

方法二:資源獲取即初始化(RAII)

class thread_guard
{
    private:
        thread &t;
    public:
        /*加入explicit防止隱式轉換,explicit僅可加在帶一個參數的構造方法上,如:Demo test; test = 12.2;
        這樣的呼叫就相當於把12.2隱式轉換爲Demo型別,加入explicit就禁止了這種轉換。*/
        explicit thread_guard(thread& _t) {
            t = _t;
        }

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

        thread_guard(const thread_guard&) = delete;  //刪除預設拷貝建構函式
        thread_guard& operator=(const thread_guard&) = delete;  //刪除預設賦值運算子
};

void func(){

    thread t([]{
        cout << "Hello thread" <<endl ;
    });

    thread_guard guard(t);
}

無論是何種情況,當函數退出時,物件guard呼叫其解構函式銷燬,從而能夠保證join一定會被呼叫。

9.3  執行緒的同步與互斥

執行緒之間通訊的兩個基本問題是互斥和同步:

  • 執行緒同步是指執行緒之間所具有的一種制約關係,一個執行緒的執行依賴另一個執行緒的訊息,當它沒有得到另一個執行緒的訊息時應等待,直到訊息到達時才被喚醒。
  • 執行緒互斥是指對於共用的操作系統資源,在各執行緒存取時的排它性。當有若幹個執行緒都要使用某一共用資源時,任何時刻最多隻允許一個執行緒去使用,其它要使用該資源的執行緒必須等待,直到佔用資源者釋放該資源。

執行緒互斥是一種特殊的執行緒同步。實際上,同步和互斥對應着執行緒間通訊發生的兩種情況:

  • 當一個執行緒需要將某個任務已經完成的情況通知另外一個或多個執行緒時;
  • 當有多個執行緒存取共用資源而不使資源被破壞時。

在WIN32中,同步機制 機製主要有以下幾種:

  1. 臨界區(Critical Section):通過對多執行緒的序列化來存取公共資源或一段程式碼,速度快,適合控制數據存取。  
  2. 事件(Event):用來通知執行緒有一些事件已發生,從而啓動後繼任務的開始。
  3. 號志(Semaphore):爲控制一個具備有限數量使用者資源而設計。  
  4. 互斥量(Mutex):爲協調一起對一個共用資源的單獨存取而設計的。   

9.3.1  臨界區

臨界區(Critical Section)是一段獨佔對某些共用資源存取的程式碼,在任意時刻只允許一個執行緒對共用資源進行存取。如果有多個執行緒試圖同時存取臨界區,那麼在有一個執行緒進入後其他所有試圖存取此臨界區的執行緒將被掛起,並一直持續到進入臨界區的執行緒離開。臨界區在被釋放後,其他執行緒可以繼續搶佔,並以此達到用原子方式操作共用資源的目的。

臨界區在使用時以CRITICAL_SECTION結構物件保護共用資源,並分別用EnterCriticalSection()和LeaveCriticalSection()函數去標識和釋放一個臨界區。所用到的CRITICAL_SECTION結構物件必須經過InitializeCriticalSection()的初始化後才能 纔能使用,而且必須確保所有執行緒中的任何試圖存取此共用資源的程式碼都處在此臨界區的保護之下。否則臨界區將不會起到應有的作用,共用資源依然有被破壞的可能。

#include "stdafx.h"
#include<windows.h>
#include<iostream>
using namespace std;
 
int number = 1; //定義全域性變數
CRITICAL_SECTION Critical;      //定義臨界區控制代碼
 
unsigned long __stdcall ThreadProc1(void* lp)
{
    while (number < 100)
    {
        EnterCriticalSection(&Critical);
        cout << "thread 1 :"<<number << endl;
        ++number;
        _sleep(100);
        LeaveCriticalSection(&Critical);
    }
 
    return 0;
}
 
unsigned long __stdcall ThreadProc2(void* lp)
{
    while (number < 100)
    {
        EnterCriticalSection(&Critical);
        cout << "thread 2 :"<<number << endl;
        ++number;
        _sleep(100);
        LeaveCriticalSection(&Critical);
    }
 
    return 0;
}
 
int main()
{
    InitializeCriticalSection(&Critical);   //初始化臨界區物件
 
    CreateThread(NULL, 0, ThreadProc1, NULL, 0, NULL);
    CreateThread(NULL, 0, ThreadProc2, NULL, 0, NULL);
 
    Sleep(10*1000);
 
    system("pause");
    return 0;
}

9.3.2  事件

事件物件能夠通過通知操作的方式來保持執行緒的同步,並且能夠實現不同進程中的執行緒同步操作。事件可以處於激發狀態(signaled or true)或未激發狀態(unsignal or false)。根據狀態變遷方式的不同,事件可分爲兩類:

  1. 手動設定:這種物件只可能用程式手動設定,在需要該事件或者事件發生時,採用SetEvent及ResetEvent來進行設定。
  2. 自動恢復:一旦事件發生並被處理後,自動恢復到沒有事件狀態,不需要再次設定。

使用」事件」機制 機製應注意以下事項:

  1. 如果跨進程存取事件,必須對事件命名,在對事件命名的時候,要注意不要與系統名稱空間中的其它全域性命名物件衝突;
  2. 事件是否要自動恢復;
  3. 事件的初始狀態設定。
#include "stdafx.h"
#include<windows.h>
#include<iostream>
using namespace std;
 
int number = 1; //定義全域性變數
HANDLE hEvent;  //定義事件控制代碼
 
unsigned long __stdcall ThreadProc1(void* lp)
{
    while (number < 100)
    {
        WaitForSingleObject(hEvent, INFINITE);  //等待物件爲有信號狀態
        cout << "thread 1 :"<<number << endl;
        ++number;
        _sleep(100);
        SetEvent(hEvent);
    }
 
    return 0;
}
 
unsigned long __stdcall ThreadProc2(void* lp)
{
    while (number < 100)
    {
        WaitForSingleObject(hEvent, INFINITE);  //等待物件爲有信號狀態
        cout << "thread 2 :"<<number << endl;
        ++number;
        _sleep(100);
        SetEvent(hEvent);
    }
 
    return 0;
}
 
int main()
{
    CreateThread(NULL, 0, ThreadProc1, NULL, 0, NULL);
    CreateThread(NULL, 0, ThreadProc2, NULL, 0, NULL);
    hEvent = CreateEvent(NULL, FALSE, TRUE, "event");
 
    Sleep(10*1000);
 
    system("pause");
    return 0;
}

由於event物件屬於內核物件,故進程B可以呼叫OpenEvent函數通過物件的名字獲得進程A中event物件的控制代碼,然後將這個控制代碼用於ResetEvent、SetEvent和WaitForMultipleObjects等函數中。此法可以實現一個進程的執行緒控制另一進程中執行緒的執行,例如:

HANDLE hEvent=OpenEvent(EVENT_ALL_ACCESS,true,"MyEvent");
ResetEvent(hEvent);

9.3.3  號志

號志物件對執行緒的同步方式和前面幾種方法不同,信號允許多個執行緒同時使用共用資源,但是需要限制在同一時刻存取此資源的最大執行緒數目。

用CreateSemaphore()建立號志時即要同時指出允許的最大資源計數和當前可用資源計數。一般是將當前可用資源計數設定爲最大資源計數,每增加一個執行緒對共用資源的存取,當前可用資源計數就會減1,只要當前可用資源計數是大於0的,就能夠發出號志信號。但是當前可用計數減小到0時則說明當前佔用資源的執行緒數已達到了所允許的最大數目,不能在允許其他執行緒的進入,此時的號志信號將無法發出。執行緒在處理完共用資源後,應在離開的同時通過ReleaseSemaphore()函數將當前可用資源計數加1。在任何時候當前可用資源計數決不可能大於最大資源計數。 

號志包含的幾個操作原語:   

  • CreateSemaphore() 建立一個號志   
  • OpenSemaphore() 開啓一個號志   
  • ReleaseSemaphore() 釋放號志   
  • WaitForSingleObject() 等待號志  
#include "stdafx.h"
#include<windows.h>
#include<iostream>
using namespace std;
 
int number = 1; //定義全域性變數
HANDLE hSemaphore;  //定義號志控制代碼
 
unsigned long __stdcall ThreadProc1(void* lp)
{
    long count;
    while (number < 100)
    {
        WaitForSingleObject(hSemaphore, INFINITE);  //等待號志爲有信號狀態
        cout << "thread 1 :"<<number << endl;
        ++number;
        _sleep(100);
        ReleaseSemaphore(hSemaphore, 1, &count);
    }
 
    return 0;
}
 
unsigned long __stdcall ThreadProc2(void* lp)
{
    long count;
    while (number < 100)
    {
        WaitForSingleObject(hSemaphore, INFINITE);  //等待號志爲有信號狀態
        cout << "thread 2 :"<<number << endl;
        ++number;
        _sleep(100);
        ReleaseSemaphore(hSemaphore, 1, &count);
    }
 
    return 0;
}
 
int main()
{
    hSemaphore = CreateSemaphore(NULL, 1, 100, "sema");
 
    CreateThread(NULL, 0, ThreadProc1, NULL, 0, NULL);
    CreateThread(NULL, 0, ThreadProc2, NULL, 0, NULL);
 
    Sleep(10*1000);
 
    system("pause");
    return 0;
}

9.3.4  互斥量

採用互斥物件機制 機製。 只有擁有互斥物件的執行緒纔有存取公共資源的許可權,因爲互斥物件只有一個,所以能保證公共資源不會同時被多個執行緒存取。互斥不僅能實現同一應用程式的公共資源安全共用,還能實現不同應用程式的公共資源安全共用。

互斥量包含的幾個操作原語:   

  • CreateMutex() 建立一個互斥量   
  • OpenMutex() 開啓一個互斥量   
  • ReleaseMutex() 釋放互斥量   
  • WaitForMultipleObjects() 等待互斥量物件  
#include "stdafx.h"
#include<windows.h>
#include<iostream>
using namespace std;
 
int number = 1; //定義全域性變數
HANDLE hMutex;  //定義互斥物件控制代碼
 
unsigned long __stdcall ThreadProc1(void* lp)
{
    while (number < 100)
    {
        WaitForSingleObject(hMutex, INFINITE);
        cout << "thread 1 :"<<number << endl;
        ++number;
        _sleep(100);
        ReleaseMutex(hMutex);
    }
 
    return 0;
}
 
unsigned long __stdcall ThreadProc2(void* lp)
{
    while (number < 100)
    {
        WaitForSingleObject(hMutex, INFINITE);
        cout << "thread 2 :"<<number << endl;
        ++number;
        _sleep(100);
        ReleaseMutex(hMutex);
    }
 
    return 0;
}
 
int main()
{
    hMutex = CreateMutex(NULL, false, "mutex");     //建立互斥物件
 
    CreateThread(NULL, 0, ThreadProc1, NULL, 0, NULL);
    CreateThread(NULL, 0, ThreadProc2, NULL, 0, NULL);
 
    Sleep(10*1000);
 
    system("pause");
    return 0;
}

9.4  C++中的幾種鎖

在9.3.4中我們講到了互斥量,其中CreateMutex等是Win32 api函數,而本節要介紹的std :: mutex來自C++標準庫。

在C++11中執行緒之間的鎖有:互斥鎖、條件鎖、自旋鎖、讀寫鎖、遞回鎖

9.4.1  互斥鎖

互斥鎖是一種簡單的加鎖的方法來控制對共用資源的存取。

通過std::mutex可以方便的對臨界區域加鎖,std::mutex類定義於mutex標頭檔案,是用於保護共用數據避免從多個執行緒同時存取的同步原語,它提供了lock、try_lock、unlock等幾個介面。使用方法如下:

std::mutex mtx;
mtx.lock()
do_something...;    //共用的數據
mtx.unlock();

mutex的lock和unlock必須成對呼叫,lock之後忘記呼叫unlock將是非常嚴重的錯誤,再次lock時會造成死鎖。

此時可以使用類別範本std::lock_guard,通過RAII機制 機製在其作用域內佔有mutex,當程式流程離開建立lock_guard物件的作用域時,lock_guard物件被自動銷燬並釋放mutex。lock_guard構造時還可以傳入一個參數adopt_lock或者defer_lock。adopt_lock表示是一個已經鎖上了鎖,defer_lock表示之後會上鎖的鎖。

std::mutex mtx;
std::lock_guard<std::mutex> guard(mtx);
do_something...;    //共用的數據

lock_guard類最大的缺點也是簡單,沒有給程式設計師提供足夠的靈活度,因此C++11定義了另一個unique_guard類。這個類和lock_guard類似,也很方便執行緒對互斥量上鎖,但它提供了更好的上鎖和解鎖控制,允許延遲鎖定、鎖定的有時限嘗試、遞回鎖定、所有權轉移和與條件變數一同使用。

#include <iostream>       // std::cout
#include <thread>         // std::thread
#include <mutex>          // std::mutex, std::unique_lock
#include <vector>

std::mutex mtx;           // mutex for critical section
std::once_flag flag;

void print_block (int n, char c) {
    //unique_lock有多組建構函式, 這裏std::defer_lock不設定鎖狀態
    std::unique_lock<std::mutex> my_lock (mtx, std::defer_lock);
    //嘗試加鎖, 如果加鎖成功則執行
    //(適合定時執行一個job的場景, 一個執行緒執行就可以, 可以用更新時間戳輔助)
    if(my_lock.try_lock()){
        for (int i=0; i<n; ++i)
            std::cout << c;
        std::cout << '\n';
    }
}

void run_one(int &n){
    std::call_once(flag, [&n]{n=n+1;}); //只執行一次, 適合延遲載入; 多執行緒static變數情況
}

int main () {
    std::vector<std::thread> ver;
    int num = 0;
    for (auto i = 0; i < 10; ++i){
        ver.emplace_back(print_block,50,'*');
        ver.emplace_back(run_one, std::ref(num));
    }

    for (auto &t : ver){
        t.join();
    }
    std::cout << num << std::endl;
    return 0;
}

unique_lock比lock_guard使用更加靈活,功能更加強大,但使用unique_lock需要付出更多的時間、效能成本。

9.4.2  條件鎖

條件鎖就是所謂的條件變數,當某一執行緒滿足某個條件時,可以使用條件變數令該程式處於阻塞狀態;一旦該條件狀態發生變化,就以「號志」的方式喚醒一個因爲該條件而被阻塞的執行緒。

最爲常見就是線上程池中,起初沒有任務時任務佇列爲空,此時執行緒池中的執行緒因爲「任務佇列爲空」這個條件處於阻塞狀態。一旦有任務進來,就會以號志的方式喚醒一個執行緒來處理這個任務。

  • 標頭檔案:<condition_variable>
  • 型別:std::condition_variable(只與std::mutex一起工作)、std::condition_variable_any(可與符合類似互斥元的最低標準的任何東西一起工作)。
std::deque<int> q;
std::mutex mu;
std::condition_variable cond;

void function_1() //生產者
{
    int count = 10;
    while (count > 0) 
    {
        std::unique_lock<std::mutex> locker(mu);
        q.push_front(count);
        locker.unlock();
        cond.notify_one();  // Notify one waiting thread, if there is one.
        std::this_thread::sleep_for(std::chrono::seconds(1));
        count--;
    }
}

void function_2() //消費者
{
    int data = 0;
    while (data != 1) 
    {
        std::unique_lock<std::mutex> locker(mu);
        while (q.empty())
            cond.wait(locker); // Unlock mu and wait to be notified
        data = q.back();
        q.pop_back();
        locker.unlock();
        std::cout << "t2 got a value from t1: " << data << std::endl;
    }
}
int main() 
{
    std::thread t1(function_1);
    std::thread t2(function_2);
    t1.join();
    t2.join();
    return 0;
}

上面是一個生產者-消費者模型,軟體開啓後,消費者執行緒進入回圈,在回圈裡獲取鎖,如果消費品佇列爲空則wait,wait會自動釋放鎖;此時消費者已經沒有鎖了,在生產者執行緒裡,獲取鎖,然後往消費品佇列生產產品,釋放鎖,然後notify告知消費者退出wait,消費者重新獲取鎖,然後從佇列裡取消費品。

9.4.3  自旋鎖

當發生阻塞時,互斥鎖會讓CPU去處理其他的任務,而自旋鎖則會讓CPU一直不斷回圈請求獲取這個鎖。由此可見「自旋鎖」是比較耗費CPU的。在C++中我們可以通過原子操作實現自旋鎖:

//使用std::atomic_flag的自旋鎖互斥實現
class spinlock_mutex{
private:
    std::atomic_flag flag;
public:
    spinlock_mutex():flag(ATOMIC_FLAG_INIT) {}
    void lock()
    {
        while(flag.test_and_set(std::memory_order_acquire));
    }
    void unlock()
    {
        flag.clear(std::memory_order_release);
    }
}

9.4.4  讀寫鎖

說到讀寫鎖我們可以藉助於「讀者-寫者」問題進行理解。

計算機中某些數據被多個進程共用,對數據庫的操作有兩種:一種是讀操作,就是從數據庫中讀取數據不會修改數據庫中內容;另一種就是寫操作,寫操作會修改數據庫中存放的數據。因此可以得到我們允許在數據庫上同時執行多個「讀」操作,但是某一時刻只能在數據庫上有一個「寫」操作來更新數據。這就是一個簡單的讀者-寫者模型。

標頭檔案:boost/thread/shared_mutex.cpp
型別:boost::shared_lock、boost::shared_mutex

shared_mutex比一般的mutex多了函數lock_shared() / unlock_shared(),允許多個(讀者)執行緒同時加鎖和解鎖;而shared_lock則相當於共用版的lock_guard。對於shared_mutex使用lock_guard或unique_lock就可以達到寫者執行緒獨佔鎖的目的。

讀寫鎖的特點:

  1. 如果一個執行緒用讀鎖鎖定了臨界區,那麼其他執行緒也可以用讀鎖來進入臨界區,這樣可以有多個執行緒並行操作。這個時候如果再用寫鎖加鎖就會發生阻塞。寫鎖請求阻塞後,後面繼續有讀鎖來請求時,這些後來的讀鎖都將會被阻塞。這樣避免讀鎖長期佔有資源,防止寫鎖飢餓。
  2. 如果一個執行緒用寫鎖鎖住了臨界區,那麼其他執行緒無論是讀鎖還是寫鎖都會發生阻塞。

9.4.5  遞回鎖

遞回鎖又稱可重入鎖,在同一個執行緒在不解鎖的情況下,可以多次獲取鎖定同一個遞回鎖,而且不會產生死鎖。遞回鎖用起來固然簡單,但往往會隱藏某些程式碼問題。比如呼叫函數和被呼叫函數以爲自己拿到了鎖,都在修改同一個物件,這時就很容易出現問題。

9.5  C++中的原子操作

9.5.1  atomic模版函數

爲了避免多個執行緒同時修改全域性變數,C++11除了提供互斥量mutex這種方法以外,還提供了atomic模版函數。使用atomic可以避免使用鎖,而且更加底層,比mutex效率更高。

#include <thread>
#include <iostream>
#include <vector>
#include <atomic>

using namespace std;

void func(int& counter)
{
    for (int i = 0; i < 100000; ++i)
    {
        ++counter;
    }
}

int main()
{
    //atomic<int> counter(0);
    atomic_int counter(0); //新建一個整型原子counter,將counter初始化爲0
    //int counter = 0;
    vector<thread> threads;
    for (int i = 0; i < 10; ++i)
    {
        threads.push_back(thread(func, ref(counter)));
    }
    for (auto& current_thread : threads)
    {
        current_thread.join();
    }
    cout << "Result = " << counter << '\n';
    return 0;
}

爲了避免多個執行緒同時修改了counter這個數導致出現錯誤,只需要把counter的原來的int型,改爲atomic_int型就可以了,非常方便,也不需要用到鎖。

9.5.2  std::atomic_flag

std::atomic_flag是一個原子型的布爾變數,只有兩個操作:

1)test_and_set,如果atomic_flag 物件已經被設定了,就返回True,如果未被設定,就設定之然後返回False

2)clear,把atomic_flag物件清掉

注意這個所謂atomic_flag物件其實就是當前的執行緒。如果當前的執行緒被設定成原子型,那麼等價於上鎖的操作,對變數擁有唯一的修改權。呼叫clear就是類似於解鎖。

下面 下麪先看一個簡單的例子,main() 函數中建立了 10 個執行緒進行計數,率先完成計數任務的執行緒輸出自己的 ID,後續完成計數任務的執行緒不會輸出自身 ID:

#include <iostream>              // std::cout
#include <atomic>                // std::atomic, std::atomic_flag, ATOMIC_FLAG_INIT
#include <thread>                // std::thread, std::this_thread::yield
#include <vector>                // std::vector

std::atomic<bool> ready(false);    // can be checked without being set
std::atomic_flag winner = ATOMIC_FLAG_INIT;    // always set when checked

void count1m(int id)
{
    while (!ready) {
        std::this_thread::yield();
    } // 等待主執行緒中設定 ready 爲 true.

    for (int i = 0; i < 1000000; ++i) {
    } // 計數.

    // 如果某個執行緒率先執行完上面的計數過程,則輸出自己的 ID.
    // 此後其他執行緒執行 test_and_set 是 if 語句判斷爲 false,
    // 因此不會輸出自身 ID.
    if (!winner.test_and_set()) {
        std::cout << "thread #" << id << " won!\n";
    }
};

int main()
{
    std::vector<std::thread> threads;
    std::cout << "spawning 10 threads that count to 1 million...\n";
    for (int i = 1; i <= 10; ++i)
        threads.push_back(std::thread(count1m, i));
    ready = true;

    for (auto & th:threads)
        th.join();

    return 0;
}

再來一個例子:

#include <iostream>
#include <atomic>
#include <vector>
#include <thread>
#include <sstream>


std::atomic_flag lock = ATOMIC_FLAG_INIT; //初始化原子flag
std::stringstream  stream;

void append_number(int x)
{
    while(lock.test_and_set()); //如果原子flag未設定,那麼返回False,就繼續後面的程式碼。否則一直返回True,就一直停留在這個回圈。
    stream<<"thread#" <<x<<'\n';
    lock.clear(); //去除flag的物件
}

int main()
{
    std::vector<std::thread> threads;
    for(int i=0;i<10;i++)
        threads.push_back(std::thread(append_number, i));
    
    for(auto& th:threads)
        th.join();
    std::cout<<stream.str()<<'\n';
}

9.6  相關面試題

Q:C++怎麼保證執行緒安全

A:

Q:悲觀鎖和樂觀鎖

A:悲觀鎖:悲觀鎖是就是悲觀思想,即認爲讀少寫多,遇到併發寫的可能性高,每次去拿數據的時候都認爲別人會修改,所以每次在讀寫數據的時候都會上鎖,這樣別人想讀寫這個數據就會 block 直到拿到鎖。

樂觀鎖:樂觀鎖是一種樂觀思想,即認爲讀多寫少,遇到併發寫的可能性低,每次去拿數據的時候都認爲別人不會修改,所以不會上鎖,但是在更新的時候會判斷一下在此期間別人有沒有去更新這個數據,採取在寫時先讀出當前版本號,然後加鎖操作(比較跟上一次的版本號,如果一樣則更新),如果失敗則要重複【讀 - 比較 - 寫】的操作。

Q:什麼是死鎖

A:所謂死鎖是指多個執行緒因競爭資源而造成的一種僵局(互相等待),若無外力作用,這些進程都將無法向前推進。

Q:死鎖形成的必要條件

A:

產生死鎖必須同時滿足以下四個條件,只要其中任一條件不成立,死鎖就不會發生

  • 互斥條件:進程要求對所分配的資源(如印表機)進行排他性控制,即在一段時間內某 資源僅爲一個進程所佔有。此時若有其他進程請求該資源,則請求進程只能等待。
  • 不剝奪條件:進程所獲得的資源在未使用完畢之前,不能被其他進程強行奪走,即只能 由獲得該資源的進程自己來釋放(只能是主動釋放)。
  • 請求和保持條件:進程已經保持了至少一個資源,但又提出了新的資源請求,而該資源 已被其他進程佔有,此時請求進程被阻塞,但對自己已獲得的資源保持不放。
  • 回圈等待條件:存在一種進程資源的回圈等待鏈,鏈中每一個進程已獲得的資源同時被 鏈中下一個進程所請求。即存在一個處於等待狀態的進程集合 {Pl, P2, …, pn},其中 Pi 等 待的資源被 P (i+1) 佔有(i=0, 1, …, n-1),Pn 等待的資源被 P0 佔有

Q:什麼是活鎖

A:活鎖和死鎖在表現上是一樣的兩個執行緒都沒有任何進展,但是區別在於:死鎖,兩個執行緒都處於阻塞狀態而活鎖並不會阻塞,而是一直嘗試去獲取需要的鎖,不斷的 try,這種情況下執行緒並沒有阻塞所以是活的狀態,我們檢視執行緒的狀態也會發現執行緒是正常的,但重要的是整個程式卻不能繼續執行了,一直在做無用功。

Q:公平鎖與非公平鎖

A:公平鎖:是指多個執行緒在等待同一個鎖時,必須按照申請鎖的先後順序來一次獲得鎖。

非公平鎖:理解了公平鎖,非公平鎖就很好理解了,它無非就是不用排隊,當餐廳裡的人出來後將鑰匙往地上一扔,誰搶到算誰的。