淺談C++11中的多執行緒(一)

唯有自己強大發表於2021-07-14

摘要

本篇文章圍繞以下幾個問題展開:

  1. 程式和執行緒的區別
  2. 何為併發?C++中如何解決併發問題?C++中多執行緒的基本操作
  3. 同步互斥原理以及多程式和多執行緒中實現同步互斥的兩種方法
  4. 條件變數和原子操作
  5. Qt中的多執行緒應用

c++的多執行緒可以充分利用計算機資源,提高程式碼執行效率。在這裡總結了一些多執行緒應用過程中的基本概念和用法。   

一,程式和執行緒的區別

  • 程式是一個應用程式被作業系統拉起來載入到記憶體之後從開始執行到執行結束的這樣一個過程。簡單來說,程式是程式(應用程式,可執行檔案)的一次執行。比如雙擊開啟一個桌面應用軟體就是開啟了一個程式。
  • 執行緒是程式中的一個實體,是被系統獨立分配和排程的基本單位。也有說,執行緒是CPU可執行排程的最小單位。也就是說,程式本身並不能獲取CPU時間,只有它的執行緒才可以。

引入執行緒之後,將傳統程式的兩個基本屬性分開了,執行緒作為排程和分配的基本單位,程式作為獨立分配資源的單位。

我對這句話的理解是:執行緒參與作業系統的排程,參與CPU的競爭,得到分配的時間片,獲得處理機(CPU)執行。而程式負責獲取作業系統分配的資源,如記憶體。

二,何為併發?

簡單來說,併發指的是兩個或多個獨立的活動在同一時段內發生

與併發相近的另一個概念是並行。它們兩者存在很大的差別,圖示如下:

  • 併發:同一時間段內可以交替處理多個操作,強調同一時段內交替發生。

  • 並行:同一時刻內同時處理多個操作,強調同一時刻點同時發生。

在傳統的單核CPU中,CPU通過極快的速度不停的切換不同應用程式的命令,而讓我們看起來感覺計算機在同時執行很多個應用程式。比如,一邊聽歌,一邊聊天,還能同時打遊戲,我們誤以為這是併發,其實只是一種偽併發的假象。

在出現多核處理器以後,使得併發真正的實現。C++中採用多執行緒實現併發。

三,C++11執行緒基本操作

一個多執行緒C++程式是什麼樣子的?它看上去和其他所有C++程式一樣,通常是變數、類以及函式的組合。唯一真正的區別在於某些函式可以併發執行,所以你需要確保共享資料的併發訪問是安全的。當然,為了併發地執行函式,必須使用特定的函式以及物件來管理各個執行緒。

C++11新標準多執行緒支援庫

C++標準並沒有提供對多程式併發的原生支援,所以C++的多程式併發要靠其他API(比如用CreateThread進行建立)
C++11可以通過多執行緒實現併發,這是一種比較底層、傳統的實現方式。C++11引入了5個標頭檔案來支援多執行緒程式設計,是<atomic>/<thread>/<mutex>/<condition_variable>/<future>

  • < thread > : 提供執行緒建立及管理的函式或類介面;
  • < mutex > : C++11 互斥量Mutex。在多執行緒環境中,有多個執行緒競爭同一個公共資源,就很容易引發執行緒安全的問題
  • < condition_variable > : 允許一定量的執行緒等待(可以定時)被另一執行緒喚醒,然後再繼續執行;
  • < future > : 提供了一些工具來獲取非同步任務(即在單獨的執行緒中啟動的函式)的返回值,並捕捉其所丟擲的異常
  • < atomic >:為細粒度的原子操作(不能被處理器拆分處理的操作)提供元件,允許無鎖併發程式設計。

std::thread類成員函式:

  • get_id:獲取執行緒ID,返回一個型別為std::thread::id的物件。
  • joinable:檢查執行緒是否可被join。檢查thread物件是否標識一個活動(active)的可行性執行緒。預設構造的thread物件、已經完成join的thread物件、已經detach的thread物件都不是joinable。
  • join:通過join()函式關聯並阻塞執行緒,等待該執行緒執行完畢後繼續;
  • detach:通過detach()函式解除關聯使執行緒可以與主執行緒併發執行,但若主執行緒執行完畢退出後,detach()接觸關聯的執行緒即便沒有執行完畢,也將自動退出。
  • native_handle:該函式返回與std::thread具體實現相關的執行緒控制程式碼。
  • swap:交換兩個執行緒物件所代表的底層控制程式碼。
  • operator:移動執行緒物件

有時候我們需要線上程執行程式碼裡面對當前呼叫者執行緒進行操作,針對這種情況,C++11裡面專門定義了一個名稱空間this_thread,此名稱空間也宣告在<thread>標頭檔案中,其中包括:

  • get_id()函式用來獲取當前呼叫者執行緒的ID;
  • yield()函式可以用來將呼叫者執行緒跳出執行狀態,重新交給作業系統進行排程,即當前執行緒放棄執行,作業系統排程另一執行緒繼續執行;
  • sleep_until()函式是將執行緒休眠至某個指定的時刻(time point),該執行緒才被重新喚醒;
  • sleep_for()函式是將執行緒休眠某個指定的時間片(time span),該執行緒才被重新喚醒,不過由於執行緒排程等原因,實際休眠實際可能比sleep_duration所表示的時間片更長。

std::thread的關鍵總結:

  1. C++ 11中建立執行緒非常簡單,使用std::thread類就可以,thread類定義於thread標頭檔案,構造thread物件時傳入一個可呼叫物件作為引數(如果可呼叫物件有引數,把引數同時傳入),這樣構造完成後,新的執行緒馬上被建立,同時執行該可呼叫物件
  2. 用std::thread預設的建構函式構造的物件不關聯任何執行緒;判斷一個thread物件是否關聯某個執行緒,使用joinable()介面,如果返回true,表明該物件關聯著某個執行緒(即使該執行緒已經執行結束);
  3. "joinable"的物件析構前,必須呼叫join()介面等待執行緒結束,或者呼叫detach()介面解除與執行緒的關聯,否則會拋異常;
  4. 正在執行的執行緒從關聯的物件detach後會自主執行直至結束,對應的物件變成不關聯任何執行緒的物件,joinable()將返回false;
  5. std::thread沒有拷貝建構函式和拷貝賦值操作符,因此不支援複製操作(但是可以move),也就是說,沒有兩個 std::thread物件會表示同一執行執行緒;
  6.  容易知道,如下幾種情況下,std::thread物件是不關聯任何執行緒的(對這種物件呼叫join或detach介面會拋異常):

    預設構造的thread物件;

    被移動後的thread物件;

    detach 或 join 後的thread物件;

C++中多執行緒建立:

1️⃣簡單使用

#include <iostream>
#include <thread>
using namespace std;

void f()
{
    cout<<"thread 1 is running"<<endl;
    this_thread::sleep_for(chrono::seconds(1));
}
int main()
{
    thread t1(f); //建立執行緒,一旦建立完畢,馬上開始執行。
    t1.join();
}

這裡建立傳入的函式f,實際上其建構函式需要的是可呼叫(callable)型別,只要是有函式呼叫型別的例項都是可以的。所有除了傳遞函式外,還可以使用:

  • lambda表示式
for (int i = 0; i < 4; i++)
{
    thread t([i]{
        cout << i << endl;
    });
    t.detach();
}
  • 過載了()運算子的類的例項
#include <iostream>
#include <thread>
using namespace std;
class Task
{
public :
void operator()(int i)  //()過載
    {
        cout << i << endl;
    }
};

int main()
{
    for (int i = 0; i < 4; i++)
    {
        Task task;
        thread t(task, i);
        t.detach();
    }
}

當執行緒啟動後一定要在和執行緒相關聯的thread銷燬前,確定以何種方式等待執行緒執行結束。C++11有兩種方式來等待執行緒結束

  • detach方式,啟動的執行緒自主在後臺執行,當前的程式碼繼續往下執行,不等待新執行緒結束。前面程式碼所使用的就是這種方式。因此執行的結果就多種多樣了。

這就涉及到多執行緒程式設計最核心的問題了資源競爭。CPU有4核,可以同時執行4個執行緒是沒有問題的。但是控制檯(資源)卻只有一個,同時只能有一個執行緒擁有這個唯一的控制檯,將數字輸出。

  • join方式,等待啟動的執行緒完成,才會繼續往下執行。假如前面的程式碼使用這種方式,其輸出就會0,1,2,3,因為每次都是前一個執行緒輸出完成了才會進行下一個迴圈,啟動下一個新執行緒。

2️⃣帶函式引數的執行緒

 當需要向執行緒函式傳遞引數時,直接在建立執行緒時,同時也把引數作為入參傳遞給執行緒函式。

注意當呼叫函式的引數為引用引數時,執行緒呼叫需要加上ref關鍵字表示引用。並且執行緒函式會改變引用的變數值。

void f1(int n)
{
    n++;
    cout<<"n = "<< n <<endl;
}

void f2(int &n)//引用引數
{
    n++;
    cout<<"n = "<<n<<endl;
}

int main()
{
    int n = 0;
    
    thread t1(f1, n);
    t1.join();
    cout<<"n = "<<n<<endl;
    
    thread t2(f2, ref(n));
    t2.join();
    cout<<"n = "<<n<<endl;

}

  執行結果為:

3️⃣轉移執行緒的所有權

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

void f2(int &n)
{
    n++;
    cout<<"n = "<<n<<endl;
}

int main()
{
    int n = 0;
    
    thread t3(f2, ref(n));
    thread t4(move(t3)); //此時t4正在執行f2(),t3不再是一個執行緒了。
    t4.join();

}

執行結果:

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

4️⃣執行緒暫停

如果讓執行緒從外部暫停會引發很多併發問題,這也是為什麼std::thread沒有直接提供pause函式的原因。

如果執行緒在執行過程中,確實需要停頓,就可以用this_thread::sleep_for。

 this_thread::sleep_for(chrono::seconds(3)); //此處執行緒停頓3秒。

5️⃣獲取當前執行緒號

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

  • 通過thread的例項呼叫get_id()直接獲取
  • 在當前執行緒上呼叫this_thread::get_id()獲取
thread::id main_threadId =  this_thread::get_id();

小結:

 本結主要介紹了C++11引入的標準多執行緒庫的一些基本操作。有以下內容:

  • 執行緒的建立
  • 執行緒的執行方式,join或者detach
  • 向執行緒函式傳遞引數,需要注意的是執行緒預設是以拷貝的方式傳遞引數的,當期望傳入一個引用時,要使用std::ref進行轉換
  • 執行緒是movable的,可以在函式內部或者外部進行傳遞
  • 執行緒在執行過程中,如果需要停頓,可以用this_thread::sleep_for實現。
  • 每個執行緒都一個標識,可以呼叫get_id獲取。

 執行緒管理的示例程式碼

建立執行緒,並觀察執行緒的併發執行與阻塞等待

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

using namespace std;

void thread_function(int n)
{
    std::thread::id this_id = std::this_thread::get_id();            //獲取執行緒ID

    for (int i = 0; i < 5; i++) {
        cout << "子執行緒 " << this_id << " 執行 : " << i + 1 << endl;
        std::this_thread::sleep_for(std::chrono::seconds(n));       //程式睡眠n秒
    }
}

class Thread_functor
{
public:
    // functor行為類似函式,C++中的仿函式是通過在類中過載()運算子實現,使你可以像使用函式一樣來建立類的物件
    void operator()(int n)
    {
        std::thread::id this_id = std::this_thread::get_id();

        for (int i = 0; i < 5; i++) {
            cout << "子仿函式執行緒" << this_id << " 執行: " << i + 1 << endl;
            std::this_thread::sleep_for(std::chrono::seconds(n));   //程式睡眠n秒
        }
    }
};


int main()
{
    thread mythread1(thread_function, 1);      // 傳遞初始函式作為執行緒的引數
    if (mythread1.joinable())                  //判斷是否可以成功使用join()或者detach(),返回true則可以,返回false則不可以
        mythread1.join();                     // 使用join()函式阻塞主執行緒直至子執行緒執行完畢

    Thread_functor thread_functor;             //函式物件例項化一個物件
    thread mythread2(thread_functor, 3);     // 傳遞初始函式作為執行緒的引數
    if (mythread2.joinable())
        mythread2.detach();                  // 使用detach()函式讓子執行緒和主執行緒並行執行,主執行緒也不再等待子執行緒

    auto thread_lambda = [](int n) {            //lambda表示式格式:[捕獲列表](引數列表)可變異常->返回型別{函式體}
        std::thread::id this_id = std::this_thread::get_id();
        for (int i = 0; i < 5; i++)
        {
            cout << "子lambda執行緒" << this_id << " 執行: " << i + 1 << endl;
            std::this_thread::sleep_for(std::chrono::seconds(n));   //程式睡眠n秒
        }
    };

    thread mythread3(thread_lambda, 4);     // 傳遞初始函式作為執行緒的引數
    if (mythread3.joinable())
        mythread3.join();                     // 使用join()函式阻塞主執行緒直至子執行緒執行完畢

    std::thread::id this_id = std::this_thread::get_id();
    for (int i = 0; i < 5; i++) {
        cout << "主執行緒" << this_id << " 執行: " << i + 1 << endl;
        std::this_thread::sleep_for(std::chrono::seconds(1));
    }

    getchar();
    return 0;
}

執行結果:

 上面的程式碼分別用三種函式物件建立了三個執行緒,其中第一個執行緒mythread1阻塞等待其執行完後繼續往下執行,第二個執行緒mythread2不阻塞等待在後臺與後面的第三個執行緒mythread3併發執行,第三個執行緒繼續阻塞等待其完成後再繼續往下執行主執行緒任務。為了便於觀察併發過程,對三個執行緒均用了睡眠延時this_thread::sleep_for(duration)函式,且延時時間作為引數傳遞給該函式。

上面的示例假如多重複執行幾次,有很大可能會出現某行與其他行交疊錯亂的情況(如下圖所示),為何會出現這種情況呢?

這就涉及到多執行緒資源競爭的問題了,即一個執行緒對某一資源(這裡指顯示終端)的訪問還未完成,另一執行緒搶奪並訪問了該資源,導致該資源資料混亂情況的出現。

解決方案詳見下一篇文章:淺談C++11中的多執行緒(二) - 唯有自己強大 - 部落格園 (cnblogs.com)

 

相關博文:(2條訊息) C++多執行緒併發(一)--- 執行緒建立與管理_流雲-CSDN部落格_c++多執行緒併發

  

相關文章