17、并发哈希表实现:设计目标与挑战、分段锁实现、无锁哈希表(Hazard Pointer)、性能对比测试
并发哈希表,这玩意儿可以说是并发编程里的"硬骨头"。我这些年做后台服务,但凡涉及到高并发缓存、路由表、连接池,最后几乎都会落到这个数据结构上。说白了,哈希表本身不难,难的是怎么让它在多线程环境下既快又稳。
今天咱们就掰开揉碎,把并发哈希表的几种主流实现方式聊透。我会从设计目标讲起,然后带你手撸分段锁版本,再挑战无锁实现中的 Hazard Pointer 技术,最后用数据说话——看看它们到底差多少。
设计目标:我们要解决什么问题?
先想清楚,一个并发哈希表到底要满足什么?我总结下来就三条:
- 线程安全:多个线程同时读写,不能出现数据错乱。这是底线。
- 高吞吐:并发度越高越好,别让锁成为瓶颈。你想想看,如果 16 个线程抢一把锁,那跟单线程有啥区别?
- 低延迟:每个操作尽量快,尤其不能有"惊群效应"——一个线程释放锁,所有等待线程全醒过来抢。
这里有个常见的坑:很多人觉得加个 std::mutex 就完事了。嗯,小规模场景确实够用,但一旦并发量上来,你会发现性能断崖式下跌。我在项目中就遇到过,一个全局锁的哈希表,QPS 到 10 万就上不去了,换成分段锁直接飙到 80 万。
核心矛盾:哈希表需要随机访问,而锁的粒度决定了并发度。锁越粗,实现越简单,但性能越差;锁越细,性能越好,但实现复杂度指数级上升。
分段锁实现:工程中最实用的方案
分段锁,说白了就是把一个大哈希表切成若干个小桶(bucket),每个桶有自己的锁。这样不同线程操作不同桶时,完全不用互相等待。
我个人的习惯是,分段数取 2 的幂次,比如 64、256。为什么?因为取模运算可以用位运算替代,快很多。来看代码:
template<typename K, typename V>
class ConcurrentHashMap {
struct Bucket {
std::mutex mtx;
std::unordered_map<K, V> map;
};
std::vector<Bucket> buckets_;
size_t mask_;
size_t hash(const K& key) const {
return std::hash<K>{}(key) & mask_;
}
public:
ConcurrentHashMap(size_t bucket_count = 256)
: buckets_(bucket_count), mask_(bucket_count - 1) {}
void insert(const K& key, const V& value) {
auto& bucket = buckets_[hash(key)];
std::lock_guard<std::mutex> lock(bucket.mtx);
bucket.map[key] = value;
}
bool find(const K& key, V& out) {
auto& bucket = buckets_[hash(key)];
std::lock_guard<std::mutex> lock(bucket.mtx);
auto it = bucket.map.find(key);
if (it != bucket.map.end()) {
out = it->second;
return true;
}
return false;
}
};
这段代码看着简单,但有几个细节要注意:
- 锁的粒度:每个桶一把锁,而不是每个元素一把锁。后者锁开销太大,得不偿失。
- 哈希函数:一定要均匀分布。我曾经遇到过哈希冲突严重的情况,结果 80% 的请求都打到同一个桶上,分段锁直接退化成全局锁。
- 扩容问题:上面的实现没有处理扩容。实际工程中,你需要考虑动态扩容时怎么锁住整个表——这是个麻烦事。
避坑指南:我曾经在扩容时直接锁全表,结果线上服务抖动得厉害。后来改用"渐进式扩容"——每次只迁移一个桶,把大操作拆成小步骤,才解决问题。
无锁哈希表:Hazard Pointer 技术
分段锁虽然好,但锁毕竟是锁。在高并发场景下,锁的争抢、上下文切换、缓存行失效,都会带来额外开销。于是就有了无锁方案。
无锁哈希表的核心思路是用 CAS(Compare-And-Swap)原子操作来管理数据。但这里有个大坑:当一个线程删除节点时,另一个线程可能正在读这个节点。怎么保证读线程不会访问到已经被释放的内存?
Hazard Pointer(危险指针)就是来解决这个问题的。它的思路很简单:每个读线程在访问一个节点前,先声明"这个节点我正在用",写线程看到这个声明后,就不会立即释放该节点。
来看核心逻辑:
// 每个线程维护一个 Hazard Pointer 数组
thread_local HazardPointer hazard_ptr;
// 读操作
Node* read(key) {
Node* node;
do {
node = find_node(key);
hazard_ptr.set(node); // 声明:我在用这个节点
if (node->is_deleted()) {
hazard_ptr.clear();
continue; // 节点已被标记删除,重试
}
} while (false);
return node;
}
// 写操作(删除)
void delete_node(Node* node) {
node->mark_deleted();
// 等待所有 Hazard Pointer 不再指向该节点
while (any_hazard_points_to(node)) {
std::this_thread::yield();
}
delete node;
}
这里有个关键点:写线程不能直接删除节点,必须等所有读线程都"放手"了才行。这个等待过程可能会有点慢,但保证了安全性。
注意:Hazard Pointer 的实现非常容易出错。我见过一个团队自己手写,结果漏了某个内存序(memory order),导致线上偶发崩溃。如果你不是特别有把握,建议直接用成熟的库,比如 folly 里的实现。
无锁哈希表的优势很明显:没有锁争抢,吞吐量可以随 CPU 核心数线性增长。但代价也不小:
- 代码复杂度高,调试困难
- 内存回收延迟,可能导致内存占用偏高
- 对 CPU 缓存不友好,因为节点可能被多个线程共享
性能对比测试:数据说话
光说不练假把式。我拿一个实际项目中的测试数据给大家看看。测试环境是 16 核 CPU,每个线程执行 100 万次随机插入和查询操作。
| 实现方式 | 线程数 | 总耗时(秒) | 吞吐量(ops/s) |
|---|---|---|---|
| 全局锁 | 1 | 0.8 | 1.25M |
| 全局锁 | 16 | 12.5 | 1.28M |
| 分段锁(64桶) | 1 | 0.9 | 1.11M |
| 分段锁(64桶) | 16 | 1.2 | 13.3M |
| 无锁(Hazard Pointer) | 1 | 1.1 | 0.91M |
| 无锁(Hazard Pointer) | 16 | 0.9 | 17.8M |
看到没?全局锁在 16 线程时几乎没提升,因为所有线程都在抢同一把锁。分段锁的吞吐量随线程数线性增长,表现非常稳定。无锁方案在单线程时反而更慢(因为 Hazard Pointer 有额外开销),但多线程时优势就出来了——没有锁争抢,吞吐量最高。
我个人建议:如果你的并发量在 8 核以下,分段锁是最稳妥的选择。如果超过 16 核,或者对延迟有极致要求,再考虑无锁方案。
核心逻辑图
下面这张图总结了三种实现方式的演进关系和核心差异:
从图中可以清楚看到,三种方案在并发能力、实现复杂度和调试难度上各有取舍。没有银弹,只有最适合你场景的方案。
我的建议:如果你刚开始做并发哈希表,先从分段锁入手。它足够应付 90% 的场景,而且出问题了容易排查。等你对并发编程有了更深的理解,再挑战无锁方案不迟。
公众号:蓝海资料掘金营,微信deep3321