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导致内存重分配——迭代器失效了,你的读操作也就跟着崩了。

核心原则:只要有一个线程在修改容器(包括调用非const成员函数),其他所有线程都不能对该容器做任何操作——包括读操作。

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;
    }
};

我在项目中遇到过这样一个问题:用读写锁时,写操作被读操作活活饿死了。原因是读线程太多,写线程一直抢不到独占锁。解决办法是给写操作加个「优先级提升」策略,或者限制并发读的数量。

小技巧:如果你的场景是「写操作极少但必须及时」,可以考虑用std::atomic<bool>做写标志位,配合自旋锁。不过别滥用,自旋锁在写操作频繁时反而更慢。

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可能是旧的,甚至是不一致的。

我曾经在调试一个线上bug时,发现一个线程读到size()=10,然后去访问vec[9],结果越界了。为什么?因为另一个线程刚执行了pop_back,但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:

  1. 配合条件变量:std::condition_variable的wait()要求传入一个unique_lock,因为它需要在等待期间临时解锁。
  2. 需要手动解锁:比如一个函数前半部分需要锁保护,后半部分不需要。用unique_lock可以在中间unlock(),提前释放锁。
  3. 锁的转移:你想把锁的所有权从一个作用域转移到另一个作用域。
// 场景:手动解锁
std::unique_lock<std::mutex> lock(mtx);
// ... 做一些需要保护的操作 ...
lock.unlock();  // 提前释放
// ... 做一些不需要保护的操作(比如IO、计算) ...
// 注意:析构时不会再次解锁,因为已经unlock了
注意:lock_guard和unique_lock都不支持拷贝,只支持移动。如果你试图拷贝一个unique_lock,编译器会报错。这是RAII设计的基本要求——锁不能被复制。

26.5 知识体系总览

下面这张图把本章的核心逻辑串起来了。你可以看到,从容器安全级别出发,到锁策略的选择,再到具体工具的使用,是一条完整的技术链路。

STL线程安全知识体系 容器安全级别 不同容器 = 完全安全 同容器读 = 读安全 同容器写 = 不安全 常见陷阱 迭代器失效 size()非原子 DCLP无效 锁策略 读多写少 → 读写锁 写多读少 → 互斥锁 无竞争 → 原子操作 工具选择 lock_guard:轻量、简单、无额外开销 → 90%场景够用 unique_lock:灵活、可手动解锁、配合条件变量 → 10%复杂场景 最佳实践 最小锁粒度 · 避免死锁 · 优先RAII · 考虑读写分离 性能测试验证 · 不要过度设计

最后说一句:锁不是银弹。有时候换个思路,比如用无锁数据结构、或者把数据分片(sharding),反而比加锁更高效。我在一个高并发日志系统里就用过分片策略——每个线程写自己的buffer,最后再合并,完全避开了锁竞争。

但如果你决定用锁,那就用好RAII。lock_guard和unique_lock就是C++给你的最佳实践。记住:手动lock/unlock是万恶之源,能交给RAII就别自己动手。

我的建议:先写lock_guard,性能不够再考虑unique_lock。别一开始就上读写锁——它比互斥锁重得多,读操作少的时候反而更慢。先跑起来,再优化。

公众号:蓝海资料掘金营,微信deep3321