26、STL的线程安全:容器的线程安全级别、读写锁策略、并发访问的常见陷阱、lock_guard与unique_lock的使用
说到STL的线程安全,我得先泼一盆冷水——标准库容器默认不是线程安全的。很多新手刚接触多线程时,想当然地以为std::vector、std::map这些容器可以随便在多个线程里读写。嗯,我当年也踩过这个坑。
说白了,STL的设计哲学是「你不碰我,我不碰你」。它把并发控制的责任完全交给了我们开发者。这听起来有点坑,但其实给了我们最大的灵活性——你可以根据场景选择最合适的锁策略,而不是被容器内部绑死。
26.1 容器的线程安全级别
我们先给STL容器的线程安全分个级。我个人习惯把它分成三个层次:
| 安全级别 | 描述 | 典型场景 |
|---|---|---|
| 完全安全 | 多个线程同时调用不同容器的成员函数 | 线程A操作vector1,线程B操作vector2 |
| 读安全 | 多个线程同时读取同一个容器的const成员函数 | 多个线程同时调用size()、empty()、at() const |
| 写不安全 | 任何线程在写(修改)容器时,其他线程不能同时读或写 | push_back、erase、operator[] 非const版本 |
你可能会问:「那const成员函数就一定安全吗?」不一定。举个例子,如果你在一个线程里调用vector的const begin(),另一个线程却在push_back导致内存重分配——迭代器失效了,你的读操作也就跟着崩了。
26.2 读写锁策略
在实际项目中,读多写少是非常常见的场景。比如一个配置表,99%的时间都在查,只有1%的时间在更新。这时候用普通的互斥锁就太浪费了——读操作之间明明可以并发的嘛。
读写锁(shared_mutex)就是为这种场景设计的。C++17标准库提供了std::shared_mutex,配合shared_lock和unique_lock使用:
#include <shared_mutex>
#include <map>
#include <string>
class ConfigTable {
std::map<std::string, std::string> data_;
mutable std::shared_mutex mtx_; // 注意:mutable
public:
std::string get(const std::string& key) const {
std::shared_lock<std::shared_mutex> lock(mtx_); // 共享锁
auto it = data_.find(key);
return it != data_.end() ? it->second : "";
}
void set(const std::string& key, const std::string& val) {
std::unique_lock<std::shared_mutex> lock(mtx_); // 独占锁
data_[key] = val;
}
};
我在项目中遇到过这样一个问题:用读写锁时,写操作被读操作活活饿死了。原因是读线程太多,写线程一直抢不到独占锁。解决办法是给写操作加个「优先级提升」策略,或者限制并发读的数量。
26.3 并发访问的常见陷阱
我总结了几个最容易踩的坑,每一个都是我或者我同事的血泪史:
陷阱一:迭代器失效
这是最经典的。你在一个线程里遍历vector,另一个线程在末尾push_back——如果触发了内存重分配,你手里的迭代器就变成野指针了。
std::vector<int> vec = {1, 2, 3};
// 线程A
for (auto it = vec.begin(); it != vec.end(); ++it) {
// 此时线程B push_back 导致重分配 —— 崩溃!
}
// 线程B
vec.push_back(4);
解决办法?要么在遍历期间加锁,要么用std::shared_ptr<std::vector<int>>做写时复制(COW)。
陷阱二:size() 和 empty() 不是原子操作
很多人以为size()是原子的。其实不是。vector的size()只是返回一个成员变量,但这个变量的读取和写入之间没有内存屏障。你读到的size可能是旧的,甚至是不一致的。
陷阱三:双重检查锁定(DCLP)在STL中无效
有些同学想用「先检查再加锁」来优化性能:
if (!vec.empty()) { // 第一次检查,无锁
std::lock_guard<std::mutex> lock(mtx);
if (!vec.empty()) { // 第二次检查,有锁
auto val = vec.back();
vec.pop_back();
}
}
这个模式在单例模式中配合std::atomic可以用,但在STL容器上——没用。因为第一次检查时vec可能正在被修改,empty()返回的结果本身就是不可靠的。
26.4 lock_guard与unique_lock的使用
这两个是C++11提供的RAII锁管理工具。它们的核心区别就一句话:lock_guard是轻量级的,unique_lock是灵活的。
| 特性 | lock_guard | unique_lock |
|---|---|---|
| 构造时加锁 | 是 | 是(可延迟) |
| 析构时解锁 | 是 | 是 |
| 手动解锁 | 否 | 是(调用unlock()) |
| 转移所有权 | 否 | 是(move语义) |
| 条件变量配合 | 否 | 是(wait()需要) |
| 性能开销 | 极小 | 略大(内部有标志位) |
我个人习惯是:能用lock_guard就别用unique_lock。为什么?因为unique_lock内部维护了一个owns_lock_的标志位,每次析构时都要判断一下是否需要解锁。虽然这点开销微乎其微,但写代码嘛,能简单就别复杂。
但有些场景必须用unique_lock:
- 配合条件变量:std::condition_variable的wait()要求传入一个unique_lock,因为它需要在等待期间临时解锁。
- 需要手动解锁:比如一个函数前半部分需要锁保护,后半部分不需要。用unique_lock可以在中间unlock(),提前释放锁。
- 锁的转移:你想把锁的所有权从一个作用域转移到另一个作用域。
// 场景:手动解锁
std::unique_lock<std::mutex> lock(mtx);
// ... 做一些需要保护的操作 ...
lock.unlock(); // 提前释放
// ... 做一些不需要保护的操作(比如IO、计算) ...
// 注意:析构时不会再次解锁,因为已经unlock了
26.5 知识体系总览
下面这张图把本章的核心逻辑串起来了。你可以看到,从容器安全级别出发,到锁策略的选择,再到具体工具的使用,是一条完整的技术链路。
最后说一句:锁不是银弹。有时候换个思路,比如用无锁数据结构、或者把数据分片(sharding),反而比加锁更高效。我在一个高并发日志系统里就用过分片策略——每个线程写自己的buffer,最后再合并,完全避开了锁竞争。
但如果你决定用锁,那就用好RAII。lock_guard和unique_lock就是C++给你的最佳实践。记住:手动lock/unlock是万恶之源,能交给RAII就别自己动手。
公众号:蓝海资料掘金营,微信deep3321