20251204_线程安全问题及STL数据结构的存储规则

1. 背景

近日在工作中遇到多线程之间访问临界资源,引发数据竞争,导致程序崩溃的问题。

主要是发生了哈希表rehasing导致桶索引更新,导致索引失效而崩溃。

当然要给类中的临界资源加锁来解决,但同时也引起我的疑问:

  1. 哈希表的rehasing发生时机是怎样的?
  2. 哈希表的存储结构是怎样的?
  3. 其他STL的数据结构的存储结构如何?

在给类加锁保证线程安全的过程中,对锁、以及成员函数的调用也有些疑问:

  1. 互斥锁、读写锁的区别是什么?应该如何选择?
  2. 递归锁的开销大吗?什么情况下会用递归锁?
  3. 公有成员函数调用公有成员函数,会导致死锁,怎么处理?这样的设计合理吗?

带着这些疑问,寻找到了答案,在此记录。

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后缀的方法,看起来也很不美观。

暂时就这样吧。

相关推荐
Ka1Yan13 小时前
[链表] - 代码随想录 707. 设计链表
数据结构·算法·链表
scx2013100413 小时前
20260112树状数组总结
数据结构·c++·算法·树状数组
星竹晨L14 小时前
【C++内存安全管理】智能指针的使用和原理
开发语言·c++
宵时待雨14 小时前
数据结构(初阶)笔记归纳3:顺序表的应用
c语言·开发语言·数据结构·笔记·算法
智者知已应修善业14 小时前
【C语言 dfs算法 十四届蓝桥杯 D飞机降落问题】2024-4-12
c语言·c++·经验分享·笔记·算法·蓝桥杯·深度优先
玖釉-14 小时前
[Vulkan 学习之路] 09 - 显卡的流水线工厂:图形管线概览 (Graphics Pipeline)
c++·windows·图形渲染
无限进步_15 小时前
【C语言&数据结构】二叉树遍历:从前序构建到中序输出
c语言·开发语言·数据结构·c++·算法·github·visual studio
CodeByV15 小时前
【算法题】哈希
算法·哈希算法
天赐学c语言15 小时前
1.14 - 用栈实现队列 && 对模板的理解以及模板和虚函数区别
c++·算法·leecode
专注于大数据技术栈16 小时前
java学习--HashSet
java·学习·哈希算法