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

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

摘要

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

  1. 程式和執行緒的區別
  2. 何為併發?C++中如何解決併發問題?C++中多執行緒的基本操作 淺談C++11中的多執行緒(一) - 唯有自己強大 - 部落格園 (cnblogs.com)
  3. 同步互斥原理以及如何處理資料競爭 淺談C++11中的多執行緒(二) - 唯有自己強大 - 部落格園 (cnblogs.com)
  4. 條件變數和原子操作
  5. Qt中的多執行緒應用

條件變數

一、何為條件變數

在前一篇文章淺談C++11中的多執行緒(二) - 唯有自己強大 - 部落格園 (cnblogs.com)中解釋了執行緒同步的原理和實現,使用互斥鎖解決資料競爭訪問問題。我們在使用mutex時,一般都會期望加鎖不要阻塞,總是能立刻拿到鎖,然後儘快訪問資料,用完之後儘快解鎖,這樣才能不影響併發性和效能。

如果需要等待某個條件的成立,我們就該使用條件變數(condition variable)了,那什麼是條件變數呢?

C++11提供了condition_variable類。使用時需要include標頭檔案<condition_variable>。

簡單理解來說:如果把變數區看成是一座房子,那麼前面兩篇頻繁用到的mutex可以看成是房門的鎖,正常來說是房門常年開啟的,鎖並用不上。但是有了多執行緒以後,為了防止多個執行緒一窩蜂胡亂篡改裡面的資料,所以就有了鎖的概念。

現在假設每個執行緒都有一個管理鎖的人,叫lock_guard,或者unique_lock,但是一次只能有一個人能夠去操作鎖(鎖上或者是解鎖)。一般來說他們是輪流去操作鎖。而condition_variable則可以看做是門童,如果沒有滿足條件,門童就會通知執行緒的管鎖人必須要休眠而不可以操作鎖,可是一旦條件滿足,他就會喚醒某些執行緒的管鎖人可以去操作鎖了。

二,為何要用條件變數

下面給出一個簡單的程式示例:一個執行緒往佇列中放入資料,一個執行緒從佇列中提取資料,取資料前需要判斷一下佇列中確實有資料,由於這個佇列是執行緒間共享的,所以,需要使用互斥鎖進行保護,一個執行緒在往佇列新增資料的時候,另一個執行緒不能取,反之亦然。程式實現程式碼如下:

//cond_var1.cpp用互斥鎖實現一個生產者消費者模型

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

std::deque<int> q;                        //雙端佇列標準容器全域性變數
std::mutex mu;                            //互斥鎖全域性變數
//生產者,往佇列放入資料
void function_1() {
    int count = 10;
    while (count > 0) {
        std::unique_lock<std::mutex> locker(mu);
        q.push_front(count);            //資料入隊鎖保護
        locker.unlock();
        std::this_thread::sleep_for(std::chrono::seconds(1));        //延時1秒
        count--;
    }
}
//消費者,從佇列提取資料
void function_2() {
    int data = 0;
    while ( data != 1) {
        std::unique_lock<std::mutex> locker(mu);
        if (!q.empty()) {            //判斷佇列是否為空
            data = q.back();
            q.pop_back();            //資料出隊鎖保護
            locker.unlock();
            std::cout << "t2 got a value from t1: " << data << std::endl;
        } else {
            locker.unlock();
        }
    }
}

int main() {
    std::thread t1(function_1);
    std::thread t2(function_2);
    t1.join();
    t2.join();

    getchar();
    return 0;
}

從程式碼中不難看出:在生產過程中,因每放入一個資料有1秒延時,所以這個生產的過程是很慢的;在消費過程中,存在著一個while迴圈,只有在接收到表示結束的資料的時候,才會停止,每次迴圈內部,都是先加鎖,判斷佇列不空,然後就取出一個數,最後解鎖。所以說,在1s內,做了很多無用功!這樣的話,CPU佔用率會很高,可能達到100%(單核)。

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

三,如何使用條件變數

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

  • 你必須同時包含< mutex >和< condition_variable >,並宣告一個mutex和一個condition_variable變數;
  • 那個通知“條件已滿足”的執行緒(或多個執行緒之一)必須呼叫notify_one()或notify_all(),以便條件滿足時喚醒處於等待中的一個條件變數;
  • 那個等待"條件被滿足"的執行緒必須呼叫wait(),可以讓執行緒在條件未被滿足時陷入休眠狀態,當接收到通知時被喚醒去處理相應的任務;
//cond_var2.cpp用條件變數解決輪詢間隔難題

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

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();              // 向一個等待執行緒發出“條件已滿足”的通知

        std::this_thread::sleep_for(std::chrono::seconds(1));        //延時1秒
        count--;
    }
}
//消費者,從佇列提取資料
void function_2() {
    int data = 0;
    while (data != 1) {
        std::unique_lock<std::mutex> locker(mu);

        while (q.empty())        //判斷佇列是否為空
            cond.wait(locker); // 解鎖互斥量並陷入休眠以等待通知被喚醒,被喚醒後加鎖以保護共享資料

        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();

    getchar();
    return 0;
}

上面的程式碼有四個注意事項:

  1. 在function_2中,在判斷佇列是否為空的時候,使用的是while(q.empty()),而不是if(q.empty()),因為wait的喚醒可能由於系統的原因被喚醒,這個的時機是不確定的。這個過程也被稱作偽喚醒。如果在錯誤的時候被喚醒了,執行後面的語句就會錯誤,所以需要再次判斷佇列是否為空,如果還是為空,就繼續wait()阻塞;
  2. 在管理互斥鎖的時候,使用的是std::unique_lock而不是std::lock_guard,而且事實上也不能使用std::lock_guard。這需要先解釋下wait()函式所做的事情,可以看到,在wait()函式之前,使用互斥鎖保護了,如果wait的時候什麼都沒做,豈不是一直持有互斥鎖?那生產者也會一直卡住,不能夠將資料放入佇列中了。所以,wait()函式會先呼叫互斥鎖的unlock()函式,然後再將自己睡眠,在被喚醒後,又會繼續持有鎖,保護後面的佇列操作。lock_guard沒有lock和unlock介面,而unique_lock提供了,這就是必須使用unique_lock的原因;
  3. 使用細粒度鎖,儘量減小鎖的範圍,在notify_one()的時候,不需要處於互斥鎖的保護範圍內,所以在喚醒條件變數之前可以將鎖unlock()。
  4. cv.notify_one()指的是通知其中某一個執行緒,cv.notify_all()指的是通知全部執行緒。

下面給出條件變數支援的操作函式表:

  值得注意的是:

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

 原子操作

 一、何為原子操作(atomic)

所謂的原子操作,取的就是“原子是最小的、不可分割的最小個體”的意義,它表示在多個執行緒訪問同一個全域性資源的時候,能夠確保所有其他的執行緒都不在同一時間內訪問相同的資源。也就是他確保了在同一時刻只有唯一的執行緒對這個資源進行訪問。這有點類似互斥物件對共享資源的訪問的保護,但是原子操作更加接近底層,因而效率更高。

在新標準C++11,引入了原子操作的概念,並通過這個新的標頭檔案提供了多種原子運算元據型別,例如,atomic_bool,atomic_int等等,如果我們在多個執行緒中對這些型別的共享資源進行操作,編譯器將保證這些操作都是原子性的,也就是說,確保任意時刻只有一個執行緒對這個資源進行訪問,編譯器將保證,多個執行緒訪問這個共享資源的正確性。從而避免了鎖的使用,提高了效率。

二、atomic高效體現

使用atomic可以避免使用鎖,而且更加底層,比mutex效率更高。為了方便使用,c++11為模版函式提供了別名(即原子型別)。

 ?我們先來看一個例子:(加鎖不使用atomic)

#include <iostream>
#include <ctime>
#include <mutex>
#include <vector>
#include <thread>

using namespace std;
mutex mtx;
size_t Count = 0;

void threadFun()
{
    for (int i = 0; i < 10000; i++)
    {
        // 上鎖(防止多個執行緒同時訪問同一資源)
        unique_lock<mutex> lock(mtx);
        Count++;
    }
}

int main(void)
{
    clock_t start_time = clock();

    // 啟動多個執行緒
    vector<thread> threads;
    for (int i = 0; i < 10; i++)
        threads.push_back(thread(threadFun));
    for (auto& thad : threads)
        thad.join();

    // 檢測count是否正確 10000*10 = 100000
    cout << "count number:" << Count << endl;

    clock_t end_time = clock();
    std::cout << "耗時:" << end_time - start_time << "ms" << std::endl;
    return 0;
}

輸出結果:

 ?使用atomic:

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

using namespace std;
atomic<size_t> Count(0);//建立原子型別,將Cout初始化為0

void threadFun()
{
    for (int i = 0; i < 10000; i++)
        Count++;
}

int main(void)
{
    clock_t start_time = clock();

    // 啟動多個執行緒
    vector<thread> threads;
    for (int i = 0; i < 10; i++)
        threads.push_back(thread(threadFun));
    for (auto& thad : threads)
        thad.join();

    // 檢測count是否正確 10000*10 = 100000
    cout << "count number:" << Count << endl;

    clock_t end_time = clock();
    cout << "耗時:" << end_time - start_time << "ms" <<endl;

    return 0;
}

 總結:從上面的截圖可以發現,第一張圖用時33ms,第二張圖用時19ms,使用原子操作能提高程式的執行效率。

三,原子操作中的記憶體訪問模型

原子操作保證了對資料的訪問只有未開始和已完成兩種狀態,不會訪問到中間狀態,但我們訪問資料一般是需要特定順序的,比如想讀取寫入後的最新資料,原子操作函式是支援控制讀寫順序的,即帶有一個資料同步記憶體模型引數std::memory_order,用於對同一時間的讀寫操作進行排序。C++11定義的6種型別如下:

typedef enum memory_order {
    memory_order_relaxed,    // 不對執行順序做保證
    memory_order_acquire,    // 本執行緒中,所有後續的讀操作必須在本條原子操作完成後執行
    memory_order_release,    // 本執行緒中,所有之前的寫操作完成後才能執行本條原子操作
    memory_order_acq_rel,    // 同時包含 memory_order_acquire 和 memory_order_release
    memory_order_consume,    // 本執行緒中,所有後續的有關本原子型別的操作,必須在本條原子操作完成之後執行
    memory_order_seq_cst    // 全部存取都按順序執行
    } memory_order;

記憶體訪問模型屬於比較底層的控制介面,如果對編譯原理和CPU指令執行過程不瞭解的話,容易引入bug。記憶體模型不是本章重點,這裡不再展開介紹,後續的程式碼都使用預設的順序一致性模型或比較穩妥的Release-Acquire模型,如果想了解更多,可以參考連結:std::memory_order - cppreference.com

 四,使用原子型別實現自旋鎖

自旋鎖(spinlock)與互斥鎖(mutex)類似,在任一時刻最多隻能有一個持有者,但如果資源已被佔用,互斥鎖會讓資源申請者進入睡眠狀態,而自旋鎖不會引起呼叫者睡眠,會一直迴圈判斷該鎖是否成功獲取。

自旋鎖是專為防止多處理器併發而引入的一種鎖,它在核心中大量應用於中斷處理等部分。對於多核處理器來說,檢測到鎖可用與設定鎖狀態兩個動作需要實現為一個原子操作。
標準庫還專門提供了一個原子布林型別std::atomic_flag,不同於所有 std::atomic 的特化,它保證是免鎖的,不提供load()與store(val)操作,但提供了test_and_set()與clear()操作:

  1. test_and_set,如果atomic_flag 物件已經被設定了,就返回True,如果未被設定,就設定之然後返回False(等價於上鎖)
  2. clear,把atomic_flag物件清掉(等價於解鎖)

注意這個所謂atomic_flag物件其實就是當前的執行緒。可用std::atomic_flag實現自旋鎖的功能,程式碼如下:

//atomic2.cpp 使用原子布林型別實現自旋鎖的功能

#include <thread>
#include <vector>
#include <iostream>
#include <atomic>
using namespace std;
atomic_flag lock = ATOMIC_FLAG_INIT;       //初始化原子布林型別

void f(int n)
{
    for (int cnt = 0; cnt < 10; ++cnt) {
        while (lock.test_and_set(memory_order_acquire));// 獲得鎖(自旋)
        cout << n << " thread Output: " << cnt << '\n';
        lock.clear(memory_order_release);               // 釋放鎖
    }
}

int main()
{
    vector<thread> v;     //例項化一個元素型別為thread的向量
    for (int n = 0; n < 10; ++n) {
        v.emplace_back(f, n);       //以引數(f,n)為初值的元素放到向量末尾,相當於啟動新執行緒f(n)
    }
    for (auto& t : v) {     //遍歷向量v中的元素,基於範圍的for迴圈,auto&自動推導變數型別並引用指標指向的內容
        t.join();           //阻塞主執行緒直至子執行緒執行完畢
    }

    getchar();
    return 0;
}

 

參考博文:(2條訊息) C++多執行緒併發(五)---原子操作與無鎖程式設計_流雲-CSDN部落格_c++原子操作

                  (2條訊息) C++多執行緒併發(三)---執行緒同步之條件變數_流雲-CSDN部落格_c++ 條件變數

相關文章