std::lock_guard引起的思考:何時加鎖?何時解鎖?

從哪裏來的思考?

最近在項目總結過程中,發現項目大量使用了 std::lock_guard 這個模板類,仔細分析後發現這個類牽扯到了很多重要的計算機基礎,例如:多線程,互斥,鎖等等,這裏便記錄下來,也算是一次簡單的總結。

std::lock_guard 簡介

這個類是一個互斥量的包裝類,用來提供自動爲互斥量上鎖和解鎖的功能,簡化了多線程編程,用法如下:

#include <mutex>

std::mutex kMutex;

void function() {
  // 構造時自動加鎖
  std::lock_guard<std::mutex> (kMutex);
  
  // 離開局部作用域,析構函數自動完成解鎖功能
}

用法非常簡單,只需在保證線程安全的函數開始處加上一行代碼即可,其他的都在這個類的構造函數和析構函數中自動完成。

如何自動完成?其實 Just so so ...

實現 my_lock_guard

這是自己實現的一個 lock_guard,就是在構造和析構中完成加鎖和解鎖的操作,之所以會自動完成,是因爲離開函數作用域會導致局部變量析構函數被調用,而我們又是手動構造了 lock_guard,因此這兩個函數都是自動被調用的。

namespace myspace {
    template<typename T> class my_lock_guard {
    public:
        // 在 std::mutex 的定義中,下面兩個函數被刪除了
        // mutex(const mutex&) = delete;
        // mutex& operator=(const mutex&) = delete;
        // 因此這裏必須傳遞引用
        my_lock_guard(T& mutex) :mutex_(mutex){
            // 構造加鎖
            mutex_.lock();
        }

        ~my_lock_guard() {
            // 析構解鎖
            mutex_.unlock();
        }
    private:
        // 不可賦值,不可拷貝
        my_lock_guard(my_lock_guard const&);
        my_lock_guard& operator=(my_lock_guard const&);
    private:
        T& mutex_;
    };

};

要注意的是這個類官方定義是不可以賦值和拷貝,因此需要私有化 operator =copy 這兩個函數。

什麼是 std::mutex ?

如果你細心可以發現,不管是 std::lock_guard,還是my_lock_guard,都使用了一個 std::mutex 作爲構造函數的參數,這是因爲我們的 lock_guard 只是一個包裝類,而實際的加鎖和解鎖的操作都還是 std::mutex 完成的,那什麼是 std::mutex 呢?

std::mutex 其實是一個用於保護共享數據不會同時被多個線程訪問的類,它叫做互斥量,你可以把它看作一把鎖,它的基本使用方法如下:

#include <mutex>

std::mutex kMutex;

void function() {
  //加鎖
  kMutex.lock();
  //kMutex.try_lock();

  //do something that is thread safe...
  
  // 離開作用域解鎖
  kMutex.unlock();
}

前面都提到了這個概念,那麼什麼是鎖,有啥用處?

什麼是鎖?

鎖是用來保護共享資源(變量或者代碼)不被併發訪問的一種方法,它只是方法,實際的實現就是 std::mutex 等等的類了。

可以簡單的理解爲:

  1. 當前線程訪問一個變量之前,將這個變量放到盒子裏鎖住,並且當前線程拿着鑰匙。這樣一來,如果有其他的線程也要訪問這個變量,則必須等待當前線程將盒子解鎖之後才能訪問,之後其他線程在訪問這個變量之前也將會再次鎖住這個變量。

  2. 當前線程執行完後,就將該盒子解鎖,這樣其他的線程就可以拿到盒子的鑰匙,並再次加鎖訪問這個變量了。

這樣就保證了同一時刻只有一個線程可以訪問共享資源,解決了簡單的線程安全問題。

什麼,你還沒有遇到過線程安全問題?下面開始我的表演...

一個簡單的線程安全的例子

這個例子中,主線程開啓了 2 個子線程,每個子線程都修改共享的全局變量 kData,如果沒有增加必要的鎖機制,那麼每個子線程打印出的 kData 就可能會出錯。

這裏使用了 3 種不同的加鎖方法來解決:

  1. 使用 std::lock_guard
  2. 使用 std::mutex 實現原生的加鎖
  3. 使用自己的 myspace::my_lock_guard
#include <iostream>
#include <mutex>
#include <thread>

// 兩個子線程共享的全局變量
int kData = 0;

// std::mutex 提供了一種防止共享數據被多個線程併發訪問的簡單同步方法
// 調用線程可以通過 lock 和 try_lock 來獲取互斥量,使用 unlock() 釋放互斥量
std::mutex kMutex;


void increment() {
    // 1.創建一個互斥量的包裝類,用來自動管理互斥量的獲取和釋放
    // std::lock_guard<std::mutex> lock(kMutex);
    
    // 2.原生加鎖
    // kMutex.lock();

    // 3.自己實現的 std::mutex 的包裝類
    myspace::my_lock_guard<std::mutex> lock(kMutex);
    
    for (int i = 0; i < 10; i++) {
        // 打印當前線程的 id : kData
        std::cout << std::this_thread::get_id() 
                  << ":" << kData++ << std::endl;
    }
    
    // 2. 原生解鎖  
    //kMutex.unlock();
    
    // 離開局部作用域,局部鎖解鎖,釋放互斥量
    
}


int main()
{
    // 打印當前函數名
    std::cout << __FUNCTION__ << ":" << kData << std::endl;

    // 開啓兩個線程
    std::thread t1(increment);
    std::thread t2(increment);

    // 主線程等待這兩個線程完成操作之後再退出
    t1.join();
    t2.join();

    // 防止立刻退出
    getchar();
    return 0;

}

注意:在 vs 中編譯這段代碼。

結果分析

爲什麼不加鎖的結果會出錯?

首先線程是一種輕量級的進程,也存在調度,假設當前 CPU 使用的是基於時間片的輪轉調度算法,爲每個進程分配一段可執行的時間片,因此每個線程都得到一段可以執行的時間(這裏只是簡單概括,仔細研究其實是有點複雜的,涉及到內核線程和用戶線程,這裏就不多說了,不是這裏討論的重點),這就導致子線程 1 在修改並打印 kData 的時候,子線程 1 的時間片用完了,CPU 切換到子線程 2 去修改並打印 kData,這就導致了最終的打印結果不是預先的順序,就是這個原理,簡單的理解是不難的。



作者:程序小歌
鏈接:https://www.jianshu.com/p/681f553fa4ab
來源:簡書
著作權歸作者所有。商業轉載請聯繫作者獲得授權,非商業轉載請註明出處。

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