文章目录
- 读数据对数据没有影响,为什么还需要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
性能更好。永远基于性能测试来做选择。