c++读写锁
好的,我们来详细解释一下 C++ 中的读写锁 (Read-Write Lock)。
1. 什么是读写锁?
读写锁,顾名思义,是一种将“读”和“写”两种操作区分开来对待的同步原语。它允许多个线程同时进行读操作,但只允许一个线程进行写操作。
为了更好地理解,我们可以将其与标准的互斥锁 (std::mutex
) 进行对比:
- 互斥锁 (
std::mutex
):非常“霸道”。无论线程是想读还是想写,只要一个线程锁定了资源,其他任何线程都必须等待,不管它们想做什么。这就像一个只有一个座位的房间,一次只能进去一个人。 - 读写锁 (
std::shared_mutex
):更加“智能”和高效。它遵循以下规则:- 读-读共享:当没有线程在进行写操作时,任意数量的线程都可以同时获取读锁并访问资源。
- 写-写互斥:同一时间只能有一个线程获取写锁。
- 读-写互斥:当一个线程持有写锁时,其他任何线程(无论是想读还是想写)都必须等待。反之,当至少有一个线程持有读锁时,任何想获取写锁的线程都必须等待。
这就像一个图书馆的阅览室:可以有很多读者同时在里面看书(并发读),但如果有人要更换或整理所有书籍(写操作),那么所有读者都必须离开,并且在整理完成前,谁也不能进来。
2. 为什么要使用读写锁?
核心优势在于性能。
在“读多写少”的场景下,读写锁的性能远超于互斥锁。
想象一个场景:一个在线服务的配置数据。这个配置在服务启动时加载,并且很少会被修改。但是,成千上万个并发请求需要频繁地读取这些配置。
- 如果使用互斥锁:每个请求在读取配置时都需要加锁,导致所有读请求变成了串行操作,严重影响并发性能,形成性能瓶颈。
- 如果使用读写锁:所有读请求可以同时、并发地进行,几乎没有等待。只有在管理员需要更新配置(写操作)时,读请求才需要短暂等待。一旦写操作完成,大量的读请求又可以并发执行。
因此,当你的共享资源被读取的频率远高于被修改的频率时,读写锁是提升程序并发能力的神器。
3. C++中的读写锁实现
C++17 标准正式引入了 std::shared_mutex
作为读写锁的实现。它位于 <shared_mutex>
头文件中。在 C++14 中,有一个功能类似的 std::shared_timed_mutex
。我们主要关注 C++17 的版本。
与 std::shared_mutex
配合使用的通常是两种 RAII 风格的锁管理器:
-
std::shared_lock<std::shared_mutex>
:用于获取读锁(共享锁)。- 在其构造函数中调用
shared_mutex
的lock_shared()
方法。 - 在其析构函数(离开作用域时)中调用
unlock_shared()
方法。
- 在其构造函数中调用
-
std::unique_lock<std::shared_mutex>
或std::lock_guard<std::shared_mutex>
:用于获取写锁(独占锁)。- 在其构造函数中调用
shared_mutex
的lock()
方法。 - 在其析构函数(离开作用域时)中调用
unlock()
方法。
- 在其构造函数中调用
使用 RAII 风格的锁(shared_lock
, lock_guard
)是最佳实践,因为它们可以确保即使在发生异常时,锁也能被正确释放,从而避免死锁。
4. 代码示例
下面是一个简单的例子,模拟一个被多个“读者”和一个“作者”线程共享的电话簿。
C++
#include <iostream>
#include <map>
#include <string>
#include <thread>
#include <vector>
#include <shared_mutex> // 引入读写锁头文件
#include <chrono>// 共享资源:一个电话簿
std::map<std::string, int> tele_book;
// 创建一个读写锁实例
std::shared_mutex tele_book_mutex;// 读者线程函数
void reader(int id) {for (int i = 0; i < 5; ++i) {// 使用 shared_lock 获取读锁std::shared_lock<std::shared_mutex> lock(tele_book_mutex);// 在持有读锁期间,其他读者也可以进入,但作者必须等待std::cout << "Reader " << id << " is reading... ";auto it = tele_book.find("Alice");if (it != tele_book.end()) {std::cout << "Found Alice's number: " << it->second << std::endl;} else {std::cout << "Could not find Alice's number." << std::endl;}// 模拟读取耗时std::this_thread::sleep_for(std::chrono::milliseconds(200));// 当 lock 离开作用域时,读锁会自动释放}
}// 作者线程函数
void writer() {for (int i = 0; i < 2; ++i) {// 模拟写入前的准备工作std::this_thread::sleep_for(std::chrono::milliseconds(500));// 使用 lock_guard (或 unique_lock) 获取写锁std::lock_guard<std::shared_mutex> lock(tele_book_mutex);// 在持有写锁期间,任何其他线程(读者或作者)都必须等待std::cout << "\nWriter is updating the book...\n" << std::endl;tele_book["Alice"] = 100 + i;tele_book["Bob"] = 200 + i;// 模拟写入耗时std::this_thread::sleep_for(std::chrono::seconds(1));// 当 lock 离开作用域时,写锁会自动释放}
}int main() {std::vector<std::thread> threads;// 创建一个作者线程threads.emplace_back(writer);// 创建三个读者线程threads.emplace_back(reader, 1);threads.emplace_back(reader, 2);threads.emplace_back(reader, 3);for (auto& t : threads) {t.join();}return 0;
}
代码解读与输出分析:
- 程序开始时,三个读者线程会几乎同时获取到读锁,并开始读取数据。你会看到类似 "Reader 1 is reading...", "Reader 2 is reading..." 交错出现。
- 当作者线程尝试获取写锁时,它必须等待所有当前持有读锁的读者线程完成并释放锁。
- 一旦所有读者都释放了锁,作者线程就会获得写锁,并打印 "Writer is updating..."。在此期间,所有读者线程的新一轮读取都会被阻塞,等待作者完成。
- 作者释放写锁后,读者们又可以一拥而上,并发地进行读取。
- 这个过程会重复进行。
5. 注意事项与潜在问题
- 写者饥饿 (Writer Starvation):这是一个经典问题。如果读请求非常密集,接连不断地到来,那么写者线程可能永远也等不到所有读者都释放锁的那个“空窗期”,从而导致“饥饿”。C++标准本身没有规定
std::shared_mutex
必须如何解决这个问题,其具体行为(例如,是优先读者还是优先作者)取决于编译器的实现。一些实现可能会在有写者等待时,阻止新的读者获取锁,以避免写者饥饿。 - 性能开销:读写锁本身比普通互斥锁更复杂,管理和调度的开销也更大。因此,在“读多写少”不明显的场景(例如,读写比例接近或写操作更多),使用读写锁可能反而会降低性能。请根据实际场景进行性能测试和选择。
- 死锁:和所有锁一样,不当使用也会导致死锁。例如,一个线程持有了读锁,又尝试去获取写锁,这在大多数实现上都会导致死锁。正确的做法是先释放读锁,再去申请写锁。
总结
特性 | 互斥锁 (std::mutex) | 读写锁 (std::shared_mutex) |
核心机制 | 一次只允许一个线程访问 | 允许多个读者或一个作者访问 |
主要锁管理器 | std::lock_guard , std::unique_lock | 读锁: std::shared_lock <br> 写锁: std::lock_guard , std::unique_lock |
最佳适用场景 | 读写操作频率相当,或写操作频繁 | 读操作远多于写操作 |
潜在问题 | 简单,但可能在读密集场景下成为性能瓶颈 | 更复杂,可能有写者饥饿问题,自身开销略高 |
当你确定你的应用场景是典型的“读多写少”时,std::shared_mutex
是一个非常值得使用的工具,它能显著地提升你程序的并发性能。