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

暂时就这样吧。

相关推荐
hweiyu0037 分钟前
数据结构:平衡二叉树
数据结构
9523641 分钟前
并查集 / LRUCache
数据结构·算法
深思慎考42 分钟前
微服务即时通讯系统(服务端)——网关服务设计与实现(7)
linux·c++·微服务·云原生·架构
枫叶丹41 小时前
【Qt开发】Qt窗口(六) -> QMessageBox 消息对话框
c语言·开发语言·数据库·c++·qt·microsoft
橘颂TA1 小时前
【Linux】进程池
linux·运维·服务器·c++
“αβ”9 小时前
MySQL表的操作
linux·网络·数据库·c++·网络协议·mysql·https
potato_may9 小时前
链式二叉树 —— 用指针构建的树形世界
c语言·数据结构·算法·链表·二叉树
Mz12219 小时前
day07 和为 K 的子数组
数据结构
十五年专注C++开发10 小时前
Asio2: 一个基于 Boost.Asio 封装的高性能网络编程库
网络·c++·boost·asio·asio2