C++多線程併發總結

1. 線程創建與管理

1.1 併發與並行

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

1.2 多線程併發與多進程併發

  • 進程:資源分配的基本單位,也是程序運行的單位。用戶運行自己的程序,系統就創建一個進程,併爲它分配資源,包括各種表格、內存空間、磁盤空間、I/O設備等。然後,把該進程放人進程的就緒隊列。進程調度程序選中它,爲它分配CPU以及其它有關資源,該進程才真正運行。所以,進程是系統中的併發執行的單位。
  • 線程:執行處理器調度的基本單位。一個進程由一個或多個線程構成,各線程共享相同的代碼和全局數據,但各有其自己的堆棧。由於堆棧是每個線程一個,所以局部變量對每一線程來說是私有的。由於所有線程共享同樣的代碼和全局數據,它們比進程更緊密,比單獨的進程間更趨向於相互作用,線程間的相互作用更容易些,因爲它們本身就有某些供通信用的共享內存:進程的全局數據。

多進程併發編程與多線程併發編程的區別主要在有沒有共享數據,多進程間的通信較複雜且代價較大,主要的進程間通信渠道有管道、信號、文件、套接字等。由於C++沒有提供進程間通信的原生支持,後續主要介紹多線程併發編程,和多線程間的同步與通信。

爲了解決平臺相關多線程API使用上的問題,逐漸開發出了Boost、ACE等平臺無關的多線程支持類庫。直到C++11標準的發佈,借鑑了很多Boost類庫的經驗,將多線程支持納入C++標準庫。C++11標準不僅提供了一個全新的線程感知內存模型,也包含了用於管理線程、保護共享數據、線程間同步操作以及低級原子操作的各個類。

2. C++線程創建

C++11新標準多線程支持庫

  1. < thread > : 提供線程創建及管理的函數或類接口;
  2. < mutex > : 爲線程提供獲得獨佔式資源訪問能力的互斥算法,保證多個線程對共享資源的同步訪問;
  3. < condition_variable > : 允許一定量的線程等待(可以定時)被另一線程喚醒,然後再繼續執行;
  4. < future > : 提供了一些工具來獲取異步任務(即在單獨的線程中啓動的函數)的返回值,並捕捉其所拋出的異常;
  5. < atomic > : 爲細粒度的原子操作(不能被處理器拆分處理的操作)提供組件,允許無鎖併發編程。

2.1 std::thread

std::thread 用於創建一個執行的線程實例,所以它是一切併發編程的基礎,使用時需要包含頭文件,它提供了很多基本的線程操作,例如get_id()來獲取所創建線程的線程 ID,例如使用 join() 來加入一個線程等等,例如:

#include <iostream>
#include <thread>
void foo() {
    std::cout << "hello world" << std::endl;
}
int main() {
    std::thread t(foo);
    t.join();
    return 0;
}

線程創建和管理的函數或類主要由< thread >庫文件來提供,該庫文件的主要操作如下:
image
由上表可知,通過std::thread t(f, args…)創建線程,可以給線程函數傳遞參數。通過join()函數關聯並阻塞線程,等待該線程執行完畢後繼續;通過detach()函數解除關聯使線程可以與主線程併發執行,但若主線程執行完畢退出後,detach()接觸關聯的線程即便沒有執行完畢,也將自動退出,有時可能這並非我們預期的結果,所以需要特別注意。

爲了便於觀察併發過程,對三個線程均用了睡眠延時this_thread::sleep_for(duration)函數,且延時時間作爲參數傳遞給該函數。這裏的參數是支持C++泛型模板的,STL標準容器類型(比如Array/Vector/Deque/List/Set/Map/String等)都可以作爲參數傳遞,但這裏的參數默認是以拷貝的方式傳遞參數的,當期望傳入一個引用時,要使用std::ref進行轉換。
image

線程同步之互斥鎖(std::mutex, std::unique_lock)

據競爭源於併發修改同一數據結構,那麼最簡單的處理數據競爭的方法就是對該數據結構採用某種保護機制,確保只有進行修改的線程才能看到數據被修改的中間狀態,從其他訪問線程的角度看,修改不是已經完成就是還未開始。C++標準庫提供了很多類似的機制,最基本的就是互斥量,有一個< mutex >庫文件專門支持對共享數據結構的互斥訪問。

lock與unlock保護共享資源

Mutex全名mutual exclusion(互斥體),是個object對象,用來協助採取獨佔排他方式控制對資源的併發訪問。這裏的資源可能是個對象,或多個對象的組合。爲了獲得獨佔式的資源訪問能力,相應的線程必須鎖定(lock) mutex,這樣可以防止其他線程也鎖定mutex,直到第一個線程解鎖(unlock) mutex。

lock_guard與unique_lock保護共享資源

lock與unlock必須成對合理配合使用,使用不當可能會造成資源被永遠鎖住,甚至出現死鎖(兩個線程在釋放它們自己的lock之前彼此等待對方的lock)。是不是想起了C++另一對兒需要配合使用的對象new與delete,若使用不當可能會造成內存泄漏等嚴重問題,爲此C++引入了智能指針shared_ptr與unique_ptr。智能指針借用了RAII技術(Resource Acquisition Is Initialization—使用類來封裝資源的分配和初始化,在構造函數中完成資源的分配和初始化,在析構函數中完成資源的清理,可以保證正確的初始化和資源釋放)對普通指針進行封裝,達到智能管理動態內存釋放的效果。同樣的,C++也針對lock與unlock引入了智能鎖lock_guard與unique_lock,同樣使用了RAII技術對普通鎖進行封裝,達到智能管理互斥鎖資源釋放的效果。lock_guard與unique_lock的區別如下:

unique_lock功能豐富靈活得多。如果需要實現更復雜的鎖策略可以用unique_lock,如果只需要基本的鎖功能,優先使用更嚴格高效的lock_guard。兩種鎖的簡單概述與策略對比見下表:

類模板 描述 策略
std::lock_guard 嚴格基於作用域(scope-based)的鎖管理類模板,構造時是否加鎖是可選的(不加鎖時假定當前線程已經獲得鎖的所有權—使用std::adopt_lock策略),析構時自動釋放鎖,所有權不可轉移,對象生存期內不允許手動加鎖和釋放鎖 std::adopt_lock
std::unique_lock 更加靈活的鎖管理類模板,構造時是否加鎖是可選的,在對象析構時如果持有鎖會自動釋放鎖,所有權可以轉移。對象生命期內允許手動加鎖和釋放鎖 std::adopt_lock std::defer_lock std::try_to_lock

timed_mutex與recursive_mutex提供更強大的鎖

在某些特殊情況下,我們需要更復雜的功能,比如某個線程中函數的嵌套調用可能帶來對某共享資源的嵌套鎖定需求,mutex在一個線程中卻只能鎖定一次;再比如我們想獲得一個鎖,但不想一直阻塞,只想等待特定長度的時間,mutex也沒提供可設定時間的鎖。針對這些特殊需求,< mutex >庫也提供了下面幾種功能更豐富的互斥類

  • std::mutex 同一時間只可被一個線程鎖定。如果它被鎖住,任何其他lock()都會阻塞(block),直到這個mutex再次可用,且try_lock()會失敗。
  • std::recursive_mutex 允許在同一時間多次被同一線程獲得其lock。其典型應用是:函數捕獲一個lock並調用另一函數而後者再次捕獲相同的lock。
  • std::timed_mutex 額外允許你傳遞一個時間段或時間點,用來定義多長時間內它可以嘗試捕獲一個lock。爲此它提供了try_lock_for(duration)和try_lock_until(timepoint)。
  • std::recursive_timed_mutex 允許同一線程多次取得其lock,且可指定期限。

線程同步之條件變量(std::condition_variable)

多線程併發訪問共享數據時遇到的數據競爭問題,我們通過互斥鎖保護共享數據,保證多線程對共享數據的訪問同步有序。但如果一個線程需要等待一個互斥鎖的釋放,該線程通常需要輪詢該互斥鎖是否已被釋放,我們也很難找到適當的輪訓週期,如果輪詢週期太短則太浪費CPU資源,如果輪詢週期太長則可能互斥鎖已被釋放而該線程還在睡眠導致發生延誤。

引入了條件變量來解決該問題:條件變量使用“通知—喚醒”模型,生產者生產出一個數據後通知消費者使用,消費者在未接到通知前處於休眠狀態節約CPU資源;當消費者收到通知後,趕緊從休眠狀態被喚醒來處理數據,使用了事件驅動模型,在保證不誤事兒的情況下儘可能減少無用功降低對資源的消耗。

C++標準庫在< condition_variable >中提供了條件變量,藉由它,一個線程可以喚醒一個或多個其他等待中的線程。原則上,條件變量的運作如下:

  • 你必須同時包含< mutex >和< condition_variable >,並聲明一個mutex和一個condition_variable變量;
  • 那個通知“條件已滿足”的線程(或多個線程之一)必須調用notify_one()或notify_all(),以便條件滿足時喚醒處於等待中的一個條件變量;
  • 那個等待"條件被滿足"的線程必須調用wait(),可以讓線程在條件未被滿足時陷入休眠狀態,當接收到通知時被喚醒去處理相應的任務;

cond.wait(locker)換一種寫法,wait()的第二個參數可以傳入一個函數表示檢查條件,這裏使用lambda函數最爲簡單,如果這個函數返回的是true,wait()函數不會阻塞會直接返回,如果這個函數返回的是false,wait()函數就會阻塞着等待喚醒,如果被僞喚醒,會繼續判斷函數返回值。
image
值得注意的是:

所有通知(notification)都會被自動同步化,所以併發調用notify_one()和notify_all()不會帶來麻煩;
所有等待某個條件變量(condition variable)的線程都必須使用相同的mutex,當wait()家族的某個成員被調用時該mutex必須被unique_lock鎖定,否則會發生不明確的行爲;
wait()函數會執行“解鎖互斥量–>陷入休眠等待–>被通知喚醒–>再次鎖定互斥量–>檢查條件判斷式是否爲真”幾個步驟,這意味着傳給wait函數的判斷式總是在鎖定情況下被調用的,可以安全的處理受互斥量保護的對象;但在"解鎖互斥量–>陷入休眠等待"過程之間產生的通知(notification)會被遺失。

下面是一個生產者和消費者模型的例子:

#include <condition_variable>
#include <mutex>
#include <thread>
#include <iostream>
#include <queue>
#include <chrono>
 
int main()
{
    // 生產者數量
    std::queue<int> produced_nums;
    // 互斥鎖
    std::mutex m;
    // 條件變量
    std::condition_variable cond_var;
    // 結束標誌
    bool done = false;
    // 通知標誌
    bool notified = false;
 
    // 生產者線程
    std::thread producer([&]() {
        for (int i = 0; i < 5; ++i) {
            std::this_thread::sleep_for(std::chrono::seconds(1));
            // 創建互斥鎖
            std::unique_lock<std::mutex> lock(m);
            std::cout << "producing " << i << '\n';
            produced_nums.push(i);
            notified = true;
            // 通知一個線程
            cond_var.notify_one();
        }   
        done = true;
        cond_var.notify_one();
    }); 
 
    // 消費者線程
    std::thread consumer([&]() {
        std::unique_lock<std::mutex> lock(m);
        while (!done) {
            while (!notified) {  // 循環避免虛假喚醒
                cond_var.wait(lock);
            }   
            while (!produced_nums.empty()) {
                std::cout << "consuming " << produced_nums.front() << '\n';
                produced_nums.pop();
            }   
            notified = false;
        }   
    }); 
 
    producer.join();
    consumer.join();
}

異步編程

同步與異步的區別:

同步:就是在發出一個調用時,在沒有得到結果之前,該調用就不返回。但是一旦調用返回,就得到返回值了。換句話說,就是由調用者主動等待這個調用的結果。
異步:調用在發出之後,這個調用就直接返回了,所以沒有返回結果。換句話說,當一個異步過程調用發出後,調用者不會立刻得到結果。而是在調用發出後,被調用者通過狀態、通知來通知調用者,或通過回調函數處理這個調用。
image

使用promise與future傳遞結果

< future >頭文件功能允許對特定提供者設置的值進行異步訪問,可能在不同的線程中。
這些提供程序(要麼是promise 對象,要麼是packaged_task對象,或者是對異步的調用async)與future對象共享共享狀態:提供者使共享狀態就緒的點與future對象訪問共享狀態的點同步

注意前面提到的共享狀態,多線程間傳遞的返回值或拋出的異常都是在共享狀態中交流的。我們知道多線程間併發訪問共享數據是需要保持同步的,這裏的共享狀態是保證返回值或異常在線程間正確傳遞的關鍵,被調用線程可以通過改變共享狀態通知調用線程返回值或異常已寫入完畢,可以訪問或操作了。future的狀態(future_status)有以下三種:

  • deferred:異步操作還沒開始;
  • ready:異步操作已經完成;
  • timeout:異步操作超時。

使用packaged_task與future傳遞結果

在 C++11 的 std::future 被引入之前,通常的做法是:創建一個線程A,在線程A裏啓動任務 B,當準備完畢後發送一個事件,並將結果保存在全局變量中。而主函數線程 A 里正在做其他的事情,當需要結果的時候,調用一個線程等待函數來獲得執行的結果。

而 C++11 提供的 std::future 簡化了這個流程,可以用來獲取異步任務的結果。自然地,我們很容易能夠想象到把它作爲一種簡單的線程同步手段。

此外,std::packaged_task 可以用來封裝任何可以調用的目標,從而用於實現異步的調用。例如:

#include <iostream>
#include <future>
#include <thread>
 
int main() {
    // 將一個返回值爲7的 lambda 表達式封裝到 task 中
    // std::packaged_task 的模板參數爲要封裝函數的類型
    std::packaged_task<int()> task([](){return 7;});
    // 獲得 task 的 future
    std::future<int> result = task.get_future();    // 在一個線程中執行 task
    std::thread(std::move(task)).detach();    std::cout << "Waiting...";
    result.wait();
    // 輸出執行結果
    std::cout << "Done!" << std:: endl << "Result is " << result.get() << '\n';
}

在封裝好要調用的目標後,可以使用 get_future() 來獲得一個 std::future 對象,以便之後事實線程同步。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章