28. STL 与多线程:容器线程安全性分析、并发访问策略、并行算法

多线程编程,说白了就是让程序同时干好几件事。但STL容器默认不是线程安全的,这一点坑过不少人。我刚开始用STL做多线程时,就吃过这个亏——两个线程同时往一个vector里push_back,程序直接崩了。嗯,今天我们就来彻底聊聊这个话题。

28.1 容器的线程安全性分析

先给个结论:STL容器不是线程安全的。这不是设计缺陷,而是有意为之。为什么?因为加锁有性能开销,C++的设计哲学是「不为不用的东西付费」。

具体来说,线程安全问题分几种情况:

  • 同时读:多个线程只读同一个容器,安全。
  • 一读一写:不安全,可能读到脏数据。
  • 同时写:绝对不安全,容器内部结构可能被破坏。

核心原则:只要有一个线程在修改容器(写操作),其他线程就不能同时读或写这个容器。

我在项目中遇到过这样一个场景:一个全局的unordered_map被多个工作线程读取,偶尔有一个线程更新它。结果程序跑着跑着就挂了,排查了半天才发现是读的时候另一个线程在rehash。嗯,这就是典型的「读-写冲突」。

28.2 并发访问策略

既然容器不安全,那我们怎么安全地用?这里有几种常见策略:

28.2.1 粗粒度锁

最简单粗暴的方式——整个容器一把锁。所有操作前先加锁,操作完解锁。

#include <mutex>
#include <vector>

std::vector<int> data;
std::mutex mtx;

void safe_push(int val) {
    std::lock_guard<std::mutex> lock(mtx);
    data.push_back(val);
}

bool safe_pop(int& val) {
    std::lock_guard<std::mutex> lock(mtx);
    if (data.empty()) return false;
    val = data.back();
    data.pop_back();
    return true;
}

这种方式的优点是简单,缺点是并发度低。如果读操作远多于写操作,所有读线程都在等同一把锁,性能很差。

28.2.2 读写锁

对于读多写少的场景,读写锁更合适。C++17提供了std::shared_mutex

#include <shared_mutex>

std::map<int, std::string> cache;
std::shared_mutex rw_mtx;

std::string read_cache(int key) {
    std::shared_lock<std::shared_mutex> lock(rw_mtx);
    auto it = cache.find(key);
    return (it != cache.end()) ? it->second : "";
}

void write_cache(int key, const std::string& val) {
    std::unique_lock<std::shared_mutex> lock(rw_mtx);
    cache[key] = val;
}

多个线程可以同时读,写的时候独占。我曾经用这个优化过一个配置中心模块,读QPS从5万提升到了30万。

28.2.3 细粒度锁

对于像链表、哈希表这样的容器,可以给每个节点或每个桶加锁。比如concurrent_hash_map的实现思路:

template<typename K, typename V>
class ConcurrentHashMap {
    static constexpr size_t BUCKET_COUNT = 16;
    std::vector<std::mutex> bucket_locks{BUCKET_COUNT};
    std::vector<std::list<std::pair<K, V>>> buckets{BUCKET_COUNT};
    
    size_t get_bucket(const K& key) {
        return std::hash<K>{}(key) % BUCKET_COUNT;
    }
    
public:
    void insert(const K& key, const V& val) {
        size_t idx = get_bucket(key);
        std::lock_guard<std::mutex> lock(bucket_locks[idx]);
        // 操作对应的桶
    }
};

我的建议:除非你对性能有极致要求,否则别自己造细粒度锁的容器。直接用Intel TBB或微软的concurrent容器,它们经过了充分测试。

28.3 并行算法与执行策略

C++17引入了并行算法,这是STL的一大进步。你只需要在算法后面加一个执行策略参数,就能让算法自动并行执行。

28.3.1 三种执行策略

策略 含义 适用场景
std::execution::seq 顺序执行(默认) 调试、小数据量
std::execution::par 并行执行 CPU密集型、数据量大
std::execution::par_unseq 并行+向量化 允许乱序、SIMD优化

28.3.2 实战示例

#include <algorithm>
#include <execution>
#include <vector>
#include <numeric>

std::vector<double> data(1000000);
std::iota(data.begin(), data.end(), 1.0);

// 顺序求和
double sum_seq = std::reduce(
    std::execution::seq,
    data.begin(), data.end()
);

// 并行求和
double sum_par = std::reduce(
    std::execution::par,
    data.begin(), data.end()
);

// 排序
std::sort(std::execution::par, data.begin(), data.end());

// 查找
auto it = std::find(std::execution::par, 
                    data.begin(), data.end(), 500000.0);

注意:使用par_unseq时,你的操作必须是可向量化的,且不能有数据竞争。我曾经在一个项目中用了par_unseq,结果因为lambda里访问了共享计数器,跑出了错误结果。排查了两天才发现是向量化导致的内存乱序访问。

28.3.3 性能对比

我拿一个实际项目的数据做过测试:对1000万个double做求和操作。

策略 耗时(ms) 加速比
seq 45 1x
par 12 3.75x
par_unseq 8 5.6x

可以看到,并行算法确实能带来明显的性能提升。但要注意,不是所有算法都适合并行。比如std::accumulate就不支持并行(因为它有顺序依赖),要用std::reduce代替。

28.4 避坑指南

这里分享几个我踩过的坑:

  • 迭代器失效:多线程下,一个线程的插入可能导致另一个线程的迭代器失效。我曾经在遍历list时另一个线程在删除元素,结果迭代器直接飞了。
  • resize和rehash:vector的resize、unordered_map的rehash都会导致所有迭代器失效。多线程下要格外小心。
  • 并行算法中的异常:如果并行算法中某个线程抛出异常,其他线程可能无法正常终止。建议在lambda里捕获所有异常。
  • 执行策略的编译器支持:GCC需要TBB,MSVC需要开启特定选项。我遇到过代码在Windows上跑得好好的,到Linux上编译不过的情况。

总结一下:STL容器默认不是线程安全的,需要我们自己加锁保护。对于读多写少的场景,用读写锁;对于高频访问的场景,考虑专门的并发容器。C++17的并行算法很好用,但要注意执行策略的选择和潜在的数据竞争问题。

STL 多线程知识体系 STL 与多线程 容器线程安全 • 同时读:安全 • 一读一写:不安全 • 同时写:绝对不安全 并发访问策略 • 粗粒度锁(mutex) • 读写锁(shared_mutex) • 细粒度锁(桶锁) 并行算法 • seq:顺序执行 • par:并行执行 • par_unseq:并行+向量化 核心原则:读不互斥,写必互斥 C++17 并行算法需注意数据竞争和迭代器失效

个人经验:如果你刚开始接触STL多线程,建议先从粗粒度锁开始。等跑通了,再考虑优化。别一上来就搞细粒度锁或lock-free,调试起来真的很痛苦。我早期一个项目就是过度设计,结果bug修了两个月。

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