C++ std::shared_mutex
(读写锁):读多写少场景下的性能优化
大家好!今天咱们来聊聊C++里一个非常实用的工具:std::shared_mutex
,也就是常说的读写锁。这玩意儿在读多写少的场景下,能让你的程序性能嗖嗖地往上窜,简直就是性能优化的秘密武器。
啥是读写锁?为啥我们需要它?
想象一下,你和你的小伙伴们在图书馆里学习。
- 读操作(共享模式): 大部分时间,大家都在安安静静地查阅资料,互不干扰。这就像多个线程同时读取共享资源。
- 写操作(独占模式): 偶尔,你需要修改书本上的内容,这时候你得确保别人不能同时也在修改,也不能有人在阅读,要独占这本书。这就像一个线程需要独占式地写入共享资源。
传统的互斥锁(std::mutex
)就像图书馆管理员,每次只允许一个人进入。不管你是看书还是写字,都得排队等着,效率太低了!
读写锁的出现就是为了解决这个问题。它允许:
- 多个线程同时读取共享资源(共享模式)。
- 只有一个线程可以写入共享资源(独占模式),并且在写入时,不允许任何其他线程读取或写入。
简单来说,读写锁区分了读操作和写操作,允许多个读者同时访问,但只允许一个写者访问,或者没有读者的时候一个写者访问。
std::shared_mutex
的基本用法
std::shared_mutex
定义在 <shared_mutex>
头文件中。它提供了以下几个关键方法:
lock()
: 以独占模式锁定互斥量。如果互斥量已经被锁定(无论共享或独占),调用线程将阻塞,直到互斥量可用。unlock()
: 释放独占模式的互斥量。lock_shared()
: 以共享模式锁定互斥量。如果互斥量已经被一个线程以独占模式锁定,调用线程将阻塞,直到互斥量可用。unlock_shared()
: 释放共享模式的互斥量。try_lock()
: 尝试以独占模式锁定互斥量。如果互斥量可用,则锁定并返回true
;否则,立即返回false
。try_lock_shared()
: 尝试以共享模式锁定互斥量。如果互斥量可用,则锁定并返回true
;否则,立即返回false
。
为了方便管理锁的生命周期,C++还提供了以下几个 RAII (Resource Acquisition Is Initialization) 锁:
std::unique_lock<std::shared_mutex>
: 用于独占模式锁定。std::shared_lock<std::shared_mutex>
: 用于共享模式锁定。
RAII 锁会在构造时尝试获取锁,并在析构时自动释放锁,避免了手动 lock()
和 unlock()
可能导致的错误。
示例代码:
#include <iostream>
#include <shared_mutex>
#include <thread>
#include <vector>
#include <chrono>
#include <random>
std::shared_mutex mtx; // 读写锁
int data = 0; // 共享数据
// 读者线程
void reader(int id) {
std::random_device rd;
std::mt19937 gen(rd());
std::uniform_int_distribution<> distrib(100, 500); // 模拟读操作耗时
for (int i = 0; i < 5; ++i) {
std::shared_lock<std::shared_mutex> lock(mtx); // 共享模式锁定
std::cout << "Reader " << id << ": Data = " << data << std::endl;
std::this_thread::sleep_for(std::chrono::milliseconds(distrib(gen))); // 模拟读操作
}
}
// 写者线程
void writer(int id) {
std::random_device rd;
std::mt19937 gen(rd());
std::uniform_int_distribution<> distrib(500, 1000); // 模拟写操作耗时
for (int i = 0; i < 2; ++i) {
std::unique_lock<std::shared_mutex> lock(mtx); // 独占模式锁定
std::cout << "Writer " << id << ": Writing data..." << std::endl;
data = id * 100 + i; // 修改数据
std::this_thread::sleep_for(std::chrono::milliseconds(distrib(gen))); // 模拟写操作
std::cout << "Writer " << id << ": Data updated to " << data << std::endl;
}
}
int main() {
std::vector<std::thread> threads;
// 创建多个读者线程
for (int i = 0; i < 5; ++i) {
threads.emplace_back(reader, i);
}
// 创建多个写者线程
for (int i = 0; i < 2; ++i) {
threads.emplace_back(writer, i);
}
// 等待所有线程完成
for (auto& thread : threads) {
thread.join();
}
std::cout << "All threads finished." << std::endl;
return 0;
}
代码解释:
- 我们创建了一个
std::shared_mutex
类型的mtx
对象,用于保护共享数据data
。 reader()
函数模拟读者线程,使用std::shared_lock
以共享模式锁定互斥量,读取data
的值。writer()
函数模拟写者线程,使用std::unique_lock
以独占模式锁定互斥量,修改data
的值。main()
函数创建了多个读者线程和写者线程,并等待它们完成。
运行这段代码,你会发现读者线程可以并发地读取数据,而写者线程在写入数据时会阻塞其他线程,保证了数据的一致性。
读写锁的性能优势:读多写少场景
std::shared_mutex
在读多写少的场景下,性能优势非常明显。 想象一下,一个在线商品目录,用户大部分时间都在浏览商品信息(读操作),只有管理员偶尔会更新商品信息(写操作)。
在这种情况下,使用 std::mutex
会导致大量的线程阻塞在锁上,即使它们只是想读取数据。而使用 std::shared_mutex
,允许多个读者线程同时访问商品目录,大大提高了程序的并发性能。
性能对比:std::mutex
vs std::shared_mutex
(简化版)
为了更直观地展示性能差异,我们来做一个简单的性能测试。
#include <iostream>
#include <mutex>
#include <shared_mutex>
#include <thread>
#include <vector>
#include <chrono>
#include <random>
const int NUM_THREADS = 10;
const int NUM_ITERATIONS = 10000;
// 使用 std::mutex 的情况
void test_mutex(std::mutex& mtx, int& data) {
std::random_device rd;
std::mt19937 gen(rd());
std::uniform_int_distribution<> distrib(1, 10);
for (int i = 0; i < NUM_ITERATIONS; ++i) {
std::lock_guard<std::mutex> lock(mtx);
// 模拟读操作和少量写操作
if (distrib(gen) > 2) {
// 读操作
int temp = data;
} else {
// 写操作
data++;
}
}
}
// 使用 std::shared_mutex 的情况
void test_shared_mutex(std::shared_mutex& mtx, int& data) {
std::random_device rd;
std::mt19937 gen(rd());
std::uniform_int_distribution<> distrib(1, 10);
for (int i = 0; i < NUM_ITERATIONS; ++i) {
if (distrib(gen) > 2) {
// 读操作 (共享模式)
std::shared_lock<std::shared_mutex> lock(mtx);
int temp = data;
} else {
// 写操作 (独占模式)
std::unique_lock<std::shared_mutex> lock(mtx);
data++;
}
}
}
int main() {
// 测试 std::mutex
std::mutex mutex;
int data1 = 0;
auto start_mutex = std::chrono::high_resolution_clock::now();
std::vector<std::thread> mutex_threads;
for (int i = 0; i < NUM_THREADS; ++i) {
mutex_threads.emplace_back(test_mutex, std::ref(mutex), std::ref(data1));
}
for (auto& thread : mutex_threads) {
thread.join();
}
auto end_mutex = std::chrono::high_resolution_clock::now();
auto duration_mutex = std::chrono::duration_cast<std::chrono::milliseconds>(end_mutex - start_mutex);
// 测试 std::shared_mutex
std::shared_mutex shared_mutex;
int data2 = 0;
auto start_shared_mutex = std::chrono::high_resolution_clock::now();
std::vector<std::thread> shared_mutex_threads;
for (int i = 0; i < NUM_THREADS; ++i) {
shared_mutex_threads.emplace_back(test_shared_mutex, std::ref(shared_mutex), std::ref(data2));
}
for (auto& thread : shared_mutex_threads) {
thread.join();
}
auto end_shared_mutex = std::chrono::high_resolution_clock::now();
auto duration_shared_mutex = std::chrono::duration_cast<std::chrono::milliseconds>(end_shared_mutex - start_shared_mutex);
std::cout << "std::mutex time: " << duration_mutex.count() << " ms" << std::endl;
std::cout << "std::shared_mutex time: " << duration_shared_mutex.count() << " ms" << std::endl;
return 0;
}
代码解释:
- 我们分别使用
std::mutex
和std::shared_mutex
来保护一个共享的整数data
。 - 每个线程都会进行多次读操作和少量写操作。
- 我们记录了使用两种互斥量所花费的时间,并打印出来。
在读多写少的场景下,你会发现 std::shared_mutex
的性能明显优于 std::mutex
。
总结:
特性 | std::mutex |
std::shared_mutex |
适用场景 |
---|---|---|---|
独占模式 | 支持 | 支持 | 所有场景 |
共享模式 | 不支持 | 支持 | 读多写少 |
并发读 | 不支持 | 支持 | 读多写少,需要高并发读性能的场景 |
复杂度 | 较低 | 稍高 | |
锁粒度 | 粗粒度 | 可以根据需求调整 |
读写锁的潜在问题与注意事项
虽然 std::shared_mutex
在读多写少的场景下很给力,但使用不当也会带来一些问题:
-
写饥饿(Writer Starvation): 如果读操作非常频繁,写操作可能会一直被阻塞,无法获得锁。想象一下,图书馆里永远有人在看书,你想修改书本内容,永远排不上队。
解决方法: 可以使用公平锁策略,或者限制读者的数量,给写者一定的优先级。
std::shared_mutex
本身没有公平性保证,但是可以通过一些技巧来实现。 例如,可以使用条件变量配合一个计数器来控制读写顺序。 -
死锁(Deadlock): 与其他锁一样,
std::shared_mutex
也可能导致死锁。 例如,一个线程持有共享锁,然后尝试获取独占锁,而另一个线程持有独占锁,也尝试获取共享锁。解决方法: 避免循环依赖的锁请求,使用
try_lock()
或try_lock_shared()
来避免无限期阻塞。 -
过度使用: 不要为了使用读写锁而使用读写锁。 如果读写操作的比例差不多,或者写操作非常频繁,使用
std::mutex
可能更简单高效。 -
锁的粒度: 锁的粒度是指锁保护的资源范围。 锁的粒度太粗,会导致并发性降低; 锁的粒度太细,会导致锁的开销增加。 需要根据实际情况选择合适的锁粒度。
高级用法:条件变量与读写锁
std::condition_variable_any
可以与 std::shared_mutex
配合使用,实现更复杂的同步逻辑。 例如,可以在写操作完成后通知所有等待的读者线程。
示例代码:
#include <iostream>
#include <shared_mutex>
#include <condition_variable>
#include <thread>
#include <vector>
std::shared_mutex mtx;
std::condition_variable_any cv;
int data = 0;
bool data_ready = false;
void reader(int id) {
std::unique_lock<std::shared_mutex> lock(mtx, std::defer_lock); //延迟锁定
while (true) {
lock.lock_shared(); // 获取共享锁
cv.wait(lock, []{ return data_ready; }); // 等待数据准备好
std::cout << "Reader " << id << ": Data = " << data << std::endl;
data_ready = false; // 消费数据后重置标志
lock.unlock_shared();
std::this_thread::sleep_for(std::chrono::milliseconds(100)); // 模拟读操作
}
}
void writer(int id) {
for (int i = 0; i < 5; ++i) {
std::unique_lock<std::shared_mutex> lock(mtx); // 获取独占锁
std::cout << "Writer " << id << ": Writing data..." << std::endl;
data = id * 100 + i;
data_ready = true; // 设置数据准备好的标志
std::cout << "Writer " << id << ": Data updated to " << data << std::endl;
cv.notify_all(); // 通知所有等待的读者
std::this_thread::sleep_for(std::chrono::milliseconds(500)); // 模拟写操作
}
}
int main() {
std::vector<std::thread> readers;
for (int i = 0; i < 3; ++i) {
readers.emplace_back(reader, i);
}
std::vector<std::thread> writers;
for (int i = 0; i < 1; ++i) {
writers.emplace_back(writer, i);
}
for (auto& reader : readers) {
reader.detach(); //detach线程,防止程序阻塞
}
for (auto& writer : writers) {
writer.join();
}
std::cout << "All writers finished." << std::endl;
return 0;
}
代码解释:
data_ready
标志用于指示数据是否准备好。- 读者线程在获取共享锁后,使用
cv.wait()
等待data_ready
变为true
。 - 写者线程在更新数据后,将
data_ready
设置为true
,并使用cv.notify_all()
通知所有等待的读者线程。 - 读者线程消费数据后,重置
data_ready
为false
,防止重复读取。
总结
std::shared_mutex
是一个强大的工具,可以在读多写少的场景下显著提高程序的并发性能。 但是,需要注意潜在的问题,并根据实际情况选择合适的锁策略和粒度。 掌握了 std::shared_mutex
的用法,你就可以写出更加高效、并发的 C++ 程序了!
记住,没有银弹。选择合适的同步机制,需要根据具体的应用场景和性能需求进行权衡。 希望今天的分享对大家有所帮助! 谢谢!