oneapi::tbb::concurrent_hash_map 的 Size 与 Capacity 语义解析:empty / size / max_size 的并发实现与使用要点
【免费下载链接】moldmold: A Modern Linker 🦠项目地址: https://gitcode.com/GitHub_Trending/mo/mold
导读
oneapi::tbb::concurrent_hash_map是 oneAPI Threading Building Blocks(TBB,即本仓库third-party/tbb/目录下随附的并发容器库)提供的无序关联容器,支持多线程并发插入、查找与删除。本文聚焦该容器"尺寸与容量"一族接口——empty()、size()与max_size(),结合规范文档、头文件实现与一致性测试,讲清三者的返回值语义、并发环境下的"弱一致性"行为、底层原子计数器实现,以及在实际并发编程中如何正确使用这些接口。
一、接口全景:三个成员函数的规范定义
该主题对应的规范文档位于 size_and_capacity.rst,它定义了concurrent_hash_map类模板中三个与尺寸、容量相关的成员函数,声明如下:
// Defined in header <oneapi/tbb/concurrent_hash_map.h> bool empty() const; size_type size() const; size_type max_size() const;其中size_type在类模板中定义为"实现定义的(implementation-defined)无符号整数类型"(见 concurrent_hash_map_cls.rst 的类模板概要),实际实现中为std::size_t。
这三个函数均声明为const,可以安全地在任意线程上对同一个容器实例调用,无需持有访问器(accessor)锁,也不会阻塞正在进行的插入、删除操作。
empty():容器是否为空
bool empty() const;- 返回值:容器为空时返回
true,否则返回false。 - 并发语义:规范文档特别强调——在有并发的插入或删除操作尚未完成(pending)时,返回值可能与容器的实际状态不一致。
size():当前元素数量
size_type size() const;- 返回值:容器中元素的个数(键值对数量,键唯一)。
- 并发语义:与
empty()相同,当其他线程正在插入或删除元素时,返回值可能滞后或超前于"此刻"的真实状态,规范明确允许这种偏差。
max_size():容量上限
size_type max_size() const;- 返回值:容器理论上可以容纳的最大元素数量。
- 含义:这是一个理论上的上界,由底层内存分配器的能力决定,而非当前哈希表已配置的桶(bucket)数量。实际能否达到该上限,取决于运行时的物理内存与分配器约束。
关联阅读:与容量直接相关的另一个接口是
bucket_count()与rehash(),它们属于"哈希策略(Hash policy)"小节,见 hash_policy.rst。size()反映"已存放的元素个数",而bucket_count()反映"当前哈希桶数量",二者不要混淆。
二、源码级验证:三个函数在头文件中的真实实现
规范文档只给出接口契约,真正能回答"为什么并发下结果可能不一致"的是实现。三个函数的实现都位于 concurrent_hash_map.h(约第 1070–1079 行):
// Number of items in table. size_type size() const { return this->my_size.load(std::memory_order_acquire); } // True if size()==0. __TBB_nodiscard bool empty() const { return size() == 0; } // Upper bound on size. size_type max_size() const { return allocator_traits_type::max_size(base_type::get_allocator()); }从中可以提炼出三个关键实现事实:
empty()不维护独立的标志位:它直接等价于size() == 0。因此empty()与size()共享同一套并发语义——它们读到的都是同一个原子计数器的快照。size()读取的是原子计数器my_size,使用std::memory_order_acquire内存序。这意味着读取操作自身是线程安全的、无锁的、不会与正在进行的修改相互破坏,但读到的数值只是某个时刻的快照,不代表调用返回瞬间容器的精确状态。max_size()委托给分配器:返回值来自std::allocator_traits<Allocator>::max_size(allocator),即concurrent_hash_map使用的分配器(默认是tbb_allocator<std::pair<const Key, T>>)所能分配的最大对象数量。因此max_size()的结果取决于分配器类型与系统配置,与当前已分配的内存无关。
my_size:一个被精心隔离的原子计数器
从源码结构看,my_size在基类hash_map_base中定义,并且它的布局经过了性能考量。在 concurrent_hash_map.h 第 355–364 行:
protected: bucket_allocator_type my_allocator; // Hash mask = sum of allocated segment sizes - 1 std::atomic<hashcode_type> my_mask; // Size of container in stored items std::atomic<size_type> my_size; // It must be in separate cache line from my_mask due to performance effects // Zero segment bucket my_embedded_segment[embedded_buckets]; // Segment pointers table. Also prevents false sharing between my_mask and my_size segments_table_type my_table;源码注释明确说明了两点:my_size必须与my_mask(哈希掩码)处于不同的缓存行,且中间的my_table段指针表起到了**防止伪共享(false sharing)**的作用。原因是my_mask在每次扩容、查找时被高频读取,而my_size在每次插入、删除时被高频读写,二者若挤在同一缓存行,会在多核竞争下造成严重的缓存行颠簸。这一布局细节解释了为什么size()可以做到无锁且开销极低。
计数器的更新点
通过检索头文件中所有my_size的读写点,可以完整还原计数器的生命周期:
| 操作 | 源码位置 | 实现 |
|---|---|---|
| 插入新节点 | 第 290 行size_type sz = ++my_size; | 前缀自增(强制保证插入首元素后完成计数),并随后依据sz >= mask判断是否需要扩容 |
| 按 key 删除 | 第 1414 行this->my_size--; | 删除链表节点后递减 |
| 通过 accessor 删除 | 第 1459 行this->my_size--; | 同上 |
清空容器clear() | 第 1013 行this->my_size.store(0, std::memory_order_relaxed); | 直接清零 |
| 拷贝构造 | 第 1506 行this->my_size.fetch_add(1, std::memory_order_relaxed); | 逐节点复制时递增 |
| 移动构造 | 第 340–341 行 | 整体搬移计数器,源容器归零 |
交换swap | 第 320 行 | 原子交换两个计数器 |
这些更新点表明:size()的数值由所有修改操作以原子方式共同维护,任何时刻读取都是"某个已提交或未完全提交的中间值"。例如,插入操作先递增计数器、再完成桶内链表链接(或反过来分阶段进行),另一个线程恰好在两步之间调用size(),读到的就是偏大或偏小的近似值——这正是规范文档中"结果可能不同于实际容器状态"这一条款的实现根源。
三、并发语义深度解读:为什么 size() 不是精确值
标准库std::unordered_map的size()是精确的,因为它不允许并发修改;而concurrent_hash_map的设计目标是让读写线程互不阻塞,因此放弃了"强一致计数"。
具体来说,并发下的偏差来源于以下场景:
- 进行中的插入:节点已创建但尚未完成计数,或计数已递增但节点尚未对所有读者可见时,
size()可能比真实值小或大。 - 进行中的删除:节点已被摘除但计数尚未递减时,
size()可能偏大。 - 并发扩容:rehash 过程中节点在旧桶与新桶间迁移,计数器的更新与迁移不一定是同一个原子步骤。
因此规范文档对empty()与size()给出了完全相同的约束说明:"The result may differ with the actual container state in case of pending concurrent insertions or erasures."(在并发的插入或删除尚未完成时,结果可能不同于容器的实际状态。)
对编程实践的三点启示
- 不要用
size()判断"遍历是否完成":在迭代容器(包括并行range)的同时观察size(),得到的只是参考值。遍历是否结束应以迭代器本身为准,参考 iterators.rst 与并行迭代一节。 empty()适合做"低精度快速判断":例如在任务分配前判断"工作队列是否还有活干"。由于它只是size()==0的一次原子读,代价极小;但要接受偶发的不一致,必要时配合其他同步机制兜底。max_size()用于容量规划而非运行时判断:它回答的是"理论最多能装多少",与当前元素数、当前桶数无关。若需要判断是否需要扩容,应使用哈希策略接口rehash(n)/bucket_count(),见 hash_policy.rst。
四、一致性测试中的用法验证
TBB 自带的一致性测试 conformance_concurrent_hash_map.cpp 展示了这三个接口在测试代码中的标准用法,也印证了文档语义:
template<typename test_table_type> static void CheckTable( const test_table_type& x, int n ) { REQUIRE_MESSAGE( x.size()==size_t(n), "table is different size than expected" ); CHECK(x.empty()==(n==0)); CHECK(x.size()<=x.max_size()); // ... }这里size()被用于校验操作后的精确元素数(测试环境为单线程或无并发竞争),empty()与n==0等价被验证,size() <= max_size()则作为不变量断言。此外测试中还有:
CHECK(!test_map.empty());——对用初始化列表构造的{{1, 2}, {2, 4}}容器验证非空;CHECK_FAST(b==!a.empty());——在find成功与否与accessor::empty()之间建立一致性断言(注意此处是 accessor 的empty(),用于判断查找结果是否为空,语义与容器的empty()不同);CHECK(int(v.size()) == i);与CHECK(int(v.bucket_count()) <= j);的组合——验证在指定rehash(j)后,元素数保持i不变而桶数不超过j,说明size()不受 rehash 影响,只反映元素个数。
这也提醒使用者:容器级empty()/size()与访问器级accessor::empty()/release()是两套不同语义的接口,前者回答"容器有没有元素",后者回答"当前访问器是否绑定到了某个节点"。
五、总结:三个接口的对比与选用
| 接口 | 返回语义 | 并发下的行为 | 实现位置(concurrent_hash_map.h) | 典型用途 |
|---|---|---|---|---|
empty() | 容器是否为空 | 可能滞后/超前于真实状态 | 第 1074 行,等价于size()==0 | 低开销的粗略判断 |
size() | 当前元素个数 | 原子快照,可能不一致 | 第 1071 行,my_size.load(acquire) | 统计、进度上报、调试 |
max_size() | 理论容量上限 | 恒定,不随内容变化 | 第 1077–1079 行,委托分配器 | 容量规划、上界判断 |
一句话记住三者的关系:empty()是size()==0的语法糖,size()是原子计数器的一次 acquire 读,max_size()是分配器能力的一次询问。在编写多线程程序时,只要接受"并发下数值是近似快照"这一前提,这套接口就能以极低的开销为你的并发哈希表提供稳定的尺寸感知能力。
【免费下载链接】moldmold: A Modern Linker 🦠项目地址: https://gitcode.com/GitHub_Trending/mo/mold
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考