7. 线程安全的数据结构:队列、栈与读写锁
说到并发编程,最基础也最绕不开的话题,就是线程安全的数据结构。你想想看,多线程环境下,大家共享同一块数据,如果没有保护机制,那简直就是一场灾难。我在早期做后台服务时,就吃过这个亏——一个全局队列被多个线程同时push和pop,结果数据错乱得让人头皮发麻。
今天我们就来聊聊三种最常用的线程安全数据结构:队列、栈,以及它们的核心保护机制——读写锁。嗯,说白了,就是给这些容器穿上“防弹衣”。
7.1 线程安全的队列
队列这东西,太常见了。生产者消费者模型里,它就是那个“中转站”。但普通队列在多线程下,push和pop同时进行,轻则数据丢失,重则程序崩溃。
我个人的习惯是,用std::queue加互斥锁封装一层。但要注意,不是简单加锁就完事了。你想想看,如果队列为空时,消费者线程还在不断pop,那怎么办?
这里有个经典做法——条件变量配合互斥锁。看代码:
#include <queue>
#include <mutex>
#include <condition_variable>
template<typename T>
class ThreadSafeQueue {
private:
std::queue<T> queue_;
mutable std::mutex mutex_;
std::condition_variable cond_;
public:
void push(T value) {
std::lock_guard<std::mutex> lock(mutex_);
queue_.push(std::move(value));
cond_.notify_one(); // 通知等待的消费者
}
T pop() {
std::unique_lock<std::mutex> lock(mutex_);
cond_.wait(lock, [this] { return !queue_.empty(); });
T value = std::move(queue_.front());
queue_.pop();
return value;
}
bool try_pop(T& value) {
std::lock_guard<std::mutex> lock(mutex_);
if (queue_.empty()) return false;
value = std::move(queue_.front());
queue_.pop();
return true;
}
};
核心要点:
pop()使用条件变量等待,避免忙等待浪费CPUtry_pop()提供非阻塞版本,适合轮询场景- 互斥锁用
mutable修饰,因为empty()等const方法也需要加锁
我的经验: 曾经在项目中,我直接用 std::queue 的 front() 和 pop() 分开调用,中间没加锁。结果两个线程同时pop,一个线程读到了脏数据。嗯,从那以后,我所有队列操作都封装成原子操作。
7.2 线程安全的栈
栈和队列很像,但它是后进先出。多线程环境下,栈的问题和队列类似——top和pop不能分开。我曾经见过一个同事,在top之后、pop之前,另一个线程把栈清空了,结果top返回的引用直接悬空。
来看一个线程安全的栈实现:
template<typename T>
class ThreadSafeStack {
private:
std::stack<T> stack_;
mutable std::mutex mutex_;
std::condition_variable cond_;
public:
void push(T value) {
std::lock_guard<std::mutex> lock(mutex_);
stack_.push(std::move(value));
cond_.notify_one();
}
std::shared_ptr<T> pop() {
std::unique_lock<std::mutex> lock(mutex_);
cond_.wait(lock, [this] { return !stack_.empty(); });
auto sp = std::make_shared<T>(std::move(stack_.top()));
stack_.pop();
return sp;
}
bool try_pop(T& value) {
std::lock_guard<std::mutex> lock(mutex_);
if (stack_.empty()) return false;
value = std::move(stack_.top());
stack_.pop();
return true;
}
};
注意: 这里 pop() 返回的是 std::shared_ptr<T>,而不是直接返回 T。为什么?因为如果直接返回 T,在拷贝构造时可能抛出异常,而此时栈顶元素已经被移除了。用智能指针可以避免这个问题——先构造智能指针,再pop,异常安全。
7.3 读写锁:std::shared_mutex
好了,队列和栈都用了互斥锁。但互斥锁有个问题——它太“霸道”了。读操作之间其实不冲突,但互斥锁让所有读操作串行化了。你想想看,如果10个线程同时读,互斥锁会让它们排队,这多浪费啊。
读写锁就是来解决这个问题的。C++17引入了 std::shared_mutex,它支持两种锁模式:
- 共享锁(shared_lock):多个线程可以同时持有,适合读操作
- 独占锁(unique_lock):只有一个线程可以持有,适合写操作
看个例子:
#include <shared_mutex>
#include <unordered_map>
class ThreadSafeCache {
private:
std::unordered_map<int, std::string> cache_;
mutable std::shared_mutex rw_mutex_;
public:
std::string read(int key) {
std::shared_lock lock(rw_mutex_); // 共享锁,可多个线程同时读
auto it = cache_.find(key);
if (it != cache_.end()) {
return it->second;
}
return "";
}
void write(int key, const std::string& value) {
std::unique_lock lock(rw_mutex_); // 独占锁,写时禁止读
cache_[key] = value;
}
};
读写锁的核心规则:
| 操作 | 锁类型 | 并发性 |
|---|---|---|
| 读 | shared_lock | 多个线程可同时读 |
| 写 | unique_lock | 写时独占,禁止其他读写 |
我的建议: 读写锁不是万能的。如果写操作非常频繁,读写锁的开销可能比互斥锁还大。为什么?因为每次写都要等待所有读完成,频繁切换上下文反而更慢。我个人习惯是:读多写少(比如配置缓存)用读写锁;读写均衡或写多读少,直接用互斥锁。
7.4 知识体系图
下面这张图,帮你理清本章的核心逻辑:
7.5 避坑指南
最后,分享几个我踩过的坑:
- 不要返回引用:队列的
front()返回引用,但多线程下这个引用可能瞬间失效。要么拷贝返回,要么用智能指针。 - 条件变量的虚假唤醒:
wait()可能在没有 notify 的情况下返回。所以一定要用谓词判断条件,就像我代码里写的[this] { return !queue_.empty(); }。 - 读写锁的升级问题:不能从 shared_lock 直接升级到 unique_lock,否则可能死锁。如果需要升级,先释放共享锁,再获取独占锁。
我曾经踩过的坑: 有一次我写了一个缓存系统,用读写锁保护。结果写操作频繁,读操作被长时间阻塞,导致服务响应变慢。后来我改成读写分离——写操作写入临时区,读操作从主区读,定期交换。嗯,性能一下就上来了。
好了,线程安全的数据结构就聊到这里。记住,锁只是工具,真正重要的是理解你的并发场景。读多写少?读写均衡?还是写多读少?不同的场景,选择不同的策略。别盲目用读写锁,也别死磕互斥锁。