C++多線程并發基礎

什么是C++多線程并發?

線程:線程是操作系統能夠進行CPU調度的最小單位,它被包含在進程之中,一個進程可包含單個或者多個線程。可以用多個線程去完成一個任務,也可以用多個進程去完成一個任務,它們的本質都相當于多個人去合伙完成一件事。

多線程并發:多線程是實現并發(雙核的真正并行或者單核機器的任務切換都叫并發)的一種手段,多線程并發即多個線程同時執行,一般而言,多線程并發就是把一個任務拆分為多個子任務,然后交由不同線程處理不同子任務,使得這多個子任務同時執行。

C++多線程并發: C++98標準中并沒有線程庫的存在,而在C++11中才提供了多線程的標準庫,提供了管理線程、保護共享數據、線程間同步操作、原子操作等類,。(簡單情況下)實現C++多線程并發程序的思路如下:將任務的不同功能交由多個函數分別實現,創建多個線程,每個線程執行一個函數,一個任務就這樣同時分由不同線程執行了。

不要過多糾結多線程與多進程、并發與并行這些概念 (這些概念都是相當于多個人去合伙完成一件事),會用才是王道,理解大致意思即可,想要深入了解可閱讀本文“延伸拓展”章節。

我們通常在何時使用并發? 程序使用并發的原因有兩種,為了關注點分離(程序中不同的功能,使用不同的線程去執行),或者為了提高性能。當為了分離關注點而使用多線程時,設計線程的數量的依據,不再是依賴于CPU中的可用內核的數量,而是依據概念上的設計(依據功能的劃分)。

知道何時不使用并發與知道何時使用它一樣重要。 不使用并發的唯一原因就是收益(性能的增幅)比不上成本(代碼開發的腦力成本、時間成本,代碼維護相關的額外成本)。運行越多的線程,操作系統需要為每個線程分配獨立的棧空間,需要越多的上下文切換,這會消耗很多操作系統資源,如果在線程上的任務完成得很快,那么實際執行任務的時間要比啟動線程的時間小很多,所以在某些時候,增加一個額外的線程實際上會降低,而非提高應用程序的整體性能,此時收益就比不上成本。

2 C++多線程并發基礎知識

2.1 創建線程

首先要引入頭文件#include<thread>,C++11中管理線程的函數和類在該頭文件中聲明,其中包括std::thread類。

語句"std::thread th1(proc1);"創建了一個名為th1的線程,并且線程th1開始執行。

實例化std::thread類對象時,至少需要傳遞函數名作為參數。如果函數為有參函數,如"void proc2(int a,int b)",那么實例化std::thread類對象時,則需要傳遞更多參數,參數順序依次為函數名、該函數的第一個參數、該函數的第二個參數,···,如"std::thread th2(proc2,a,b);"。這里的傳參,后續章節還會有詳解與提升。

只要創建了線程對象(前提是,實例化std::thread對象時傳遞了“函數名/可調用對象”作為參數),線程就開始執行。

總之,使用C++線程庫啟動線程,可以歸結為構造std::thread對象。

那么至此一個簡單的多線程并發程序就編寫完了嗎?

不,還沒有。當線程啟動后,一定要在和線程相關聯的std::thread對象銷毀前,對線程運用join()或者detach()方法。

join()與detach()都是std::thread類的成員函數,是兩種線程阻塞方法,兩者的區別是是否等待子線程執行結束。

新手先把join()弄明白就行了,然后就可以去學習后面的章節,等過段時間再回頭來學detach()。

等待調用線程運行結束后當前線程再繼續運行,例如,主函數中有一條語句th1.join(),那么執行到這里,主函數阻塞,直到線程th1運行結束,主函數再繼續運行。

整個過程就相當于:你在處理某件事情(你是主線程),中途你讓老王幫你辦一個任務(與你同時執行)(創建線程1,該線程取名老王),又叫老李幫你辦一件任務(創建線程2,該線程取名老李),現在你的一部分工作做完了,剩下的工作得用到他們的處理結果,那就調用"老王.join()"與"老李.join()",至此你就需要等待(主線程阻塞),等他們把任務做完(子線程運行結束),你就可以繼續你手頭的工作了(主線程不再阻塞)。

一提到join,你腦海中就想起兩個字,"等待",而不是"加入",這樣就很容易理解join的功能。

#include<iostream>
#include<thread>
using namespace std;
void proc(int &a)
{
    cout << "我是子線程,傳入參數為" << a << endl;
    cout << "子線程中顯示子線程id為" << this_thread::get_id()<< endl;
}
int main()
{
    cout << "我是主線程" << endl;
    int a = 9;
    thread th2(proc,a);//第一個參數為函數名,第二個參數為該函數的第一個參數,如果該函數接收多個參數就依次寫在后面。此時線程開始執行。
    cout << "主線程中顯示子線程id為" << th2.get_id() << endl;
    th2.join();//此時主線程被阻塞直至子線程執行結束。
    return 0;
}

調用join()會清理線程相關的存儲部分,這代表了join()只能調用一次。使用joinable()來判斷join()可否調用。同樣,detach()也只能調用一次,一旦detach()后就無法join()了,有趣的是,detach()可否調用也是使用joinable()來判斷。

如果使用detach(),就必須保證線程結束之前可訪問數據的有效性,使用指針和引用需要格外謹慎,這點我們放到以后再聊。

2.2 互斥量使用

什么是互斥量?

這樣比喻:單位上有一臺打印機(共享數據a),你要用打印機(線程1要操作數據a),同事老王也要用打印機(線程2也要操作數據a),但是打印機同一時間只能給一個人用,此時,規定不管是誰,在用打印機之前都要向領導申請許可證(lock),用完后再向領導歸還許可證(unlock),許可證總共只有一個,沒有許可證的人就等著在用打印機的同事用完后才能申請許可證(阻塞,線程1lock互斥量后其他線程就無法lock,只能等線程1unlock后,其他線程才能lock)。那么,打印機就是共享數據,訪問打印機的這段代碼就是臨界區,這個必須互斥使用的許可證就是互斥量。

互斥量是為了解決數據共享過程中可能存在的訪問沖突的問題。這里的互斥量保證了使用打印機這一過程不被打斷。

互斥量怎么使用?

首先需要#include<mutex>;(std::mutex和std::lock_guard都在<mutex>頭文件中聲明。)

然后需要實例化std::mutex對象;

最后需要在進入臨界區之前對互斥量加鎖,退出臨界區時對互斥量解鎖;

至此,互斥量走完了它的一生。

lock()與unlock():

#include<iostream>
#include<thread>
#include<mutex>
using namespace std;
mutex m;//實例化m對象,不要理解為定義變量
void proc1(int a)
{
    m.lock();
    cout << "proc1函數正在改寫a" << endl;
    cout << "原始a為" << a << endl;
    cout << "現在a為" << a + 2 << endl;
    m.unlock();
}

void proc2(int a)
{
    m.lock();
    cout << "proc2函數正在改寫a" << endl;
    cout << "原始a為" << a << endl;
    cout << "現在a為" << a + 1 << endl;
    m.unlock();
}
int main()
{
    int a = 0;
    thread proc1(proc1, a);
    thread proc2(proc2, a);
    proc1.join();
    proc2.join();
    return 0;
}

需要在進入臨界區之前對互斥量lock,退出臨界區時對互斥量unlock;當一個線程使用特定互斥量鎖住共享數據時,其他的線程想要訪問鎖住的數據,都必須等到之前那個線程對數據進行解鎖后,才能進行訪問。

程序實例化mutex對象m,本線程調用成員函數m.lock()會發生下面 2 種情況: (1)如果該互斥量當前未上鎖,則本線程將該互斥量鎖住,直到調用unlock()之前,本線程一直擁有該鎖。 (2)如果該互斥量當前被其他線程鎖住,則本線程被阻塞,直至該互斥量被其他線程解鎖,此時本線程將該互斥量鎖住,直到調用unlock()之前,本線程一直擁有該鎖。

不推薦實直接去調用成員函數lock(),因為如果忘記unlock(),將導致鎖無法釋放,使用lock_guard或者unique_lock則能避免忘記解鎖帶來的問題。

lock_guard:
std::lock_guard()是什么呢?它就像一個保姆,職責就是幫你管理互斥量,就好像小孩要玩玩具時候,保姆就幫忙把玩具找出來,孩子不玩了,保姆就把玩具收納好。

其原理是:聲明一個局部的std::lock_guard對象,在其構造函數中進行加鎖,在其析構函數中進行解鎖。最終的結果就是:創建即加鎖,作用域結束自動解鎖。從而使用std::lock_guard()就可以替代lock()與unlock()。

通過設定作用域,使得std::lock_guard在合適的地方被析構(在互斥量鎖定到互斥量解鎖之間的代碼叫做臨界區(需要互斥訪問共享資源的那段代碼稱為臨界區),臨界區范圍應該盡可能的小,即lock互斥量后應該盡早unlock),通過使用{}來調整作用域范圍,可使得互斥量m在合適的地方被解鎖:

#include<iostream>
#include<thread>
#include<mutex>
using namespace std;
mutex m;//實例化m對象,不要理解為定義變量
void proc1(int a)
{
    lock_guard<mutex> g1(m);//用此語句替換了m.lock();lock_guard傳入一個參數時,該參數為互斥量,此時調用了lock_guard的構造函數,申請鎖定m
    cout << "proc1函數正在改寫a" << endl;
    cout << "原始a為" << a << endl;
    cout << "現在a為" << a + 2 << endl;
}//此時不需要寫m.unlock(),g1出了作用域被釋放,自動調用析構函數,于是m被解鎖

void proc2(int a)
{
    {
        lock_guard<mutex> g2(m);
        cout << "proc2函數正在改寫a" << endl;
        cout << "原始a為" << a << endl;
        cout << "現在a為" << a + 1 << endl;
    }//通過使用{}來調整作用域范圍,可使得m在合適的地方被解鎖
    cout << "作用域外的內容3" << endl;
    cout << "作用域外的內容4" << endl;
    cout << "作用域外的內容5" << endl;
}
int main()
{
    int a = 0;
    thread proc1(proc1, a);
    thread proc2(proc2, a);
    proc1.join();
    proc2.join();
    return 0;
}

std::lock_gurad也可以傳入兩個參數,第一個參數為adopt_lock標識時,表示構造函數中不再進行互斥量鎖定,因此此時需要提前手動鎖定。

#include<iostream>
#include<thread>
#include<mutex>
using namespace std;
mutex m;//實例化m對象,不要理解為定義變量
void proc1(int a)
{
    m.lock();//手動鎖定
    lock_guard<mutex> g1(m,adopt_lock);
    cout << "proc1函數正在改寫a" << endl;
    cout << "原始a為" << a << endl;
    cout << "現在a為" << a + 2 << endl;
}//自動解鎖

void proc2(int a)
{
    lock_guard<mutex> g2(m);//自動鎖定
    cout << "proc2函數正在改寫a" << endl;
    cout << "原始a為" << a << endl;
    cout << "現在a為" << a + 1 << endl;
}//自動解鎖
int main()
{
    int a = 0;
    thread proc1(proc1, a);
    thread proc2(proc2, a);
    proc1.join();
    proc2.join();
    return 0;
}

unique_lock:
std::unique_lock類似于lock_guard,只是std::unique_lock用法更加豐富,同時支持std::lock_guard()的原有功能。 使用std::lock_guard后不能手動lock()與手動unlock();使用std::unique_lock后可以手動lock()與手動unlock(); std::unique_lock的第二個參數,除了可以是adopt_lock,還可以是try_to_lock與defer_lock;
嘗試用mutx的lock()去鎖定這個mutex,但如果沒有鎖定成功,會立即返回,不會阻塞在那里,并繼續往下執行;

defer_lock: 始化了一個沒有加鎖的mutex;


image.png
#include<iostream>
#include<thread>
#include<mutex>
using namespace std;
mutex m;
void proc1(int a)
{
    unique_lock<mutex> g1(m, defer_lock);//始化了一個沒有加鎖的mutex
    cout << "xxxxxxxx" << endl;
    g1.lock();//手動加鎖,注意,不是m.lock();注意,不是m.lock(),m已經被g1接管了;
    cout << "proc1函數正在改寫a" << endl;
    cout << "原始a為" << a << endl;
    cout << "現在a為" << a + 2 << endl;
    g1.unlock();//臨時解鎖
    cout << "xxxxx"  << endl;
    g1.lock();
    cout << "xxxxxx" << endl;
}//自動解鎖

void proc2(int a)
{
    unique_lock<mutex> g2(m,try_to_lock);//嘗試加鎖一次,但如果沒有鎖定成功,會立即返回,不會阻塞在那里,且不會再次嘗試鎖操作。
    if(g2.owns_lock){//鎖成功
        cout << "proc2函數正在改寫a" << endl;
        cout << "原始a為" << a << endl;
        cout << "現在a為" << a + 1 << endl;
    }else{//鎖失敗則執行這段語句
        cout <<""<<endl;
    }
}//自動解鎖

int main()
{
    int a = 0;
    thread proc1(proc1, a);
    thread proc2(proc2, a);
    proc1.join();
    proc2.join();
    return 0;
}

使用try_to_lock要小心,因為try_to_lock嘗試鎖失敗后不會阻塞線程,而是繼續往下執行程序,因此,需要使用if-else語句來判斷是否鎖成功,只有鎖成功后才能去執行互斥代碼段。而且需要注意的是,因為try_to_lock嘗試鎖失敗后代碼繼續往下執行了,因此該語句不會再次去嘗試鎖。

std::unique_lock所有權的轉移

注意,這里的轉移指的是std::unique_lock對象間的轉移;std::mutex對象的所有權不需要手動轉移給std::unique_lock , std::unique_lock對象實例化后會直接接管std::mutex。

mutex m;
{  
    unique_lock<mutex> g2(m,defer_lock);
    unique_lock<mutex> g3(move(g2));//所有權轉移,此時由g3來管理互斥量m
    g3.lock();
    g3.unlock();
    g3.lock();
}

condition_variable:

需要#include<condition_variable>,該頭文件中包含了條件變量相關的類,其中包括std::condition_variable類

如何使用?std::condition_variable類搭配std::mutex類來使用,std::condition_variable對象(std::condition_variable cond;)的作用不是用來管理互斥量的,它的作用是用來同步線程,它的用法相當于編程中常見的flag標志(A、B兩個人約定flag=true為行動號角,默認flag為false,A不斷的檢查flag的值,只要B將flag修改為true,A就開始行動)。

類比到std::condition_variable,A、B兩個人約定notify_one為行動號角,A就等著(調用wait(),阻塞),只要B一調用notify_one,A就開始行動(不再阻塞)。

std::condition_variable的具體使用代碼實例可以參見文章中“生產者與消費者問題”章節。

wait(locker) :

wait函數需要傳入一個std::mutex(一般會傳入std::unique_lock對象),即上述的locker。wait函數會自動調用 locker.unlock() 釋放鎖(因為需要釋放鎖,所以要傳入mutex)并阻塞當前線程,本線程釋放鎖使得其他的線程得以繼續競爭鎖。一旦當前線程獲得notify(通常是另外某個線程調用 notify_* 喚醒了當前線程),wait() 函數此時再自動調用 locker.lock()上鎖。

cond.notify_one(): 隨機喚醒一個等待的線程

cond.notify_all(): 喚醒所有等待的線程

2.3 異步線程

需要#include<future>

async與future:

std::async是一個函數模板,用來啟動一個異步任務,它返回一個std::future類模板對象,future對象起到了占位的作用(記住這點就可以了),占位是什么意思?就是說該變量現在無值,但將來會有值(好比你擠公交瞧見空了個座位,剛準備坐下去就被旁邊的小伙給攔住了:“這個座位有人了”,你反駁道:”這不是空著嗎?“,小伙:”等會人就來了“),剛實例化的future是沒有儲存值的,但在調用std::future對象的get()成員函數時,主線程會被阻塞直到異步線程執行結束,并把返回結果傳遞給std::future,即通過FutureObject.get()獲取函數返回值。

相當于你去辦政府辦業務(主線程),把資料交給了前臺,前臺安排了人員去給你辦理(std::async創建子線程),前臺給了你一個單據(std::future對象),說你的業務正在給你辦(子線程正在運行),等段時間你再過來憑這個單據取結果。過了段時間,你去前臺取結果(調用get()),但是結果還沒出來(子線程還沒return),你就在前臺等著(阻塞),直到你拿到結果(子線程return),你才離開(不再阻塞)。

#include <iostream>
#include <thread>
#include <mutex>
#include<future>
#include<Windows.h>
using namespace std;
double t1(const double a, const double b)
{
 double c = a + b;
 Sleep(3000);//假設t1函數是個復雜的計算過程,需要消耗3秒
 return c;
}

int main() 
{
 double a = 2.3;
 double b = 6.7;
 future<double> fu = async(t1, a, b);//創建異步線程線程,并將線程的執行結果用fu占位;
 cout << "正在進行計算" << endl;
 cout << "計算結果馬上就準備好,請您耐心等待" << endl;
 cout << "計算結果:" << fu.get() << endl;//阻塞主線程,直至異步線程return
        //cout << "計算結果:" << fu.get() << endl;//取消該語句注釋后運行會報錯,因為future對象的get()方法只能調用一次。
 return 0;
}

shared_future

std::future與std::shard_future的用途都是為了占位,但是兩者有些許差別。std::future的get()成員函數是轉移數據所有權;std::shared_future的get()成員函數是復制數據。 因此: future對象的get()只能調用一次;無法實現多個線程等待同一個異步線程,一旦其中一個線程獲取了異步線程的返回值,其他線程就無法再次獲取。 std::shared_future對象的get()可以調用多次;可以實現多個線程等待同一個異步線程,每個線程都可以獲取異步線程的返回值。


image.png

2.4 原子類型atomic<>

原子操作指“不可分割的操作”,也就是說這種操作狀態要么是完成的,要么是沒完成的,不存在“操作完成了一半”這種狀況。互斥量的加鎖一般是針對一個代碼段,而原子操作針對的一般都是一個變量(操作變量時加鎖防止他人干擾)。 std::atomic<>是一個模板類,使用該模板類實例化的對象,提供了一些保證原子性的成員函數來實現共享數據的常用操作。

可以這樣理解: 在以前,定義了一個共享的變量(int i=0),多個線程會用到這個變量,那么每次操作這個變量時,都需要lock加鎖,操作完畢unlock解鎖,以保證線程之間不會沖突;但是這樣每次加鎖解鎖、加鎖解鎖就顯得很麻煩,那怎么辦呢? 現在,實例化了一個類對象(std::atomic<int> I=0)來代替以前的那個變量(這里的對象I你就把它看作一個變量,看作對象反而難以理解了),每次操作這個對象時,就不用lock與unlock,這個對象自身就具有原子性(相當于加鎖解鎖操作不用你寫代碼實現,能自動加鎖解鎖了),以保證線程之間不會沖突。

提到std::atomic<>,你腦海里就想到一點就可以了:std::atomic<>用來定義一個自動加鎖解鎖的共享變量(“定義”“變量”用詞在這里是不準確的,但是更加貼切它的實際功能),供多個線程訪問而不發生沖突。

//原子類型的簡單使用
std::atomic<bool> b(true);
b=false;

std::atomic<>對象提供了常見的原子操作(通過調用成員函數實現對數據的原子操作): store是原子寫操作,load是原子讀操作。exchange是于兩個數值進行交換的原子操作。 即使使用了std::atomic<>,也要注意執行的操作是否支持原子性,也就是說,你不要覺得用的是具有原子性的變量(準確說是對象)就可以為所欲為了,你對它進行的運算不支持原子性的話,也不能實現其原子效果。一般針對++,–,+=,-=,&=,|=,^=是支持的,這些原子操作是通過在std::atomic<>對象內部進行運算符重載實現的。

3 代碼實例

3.1 生產者消費者問題
生產者-消費者模型是經典的多線程并發協作模型。生產者用于生產數據,生產一個就往共享數據區存一個,如果共享數據區已滿的話,生產者就暫停生產;消費者用于消費數據,一個一個的從共享數據區取,如果共享數據區為空的話,消費者就暫停取數據,且生產者與消費者不能直接交互。

/*
生產者消費者問題
*/
#include <iostream>
#include <deque>
#include <thread>
#include <mutex>
#include <condition_variable>
#include<Windows.h>
using namespace std;

deque<int> q;
mutex mu;
condition_variable cond;
int c = 0;//緩沖區的產品個數

void producer() { 
 int data1;
 while (1) {//通過外層循環,能保證生產不停止
  if(c < 3) {//限流
   {
    data1 = rand();
    unique_lock<mutex> locker(mu);//鎖
    q.push_front(data1);
    cout << "存了" << data1 << endl;
    cond.notify_one();  // 通知取
    ++c;
   }
   Sleep(500);
  }
 }
}

void consumer() {
 int data2;//data用來覆蓋存放取的數據
 while (1) {
  {
   unique_lock<mutex> locker(mu);
   while(q.empty())
    cond.wait(locker); //wait()阻塞前先會解鎖,解鎖后生產者才能獲得鎖來放產品到緩沖區;生產者notify后,將不再阻塞,且自動又獲得了鎖。
   data2 = q.back();//取的第一步
   q.pop_back();//取的第二步
   cout << "取了" << data2<<endl;
   --c;
  }
  Sleep(1500);
 }
}
int main() {
 thread t1(producer);
 thread t2(consumer);
 t1.join();
 t2.join();
 return 0;
}

4 C++多線程并發高級知識

4.1 線程池

4.1.1 線程池基礎知識

不采用線程池時:

創建線程 -> 由該線程執行任務 -> 任務執行完畢后銷毀線程。即使需要使用到大量線程,每個線程都要按照這個流程來創建、執行與銷毀。

雖然創建與銷毀線程消耗的時間 遠小于 線程執行的時間,但是對于需要頻繁創建大量線程的任務,創建與銷毀線程 所占用的時間與CPU資源也會有很大占比。

為了減少創建與銷毀線程所帶來的時間消耗與資源消耗,因此采用線程池的策略:

程序啟動后,預先創建一定數量的線程放入空閑隊列中,這些線程都是處于阻塞狀態,基本不消耗CPU,只占用較小的內存空間。

接收到任務后,任務被掛在任務隊列,線程池選擇一個空閑線程來執行此任務。

任務執行完畢后,不銷毀線程,線程繼續保持在池中等待下一次的任務。

線程池所解決的問題:

(1) 需要頻繁創建與銷毀大量線程的情況下,由于線程預先就創建好了,接到任務就能馬上從線程池中調用線程來處理任務,減少了創建與銷毀線程帶來的時間開銷和CPU資源占用。

(2) 需要并發的任務很多時候,無法為每個任務指定一個線程(線程不夠分),使用線程池可以將提交的任務掛在任務隊列上,等到池中有空閑線程時就可以為該任務指定線程。

4.1.2 線程池的實現

可以通過閱讀 《C++ Concurrency in Action, Second Edition》 9.1章節來學習。線程池確實是難點部分,所以先拖著不更,等把別的章節完善了,再來更新這部分。, 本文的線程池實現的內容將會在《C++11 STL基礎入門教程》完善后再來更新。

學習交流群整理了一些最新LinuxC/C++服務器開發/架構師面試題、學習資料、教學視頻和學習路線腦圖(資料包括C/C++,Linux,golang技術,Nginx,ZeroMQ,MySQL,Redis,fastdfs,MongoDB,ZK,流媒體,CDN,P2P,K8S,Docker,TCP/IP,協程,DPDK,ffmpeg等),免費分享有需要的可以自行添加

image.png

?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容