文章目錄
- 讀數據對數據沒有影響,為什么還需要shared_mutex
- 1. 保證讀取數據的“一致性”和“時效性”
- 2. 協調“讀”與“寫”的競爭關系
- 總結
- 好的,我們來詳細介紹 C++17 中的 `std::shared_mutex`(共享互斥量,俗稱讀寫鎖)的使用。
- 核心概念
- 包含頭文件
- 基本使用步驟
- 1. 定義共享數據和共享互斥量
- 2. 讀操作 - 使用 `std::shared_lock`
- 3. 寫操作 - 使用 `std::unique_lock` 或 `std::lock_guard`
- 完整示例代碼
- 可能輸出及分析:
- 重要注意事項
讀數據對數據沒有影響,為什么還需要shared_mutex
這是一個非常經典且重要的問題。您的直覺是正確的——單純的讀操作本身確實不會改變數據。但關鍵在于,我們使用 std::shared_mutex
(共享鎖/讀寫鎖)的目的,不僅僅是為了防止讀操作“搞破壞”,更是為了保護讀操作自身能獲得一個正確、可靠的結果。
核心原因在于:并發編程的世界里,您不能只考慮一個線程的行為,必須考慮多個線程同時操作同一份數據時可能發生的交互和沖突。
讓我們用一個比喻來理解:
想象一下,您(讀線程)正在閱讀一本非常重要的參考書(數據)。
- 沒有鎖的場景:當您正在閱讀第100頁時,圖書管理員(寫線程)突然過來把第100頁撕掉,換成了新的一頁。您讀到的內容就變成了半句舊話和半句新話的混合體,這顯然是錯誤且無意義的。這就是臟讀(Dirty Read)。
- 使用
std::mutex
的場景:為了保護書的內容,圖書館規定一次只允許一個人進入(獨占鎖)。無論您是去閱讀(讀)還是去修改(寫),都要排隊。這非常安全,但效率極低,因為明明可以允許多個人同時閱讀。 - 使用
std::shared_mutex
的場景:圖書館現在有了新規則:允許多個人同時閱讀(共享鎖),但只要有人需要修改書籍(寫線程申請獨占鎖),就會阻止新的讀者進入,并等待所有現有的讀者離開后,才進行修改。修改完成后,再允許新的讀者進入。這既保證了效率(多人同時讀),又保證了安全(讀的時候書不會變,寫的時候是獨占的)。
從技術角度,主要有以下兩個問題需要解決:
1. 保證讀取數據的“一致性”和“時效性”
即使讀操作不修改數據,它也需要讀到某個特定時間點的、完整一致的數據。
-
問題一:臟讀 (Dirty Read)
- 場景:寫線程B開始修改數據(例如,分兩步更新一個結構體),剛更新到一半。
- 此時:讀線程A來讀取這個數據。它讀到的是一半新、一半舊的中間狀態,這數據是無效的、從未正式存在過的“臟”數據。
- 共享鎖的作用:讀線程A持有共享鎖,會阻止寫線程B獲取獨占鎖。因此,寫操作根本無法開始,讀操作讀到的絕對是寫操作開始前的一致狀態。
-
問題二:讀到一個“正在變化”的值
- 場景:數據可能不是一個簡單的
int
,而是一個需要多條指令才能更新的復雜結構(例如,一個鏈表頭指針)。寫線程的更新操作可能不是原子的。 - 此時:讀線程可能在寫線程更新到一半時介入,讀到錯誤的指針,導致程序崩潰或得到錯誤結果。
- 共享鎖的作用:同樣,共享鎖阻止了寫線程的進入,保證了讀線程看到的對象狀態始終是完整的、穩定的。
- 場景:數據可能不是一個簡單的
2. 協調“讀”與“寫”的競爭關系
這是您問題描述中的關鍵點,也是 std::shared_mutex
設計的精髓。
- 寫操作需要獨占性:寫操作必須保證在它修改數據的那一刻,沒有任何其他線程(無論是讀還是寫)正在訪問它要修改的數據。否則就無法安全地更新。
- 共享鎖是給寫操作看的“信號”:讀線程加共享鎖,本質上是在向寫線程宣告:“我正在讀這份數據,你(寫線程)現在不能寫!”
- 只要有一個讀線程持有共享鎖,寫線程的獨占鎖請求就會被阻塞。
- 多個讀線程的共享鎖不會互相阻塞,但它們“合力”阻塞了寫線程。
- 寫線程持有獨占鎖時,會阻塞所有新的讀線程(共享鎖)和其他寫線程。
總結
所以,回到您的問題:讀數據對數據沒有影響,為什么還需要 shared_mutex
?
- 為了保護讀操作自身:確保它不會讀到寫操作產生的中間狀態或臟數據,從而保證讀取結果的正確性和一致性。
- 為了協調讀與寫的競爭:共享鎖是一個高效的“信號機制”,它允許多個讀操作并行(提升性能),同時強制讀操作和寫操作之間互斥(保證安全)。它實現了您所說的“讀讀共享、讀寫互斥、寫寫互斥”的完美規則。
如果沒有共享鎖,在C++應用程序層面,單純的讀操作在多線程環境下就是“裸奔”,無法保證自己能讀到有意義的數據。std::shared_mutex
就是為讀操作穿上的一件防護服,它不阻止其他讀操作也穿上同樣的防護服(共享),但能有效阻止寫操作(獨占)進來搞破壞。
好的,我們來詳細介紹 C++17 中的 std::shared_mutex
(共享互斥量,俗稱讀寫鎖)的使用。
核心概念
std::shared_mutex
的核心是區分兩種訪問模式,對應兩種鎖:
-
共享模式 (Shared Mode) - 用于“讀”
- 多個線程可以同時獲得共享鎖。
- 當一個或多個線程持有共享鎖時,任何請求獨占鎖的線程都會被阻塞。
- 使用
std::shared_lock
來管理共享鎖。
-
獨占模式 (Exclusive Mode) - 用于“寫”
- 只有一個線程可以獲得獨占鎖。
- 當一線程持有獨占鎖時,任何其他請求共享鎖或獨占鎖的線程都會被阻塞。
- 使用
std::unique_lock
或std::lock_guard
來管理獨占鎖。
包含頭文件
#include <shared_mutex> // 主要頭文件
#include <mutex> // 用于 std::unique_lock, std::lock_guard
#include <map>
#include <string>
#include <thread>
基本使用步驟
1. 定義共享數據和共享互斥量
將你需要保護的數據和對應的 std::shared_mutex
放在一起,通常作為類的私有成員。
class ThreadSafeDNSCache {
private:std::map<std::string, std::string> dns_map_;mutable std::shared_mutex mutex_; // ‘mutable’ 允許在 const 成員函數中加共享鎖
};
2. 讀操作 - 使用 std::shared_lock
對于不會修改數據的操作(如 find
, get
),使用 std::shared_lock
。它會在構造時自動上共享鎖,析構時自動解鎖。
std::string ThreadSafeDNSCache::find_ip(const std::string& domain) const {std::shared_lock<std::shared_mutex> lock(mutex_); // 獲取共享鎖// 注意:這里是 const 成員函數,因為find操作不應修改數據auto it = dns_map_.find(domain);if (it != dns_map_.end()) {return it->second; // 返回時,lock 析構,自動釋放共享鎖}return "Not Found";
}
3. 寫操作 - 使用 std::unique_lock
或 std::lock_guard
對于會修改數據的操作(如 insert
, update
, erase
),使用 std::unique_lock
或 std::lock_guard
。它們會在構造時自動上獨占鎖,析構時自動解鎖。
std::unique_lock
比 std::lock_guard
更靈活(例如可以手動解鎖),但開銷稍大。對于簡單作用域,std::lock_guard
就足夠了。
void ThreadSafeDNSCache::update_or_add(const std::string& domain, const std::string& ip) {std::unique_lock<std::shared_mutex> lock(mutex_); // 獲取獨占鎖dns_map_[domain] = ip;
} // lock 析構,自動釋放獨占鎖void ThreadSafeDNSCache::clear_all() {std::lock_guard<std::shared_mutex> lock(mutex_); // 同樣獲取獨占鎖dns_map_.clear();
}
完整示例代碼
#include <iostream>
#include <map>
#include <string>
#include <shared_mutex>
#include <thread>
#include <chrono>class ThreadSafeDNSCache {
public:std::string find_ip(const std::string& domain) const {// 1. 嘗試獲取共享鎖(讀鎖)std::shared_lock<std::shared_mutex> lock(mutex_);std::cout << "Reading domain: " << domain << std::endl;// 模擬一個耗時較長的讀操作std::this_thread::sleep_for(std::chrono::milliseconds(100));auto it = dns_map_.find(domain);if (it != dns_map_.end()) {std::cout << "Found IP: " << it->second << " for domain: " << domain << std::endl;return it->second;}std::cout << "Domain not found: " << domain << std::endl;return "Not Found";}void update_or_add(const std::string& domain, const std::string& ip) {// 2. 嘗試獲取獨占鎖(寫鎖)std::unique_lock<std::shared_mutex> lock(mutex_);std::cout << "Updating/Adding domain: " << domain << " -> " << ip << std::endl;// 模擬一個耗時較長的寫操作std::this_thread::sleep_for(std::chrono::milliseconds(500));dns_map_[domain] = ip;std::cout << "Finished updating: " << domain << std::endl;}private:mutable std::shared_mutex mutex_;std::map<std::string, std::string> dns_map_;
};int main() {ThreadSafeDNSCache cache;// 啟動多個讀線程和一個寫線程來演示效果std::thread reader1([&cache]() { cache.find_ip("github.com"); });std::thread reader2([&cache]() { cache.find_ip("google.com"); });std::thread writer([&cache]() {std::this_thread::sleep_for(std::chrono::milliseconds(50)); // 稍等一下,讓讀線程先啟動cache.update_or_add("github.com", "140.82.112.4");});std::thread reader3([&cache]() { cache.find_ip("github.com"); }); // 這個讀操作會在寫之后開始reader1.join();reader2.join();writer.join();reader3.join();return 0;
}
可能輸出及分析:
輸出可能會是這樣的(順序可能略有不同):
Reading domain: github.com // 讀者1 立即獲取共享鎖,開始讀
Reading domain: google.com // 讀者2 也立即獲取共享鎖,和讀者1同時讀
// ... 讀者1和2 幾乎同時完成他們的讀操作 ...
Updating/Adding domain: github.com -> 140.82.112.4 // 寫者 等待讀者1和2釋放共享鎖后,獲取獨占鎖,開始寫
Finished updating: github.com // 寫者 完成寫操作,釋放獨占鎖
Reading domain: github.com // 讀者3 在寫者釋放鎖后,獲取共享鎖,開始讀(會讀到新值)
Found IP: 140.82.112.4 for domain: github.com
這個輸出完美展示了:
- 讀讀并行:
reader1
和reader2
同時執行。 - 讀寫互斥:
writer
必須等待所有現有的讀者 (reader1
,reader2
) 結束后才能開始。 - 寫寫互斥:(本例未展示第二個寫者)如果有第二個寫者,它也會被阻塞。
- 寫后讀:
reader3
被writer
阻塞,直到寫操作完成,從而保證了它讀到的是最新值。
重要注意事項
mutable
關鍵字:如果你的“讀”操作是const
成員函數(它應該是),但你又需要在其中修改mutex_
(加鎖解鎖屬于“物理常量性”修改,而非“邏輯常量性”),必須用mutable
修飾mutex_
。- 遞歸使用:
std::shared_mutex
是不可遞歸的。同一個線程試圖在已獲得共享鎖的情況下再獲取獨占鎖(或反之)會導致未定義行為(通常是死鎖)。 - 升級鎖:不能直接將已持有的共享鎖“升級”為獨占鎖。你必須先釋放共享鎖,然后再嘗試獲取獨占鎖。這個過程不是原子的,中間可能被其他寫線程插隊。
- 性能:雖然讀寫鎖在“讀多寫少”的場景下性能優異,但其內部實現比普通互斥量更復雜,開銷也稍大。如果臨界區非常小,或者寫操作很頻繁,可能普通的
std::mutex
性能更好。永遠基于性能測試來做選擇。