第十九章:并发容器——从C++26到无锁实现

并发编程里,数据竞争是个老生常谈的问题。而并发容器,就是帮你从底层锁的泥潭里解脱出来的好工具。今天咱们聊聊并发容器,重点放在C++26新引入的std::concurrent_unordered_map,以及第三方库的选型,最后我会分享一些无锁容器的实现思路。

核心观点:并发容器的本质,是把「线程安全」封装进数据结构内部。你不需要自己加锁,容器替你管好了。

19.1 为什么需要并发容器?

我刚开始写多线程代码时,习惯用std::unordered_map加一把大锁。后来发现,性能瓶颈全在这把锁上。你想想看,一个线程在读,另一个线程也在读,明明不冲突,却因为同一把锁互相等待。这太浪费了。

并发容器要解决的核心问题有三个:

  • 细粒度锁:把一把大锁拆成多把小锁,比如分段锁、读写锁
  • 无等待读:读操作尽量不阻塞,甚至不需要锁
  • 内存序优化:用原子操作替代互斥锁,减少上下文切换

说白了,并发容器就是「用空间换时间,用设计换性能」。

19.2 C++26 的 std::concurrent_unordered_map

C++26终于把并发哈希表纳入了标准库。说实话,我等这个特性等了很久。以前做项目时,要么自己手写,要么依赖第三方库,总归不够优雅。

std::concurrent_unordered_map 的设计思路是这样的:

  • 内部采用分段锁(striped locking),每个桶或每组桶有一把锁
  • 读操作通常是无锁的,或者只加非常轻量的读锁
  • 写操作只锁住对应的分段,不影响其他分段的读写

来看一个简单的使用示例:

#include <concurrent_unordered_map>
#include <thread>
#include <iostream>

std::concurrent_unordered_map<int, std::string> map;

void writer(int id) {
    for (int i = 0; i < 100; ++i) {
        map.insert_or_assign(i, "thread_" + std::to_string(id));
    }
}

void reader() {
    for (int i = 0; i < 100; ++i) {
        auto it = map.find(i);
        if (it != map.end()) {
            // 安全读取
            std::cout << it->second << "\n";
        }
    }
}

int main() {
    std::thread t1(writer, 1);
    std::thread t2(writer, 2);
    std::thread t3(reader);
    
    t1.join(); t2.join(); t3.join();
    return 0;
}

小提示:这个容器在C++26中还是experimental状态,编译器支持有限。不过设计思路已经定型,值得提前了解。

19.3 第三方库的并发容器

标准库的并发容器还在路上,但生产环境不能等。我在项目中用过几个第三方库,这里做个对比:

库名 特点 适用场景 我的评价
Intel TBB concurrent_hash_map, concurrent_queue 高性能计算、流水线 成熟稳定,我用了三年
Folly (Facebook) ConcurrentHashMap, MPMCQueue 高并发服务端 性能极致,但依赖重
Junction 基于无锁哈希表 读多写少场景 设计精巧,学习曲线陡
Boost.Lockfree queue, stack, SPSC队列 简单无锁需求 轻量,但功能有限

我个人习惯用TBB,因为它的API设计最接近标准库风格,迁移成本低。不过如果你追求极致性能,Folly的ConcurrentHashMap在Facebook内部经过了千锤百炼,值得一试。

避坑指南:我曾经在项目里直接用了某个第三方库的并发容器,结果发现它在ARM架构上有内存序bug。所以,跨平台项目一定要做充分的压力测试。

19.4 无锁容器的实现思路

无锁容器,说白了就是不用互斥锁,全靠原子操作和CAS(Compare-And-Swap)来保证线程安全。听起来很酷,但实现起来坑很多。

我分享几个核心思路:

19.4.1 无锁栈(Treiber Stack)

这是最简单的无锁数据结构。核心思想是用CAS更新栈顶指针:

template<typename T>
class LockFreeStack {
    struct Node {
        T data;
        Node* next;
    };
    std::atomic<Node*> head_{nullptr};
    
public:
    void push(T value) {
        Node* new_node = new Node{std::move(value), nullptr};
        new_node->next = head_.load(std::memory_order_relaxed);
        while (!head_.compare_exchange_weak(
            new_node->next, new_node,
            std::memory_order_release,
            std::memory_order_relaxed));
    }
    
    bool pop(T& value) {
        Node* old_head = head_.load(std::memory_order_relaxed);
        while (old_head && !head_.compare_exchange_weak(
            old_head, old_head->next,
            std::memory_order_acquire,
            std::memory_order_relaxed));
        if (!old_head) return false;
        value = std::move(old_head->data);
        delete old_head;  // 注意:这里可能有ABA问题
        return true;
    }
};

注意:上面的代码有ABA问题。实际项目中需要用hazard pointer或RCU来安全回收内存。我当年第一次写无锁栈时,就被ABA问题坑了一周。

19.4.2 无锁队列(Michael-Scott队列)

这是经典的无锁FIFO队列。它用两个指针:head和tail。入队操作在tail端,出队操作在head端。核心挑战是如何处理「队列为空」和「队列满」的边界情况。

实现要点:

  • 使用dummy节点,避免空队列的特殊处理
  • tail指针可能落后于实际尾节点,需要辅助推进
  • 出队时如果head和tail相同,说明队列为空

19.4.3 无锁哈希表

无锁哈希表通常有两种思路:

  • 基于无锁链表:每个桶是一个无锁链表,用CAS插入和删除
  • 基于开放寻址:用原子操作直接操作数组槽位,冲突时线性探测

我个人更倾向于第二种,因为内存布局更紧凑,缓存友好。但开放寻址的缺点是扩容很麻烦,需要「无锁搬迁」。

我的经验:除非你对内存模型和原子操作非常熟悉,否则不要轻易在生产环境手写无锁容器。先用TBB或Folly,等性能瓶颈真的出现在容器上时,再考虑定制。

19.5 知识体系总览

下面这张图总结了本章的核心脉络:

并发容器知识体系 并发容器 C++26 标准库 std::concurrent_unordered_map 分段锁 + 无锁读 第三方库 TBB / Folly / Junction 生产环境首选 无锁实现 CAS + 原子操作 ABA问题 / Hazard Pointer 选型建议 读多写少 → 无锁容器(Junction) 读写均衡 → 分段锁(TBB / C++26)

19.6 总结与建议

聊了这么多,我最后总结几点:

  • 能用标准库就用标准库:C++26的concurrent_unordered_map虽然还年轻,但设计思路是经过业界验证的
  • 第三方库是主力:TBB和Folly在生产环境已经非常成熟,不要有「非标准库不用」的洁癖
  • 无锁容器是最后的手段:除非你确定瓶颈在锁竞争上,否则别轻易上无锁。我见过太多「为了无锁而无锁」的项目,最后bug修到崩溃

嗯,并发容器的世界就是这样——看似简单,实则暗流涌动。希望今天的分享能帮你少走一些弯路。

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