第十九章:并发容器——从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 知识体系总览
下面这张图总结了本章的核心脉络:
19.6 总结与建议
聊了这么多,我最后总结几点:
- 能用标准库就用标准库:C++26的
concurrent_unordered_map虽然还年轻,但设计思路是经过业界验证的 - 第三方库是主力:TBB和Folly在生产环境已经非常成熟,不要有「非标准库不用」的洁癖
- 无锁容器是最后的手段:除非你确定瓶颈在锁竞争上,否则别轻易上无锁。我见过太多「为了无锁而无锁」的项目,最后bug修到崩溃
嗯,并发容器的世界就是这样——看似简单,实则暗流涌动。希望今天的分享能帮你少走一些弯路。