std::lock_guard 引起的思考
版權聲明:本文為 cheng-zhi 原創文章,可以隨意轉載,但必須在明確位置注明出處!
從哪里來的思考?
最近在項目總結過程中,發現項目大量使用了 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
等等的類了。
可以簡單的理解為:
當前線程訪問一個變量之前,將這個變量放到盒子里鎖住,并且當前線程拿著鑰匙。這樣一來,如果有其他的線程也要訪問這個變量,則必須等待當前線程將盒子解鎖之后才能訪問,之后其他線程在訪問這個變量之前也將會再次鎖住這個變量。
當前線程執行完后,就將該盒子解鎖,這樣其他的線程就可以拿到盒子的鑰匙,并再次加鎖訪問這個變量了。
這樣就保證了同一時刻只有一個線程可以訪問共享資源,解決了簡單的線程安全問題。
什么,你還沒有遇到過線程安全問題?下面開始我的表演...
一個簡單的線程安全的例子
這個例子中,主線程開啟了 2 個子線程,每個子線程都修改共享的全局變量 kData
,如果沒有增加必要的鎖機制,那么每個子線程打印出的 kData
就可能會出錯。
這里使用了 3 種不同的加鎖方法來解決:
- 使用
std::lock_guard
- 使用
std::mutex
實現原生的加鎖 - 使用自己的
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
,這就導致了最終的打印結果不是預先的順序,就是這個原理,簡單的理解是不難的。