1. 背景
近日在工作中遇到多线程之间访问临界资源,引发数据竞争,导致程序崩溃的问题。
主要是发生了哈希表rehasing导致桶索引更新,导致索引失效而崩溃。
当然要给类中的临界资源加锁来解决,但同时也引起我的疑问:
- 哈希表的rehasing发生时机是怎样的?
- 哈希表的存储结构是怎样的?
- 其他STL的数据结构的存储结构如何?
在给类加锁保证线程安全的过程中,对锁、以及成员函数的调用也有些疑问:
- 互斥锁、读写锁的区别是什么?应该如何选择?
- 递归锁的开销大吗?什么情况下会用递归锁?
- 公有成员函数调用公有成员函数,会导致死锁,怎么处理?这样的设计合理吗?
带着这些疑问,寻找到了答案,在此记录。
2. STL中哈希表的存储结构及rehashing改变了什么?
STL中unordered_map, unordered_set 的底层实现都是哈希表。
我主要研究的是unordered_map,但我想unordered_set应该也大差不差吧,只是不是键值对的形式了。
2.1 哈希表的存储结构
STL中哈希表是由一个桶数组和若干链表组成。这些数据都是在堆内存中动态分配的,所以类中增加一个哈希表结构,这个类增加的内存跟哈希表实际存储的数据类型无关,只与哈希表这个数据结构本身成员有关,是固定的:
cpp
class std::unordered_map<Key, T, Hash, KeyEqual, Allocator> {
private:
// 主要成员变量(简化表示,实际实现可能不同但大小固定)
HashNode** bucket_array; // 指针:8字节
size_t bucket_count; // size_t:8字节
size_t element_count; // size_t:8字节
float max_load_factor; // float:4字节(填充到8字节)
Hash hasher; // 空类:通常1字节(空基类优化)
KeyEqual key_equal; // 空类:通常1字节(空基类优化)
Allocator allocator; // 空类:通常1字节(空基类优化)
// 其他实现细节...
};
其他的STL数据结构也是如此,真实数据都在堆内存中。所有STL容器都遵循"小对象控制大内存"的原则
桶数组是一块连续的内存空间。 数组中每个元素要么为空(nullptr),要么存储的是一个链表的头指针。链表的每个节点存储这key-value键值对,以及next指针指向下一节点。
当插入一个元素key-value时,通过key计算出桶索引bucket_index。
- 若该位置上没有链表,则新建链表,key-value组成该链表的第一个节点。
- 若该位置上已经有链表,则将以key-value创建的节点挂在链表上。
2.2 rehasing会改变什么?
哈希表中维护着桶的大小、负载因子等基本的数据。当负载因子较大时,会触发rehasing。
rehasing只会改变key的桶索引。 链表中的节点在内存中的地址并不改变。
所以,rehasing时,指向元素的指针、引用都不会失效。但是迭代器会失效。
3. 锁的使用
3.1 互斥锁与递归锁
cpp
// 普通互斥锁(简单)
class std::mutex {
atomic_flag flag; // 简单的原子标志
};
// 递归互斥锁(复杂)
class std::recursive_mutex {
atomic_flag flag;
thread::id owner_thread; // 需要记录所有者线程
int recursion_count; // 需要记录递归次数
// 更多状态管理逻辑...
};
递归锁的速度与内存开销都要远大于互斥锁。因此,非必要不使用递归锁。
只有在以下极少数情况下可以考虑递归锁:
- 第三方库的兼容性包装
- 遗留代码的临时解决方案
- 性能不敏感的工具类
- 确实无法修改的接口设计
3.2 互斥锁与读写锁
读写锁的性能也是比互斥锁要大不少的,因此也是要考虑性价比。
根据具体的业务逻辑场景去选择。 如果读多写少,就用读写锁。如果一半一半,甚至写多读少,就用互斥锁。
C++ STL的读写锁貌似并没有规定要解决写锁饥饿问题。 因此,使用读写锁时,要考虑写锁饥饿问题。 但如果只有两三个线程的并发,应该问题不大。
4. public成员函数调用public成员函数,导致加锁困难
这种情况从软件工程的角度看,违反了一些原则:
- 单一职责原则
balabala之类的原则。
那么怎么办呢?
比如,公有函数funA()调用funB();
将funB中的实现提取到一个私有函数中funcB_impl(),然后funA()和funB()调用这个私有函数。
私有函数不加锁,公有函数都加锁。这样解决。
但这样,多了很多_impl后缀的方法,看起来也很不美观。
暂时就这样吧。