19、哈希表的线程安全:线程安全问题分析、互斥锁实现、读写锁优化、无锁哈希表简介
多线程环境下操作哈希表,说白了就是一场「谁先改、谁后读」的博弈。我早年做服务器中间件时,就吃过线程安全的亏——一个全局哈希表被多个工作线程同时读写,线上偶发崩溃,查了两天才定位到是并发插入导致链表成环。嗯,从那以后,我对哈希表的线程安全就格外上心。
19.1 线程安全问题分析
哈希表的线程安全问题,核心就三个字:竞态条件。多个线程同时读写同一块内存,结果不可预测。
具体来说,常见的问题场景有:
- 插入冲突:两个线程同时往同一个桶里插节点,可能导致链表断裂或节点丢失。
- 扩容重哈希:一个线程在扩容搬数据,另一个线程在读旧表,读到一半表结构变了,直接崩溃。
- 删除与遍历:一个线程删节点,另一个线程正在遍历链表,访问了已被释放的内存。
- 读-写交织:读线程看到的数据是「半写」状态,比如 key 已经更新但 value 还没写完。
核心结论:哈希表不是天生线程安全的。不加锁,就别在多线程里用。
你可能会问:「我读多写少,不加锁行不行?」我的建议是——别赌。我在项目中见过太多「理论上不会并发」但实际就撞上了的案例。线程安全不是概率问题,是确定性问题。
19.2 互斥锁实现
最简单的方案,就是给整个哈希表加一把大锁。C 语言里常用 pthread_mutex_t。
实现思路:
- 每个操作(插入、查找、删除)前后加锁解锁。
- 锁的粒度是「整个哈希表」。
#include <pthread.h>
typedef struct {
HashTable *table;
pthread_mutex_t lock;
} ThreadSafeHashTable;
void ts_insert(ThreadSafeHashTable *ht, int key, int value) {
pthread_mutex_lock(&ht->lock);
insert(ht->table, key, value);
pthread_mutex_unlock(&ht->lock);
}
int ts_lookup(ThreadSafeHashTable *ht, int key) {
pthread_mutex_lock(&ht->lock);
int val = lookup(ht->table, key);
pthread_mutex_unlock(&ht->lock);
return val;
}
个人经验:这种实现简单粗暴,适合并发量不大的场景。我曾经在一个配置管理模块里这么干过,读写频率低,完全够用。
但缺点也很明显:性能瓶颈。读操作也要等锁,读多写少时浪费严重。
| 方案 | 优点 | 缺点 |
|---|---|---|
| 全局互斥锁 | 实现简单,正确性容易保证 | 并发性能差,读操作互相阻塞 |
| 读写锁 | 读读不互斥,性能更好 | 实现稍复杂,写锁仍可能饿死 |
| 无锁哈希表 | 极高并发,无锁竞争 | 实现极复杂,调试困难 |
19.3 读写锁优化
读操作不修改数据,多个线程同时读是安全的。互斥锁把读也堵死了,太浪费。读写锁(pthread_rwlock_t)就是来解决这个问题的。
核心规则:
- 多个线程可以同时获取读锁。
- 写锁是独占的,获取写锁时,所有读锁必须释放。
- 读锁和写锁互斥。
typedef struct {
HashTable *table;
pthread_rwlock_t rwlock;
} RWSafeHashTable;
void rw_insert(RWSafeHashTable *ht, int key, int value) {
pthread_rwlock_wrlock(&ht->rwlock);
insert(ht->table, key, value);
pthread_rwlock_unlock(&ht->rwlock);
}
int rw_lookup(RWSafeHashTable *ht, int key) {
pthread_rwlock_rdlock(&ht->rwlock);
int val = lookup(ht->table, key);
pthread_rwlock_unlock(&ht->rwlock);
return val;
}
注意:读写锁不是银弹。如果写操作频繁,写锁会频繁阻塞读锁,甚至导致读线程饿死。我曾在日志系统中踩过这个坑——写日志的线程少,但每次写都抢锁,读线程几乎拿不到锁。
读写锁适合「读远多于写」的场景。比如缓存系统、配置中心、路由表等。
19.4 无锁哈希表简介
锁再好,也有开销。上下文切换、缓存失效、锁竞争……在高并发场景下,锁可能成为瓶颈。于是有了无锁(Lock-Free)哈希表。
无锁哈希表的核心思想:
- 使用 CAS(Compare-And-Swap)原子操作来更新数据。
- 不依赖操作系统锁,全靠 CPU 指令保证原子性。
- 通常配合分离链表或桶级锁来降低冲突。
一个简单的无锁插入伪代码:
// 假设每个桶是单向链表,头指针用原子变量
void lockfree_insert(AtomicPtr *head, Node *new_node) {
do {
new_node->next = atomic_load(head);
} while (!atomic_compare_exchange_weak(head, &new_node->next, new_node));
}
这里用 CAS 循环把新节点插入链表头部。如果其他线程同时插入,CAS 会失败,重试即可。
关键点:无锁不等于没有竞争。它只是把竞争从「锁等待」变成了「CAS 重试」。重试次数多了,性能也会下降。
无锁哈希表的难点:
- 内存管理:删除节点时,不能立即释放内存,因为可能有其他线程还在读它。需要引用计数或 RCU(Read-Copy-Update)。
- ABA 问题:CAS 操作中,指针值从 A 变成 B 又变回 A,CAS 会误判为没变化。需要额外标记位解决。
- 调试困难:并发 bug 难以复现,死循环或数据损坏很难定位。
我的建议:除非你的场景真的需要百万级 QPS 且不能容忍锁开销,否则别轻易上无锁哈希表。我见过太多团队花几周写无锁代码,最后性能还不如一把读写锁。先测量,再优化。
19.5 知识体系总览
下面这张图总结了哈希表线程安全的三种方案及其适用场景:
从互斥锁到读写锁,再到无锁哈希表,每一步都是在「正确性」和「性能」之间做权衡。我个人习惯是:先保证正确,再考虑优化。不要一开始就追求无锁,除非你真的很清楚自己在做什么。
避坑指南:我曾经在一个项目中用无锁哈希表存储网络连接状态,结果因为 ABA 问题导致连接泄漏,排查了整整三天。后来换成读写锁,性能只下降了 5%,但代码可读性和稳定性大幅提升。有时候,「够用」比「极致」更重要。
公众号:蓝海资料掘金营,微信deep3321