ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

oneAPI TBB concurrent_hash_map 的串行修改操作 clear 与 swap:语义、源码实现与并发安全边界

oneAPI TBB concurrent_hash_map 的串行修改操作 clear 与 swap:语义、源码实现与并发安全边界 oneAPI TBB concurrent_hash_map 的串行修改操作 clear 与 swap语义、源码实现与并发安全边界【免费下载链接】moldmold: A Modern Linker 项目地址: https://gitcode.com/GitHub_Trending/mo/mold导读concurrent_hash_map是 oneAPI Threading Building BlocksTBB提供的线程安全哈希表但并非所有成员函数都允许并发调用。本文基于本仓库内嵌 oneTBB 的规范文档 unsafe_modifiers.rst完整讲解其中定义的并发不安全修改操作——clear()与swap()它们的精确语义、分配器传播规则、未定义行为边界并结合 concurrent_hash_map.h 的源码实现与 test_concurrent_hash_map.cpp 的测试用例讲清为什么这两个操作只能串行执行。读完你将掌握在真实并发程序里安全、正确地使用clear/swap的完整方案。一、什么是并发不安全修改操作在 oneTBB 的规范中concurrent_hash_map的成员函数被划分为多个能力分区分别记录在 concurrent_hash_map_cls 目录下的不同文档中并发安全的查询与修改如insert、emplace、erase、lookup等见 modifiers.rst 与 lookup.rst。这些方法可以彼此并发执行也可以与查找方法并发执行并发安全的访问通过accessor/const_accessor对键值对进行读写访问见 accessors.rst只能串行执行的修改即本节要讲的clear()与swap()。unsafe_modifiers.rst 开篇给出了对整个分区统一且严格的约束All member functions in this section can only be performed serially. The behavior is undefined in case of concurrent execution of these member functions with other (either concurrently safe) methods.翻译过来就是本节所有成员函数只能串行执行若这些成员函数与其它即使是并发安全的方法并发执行行为是未定义的undefined behavior, UB。注意这里的措辞非常严格——不是可能导致数据竞争而是直接定性为 UB。也就是说即便你在一个线程里调用clear()另一个线程只是在执行insert()这样的并发安全操作整个程序的行为依然是未定义的。因此在使用时必须保证执行clear/swap的时间窗口内没有任何其它线程在触碰这个容器包括查询、遍历、插入、删除。二、clear一次性清空整个容器2.1 接口与语义规范文档定义的接口非常简单void clear();语义移除容器中的所有元素Removes all elements from the container。这是最直观的理解调用后容器变为空size()返回 0。但结合源码实现可以看出clear()远比清空元素做得多——它还会回收底层存储结构见下文。2.2 源码实现不止删除节点还重建了桶表在 concurrent_hash_map.h 中可以找到clear()的完整实现其逻辑可以拆解为四步// Clear table void clear() { hashcode_type m this-my_mask.load(std::memory_order_relaxed); __TBB_ASSERT((m(m1))0, data structure is invalid); this-my_size.store(0, std::memory_order_relaxed); // 1. 元素计数清零 segment_index_type s this-segment_index_of( m ) 1; __TBB_ASSERT( s this-pointers_per_table || !this-my_table[s].load(std::memory_order_relaxed), wrong mask or concurrent grow ); while(s ! 0) { // 2. 逐段遍历 s--; __TBB_ASSERT(this-is_valid(this-my_table[s].load(std::memory_order_relaxed)), wrong mask or concurrent grow ); segment_ptr_type buckets_ptr this-my_table[s].load(std::memory_order_relaxed); size_type sz this-segment_size( s ? s : 1 ); for( segment_index_type i 0; i sz; i ) // 3. 逐桶摘链删除节点 for( node_base *n buckets_ptr[i].node_list.load(std::memory_order_relaxed); this-is_valid(n); n buckets_ptr[i].node_list.load(std::memory_order_relaxed) ) { buckets_ptr[i].node_list.store(n-next, std::memory_order_relaxed); delete_node( n ); } this-delete_segment(s); // 4. 回收整段桶数组 } this-my_mask.store(this-embedded_buckets - 1, std::memory_order_relaxed); }四个关键点计数直接归零my_size是一个原子变量clear()用 relaxed 内存序将其直接置 0不再等待逐节点递减从高段到低段遍历concurrent_hash_map采用分段segment桶表结构这一点可以从segment_index_of、pointers_per_table、my_table[]等字段看出clear()按段号从高到低逐个处理每个桶的链表逐个摘除并销毁节点对每段中的每个桶循环取出链表头节点、把node_list指向下一个节点、调用delete_node释放——这是一次对全表的完整线性扫描与销毁回收段存储并把桶数重置回初始值调用delete_segment释放段内存最后把my_mask重新置为embedded_buckets - 1。其中第 4 步非常能说明问题concurrent_hash_map把初始的一小组桶embedded_buckets直接内嵌在对象内部clear()完成后哈希表的掩码被重置为内嵌桶的数量减一也就是说清空后哈希表会收缩回最初始的桶规模而不是保留清空前的大桶表。这意味着clear()不只是逻辑清空还会带来一次完整的结构重建与内存回收其开销与容器当前元素总数和段规模成正比。2.3 clear 是析构与拷贝赋值的底层原语从源码可以看到clear()并不只是一个独立的公开接口它还是其它生命周期操作复用的底层原语析构函数~concurrent_hash_map() { clear(); }concurrent_hash_map.h——析构直接调用clear()来完成元素销毁与存储回收拷贝赋值operator(const concurrent_hash_map)中先调用clear()清空自身再执行copy_assign_allocators与internal_copyconcurrent_hash_map.h异常安全回滚各类构造函数拷贝构造、区间构造、initializer_list 构造都用try_call(...).on_exception([]{ this-clear(); })的形式在复制过程中抛出异常时调用clear()做部分清理concurrent_hash_map.h。从这些调用关系可以看出clear()承担的是把容器彻底复位到初始空状态的角色因此它的破坏性极强、涉及全局结构这也是它被划入串行专属分区的原因之一。2.4 使用示例#include oneapi/tbb/concurrent_hash_map.h #include string using map_type oneapi::tbb::concurrent_hash_mapstd::string, int; void demo_clear() { map_type m; m.emplace(alpha, 1); m.emplace(beta, 2); // 前提此刻没有任何其它线程在执行 m 的查询/插入/删除/遍历 m.clear(); // 移除全部元素 // m.size() 0且桶表已收缩回初始规模 map_type::accessor acc; bool inserted m.insert(acc, gamma, 3); // clear 之后再并发使用是安全的 }需要强调m.clear()与后续m.insert(...)之间必须有一个明确的串行化边界例如在单线程初始化阶段完成或用外部互斥量保护才能保证程序行为定义良好。三、swap交换两个容器的内容3.1 接口与语义规范文档定义的接口void swap( concurrent_hash_map other );语义包含两层交换内容交换*this与other中保存的全部键值对Swaps contents of*thisandother交换分配器有条件如果std::allocator_traitsallocator_type::propagate_on_container_swap::valuePOCS为true则同时交换两个容器的分配器否则如果get_allocator() ! other.get_allocator()行为是未定义的。这正是 C 容器分配器约定AllocatorAwareContainer在并发容器上的体现swap是唯一允许传播分配器的操作但它不强制传播——是否传播完全取决于分配器类型静态声明的propagate_on_container_swap。3.2 源码实现两派实现 一条断言swap()的公开实现位于 concurrent_hash_map.h// swap two instances. Iterators are invalidated void swap(concurrent_hash_map table) { using pocs_type typename node_allocator_traits::propagate_on_container_swap; using is_equal_type typename node_allocator_traits::is_always_equal; swap_allocators(this-my_allocator, table.my_allocator); internal_swap(table, tbb::detail::disjunctionpocs_type, is_equal_type()); }注意源码注释直接点明了重要副作用swap two instances. Iterators are invalidated——交换后迭代器全部失效。这与std::unordered_map的swap不使迭代器失效的行为不同是使用时必须知道的差异。swap_allocators负责处理分配器的传播POCS 为真时互换分配器随后根据编译期条件分发到两个internal_swap重载concurrent_hash_map.hvoid internal_swap(concurrent_hash_map other, /*is_always_equal || POCS */ std::true_type) { this-internal_swap_content(other); } void internal_swap(concurrent_hash_map other, /*is_always_equal || POCS */ std::false_type) { __TBB_ASSERT(this-my_allocator other.my_allocator, nullptr); this-internal_swap_content(other); }这里的tbb::detail::disjunctionpocs_type, is_equal_type是POCS 为真或分配器恒等is_always_equal的编译期判定若为true_typePOCS 为真或分配器类型声明永远相等说明分配器可以安全互换直接交换内容若为false_type说明分配器可能不同此时代码在 debug 构建下用__TBB_ASSERT检查my_allocator other.my_allocator不满足该条件即断言失败——这正是规范中分配器不相等时行为未定义在实现层的具体化release 构建下不做任何保护直接交换内容后果由调用者承担。真正的内容交换发生在internal_swap_contentconcurrent_hash_map.h 附近用std::swap配合 relaxed 原子操作交换my_mask桶掩码与my_size元素计数再逐个交换内嵌桶段my_embedded_segment[i].node_list的链表头最后交换扩展段表。可以看到swap本质上是交换两个容器内部所有结构指针与计数属于对整张表的整体性操作不存在逐元素移动的中间状态因此与任何并发的插入/删除/增长都无法共存——这从机制上解释了它为何被归类为串行专属操作。3.3 非成员 swap 重载规范文档还在 non_member_swap.rst 中定义了配套的非成员函数template typename Key, typename T, typename HashCompare, typename Allocator void swap( concurrent_hash_mapKey, T, HashCompare, Allocator lhs, concurrent_hash_mapKey, T, HashCompare, Allocator rhs );其语义为lhs.swap(rhs)即直接转发给成员函数。源码中的实现也印证了这一点concurrent_hash_map.hinline void swap(concurrent_hash_mapKey, T, HashCompare, A a, concurrent_hash_mapKey, T, HashCompare, A b) { a.swap( b ); }因此无论使用a.swap(b)还是std::swap/ADL 找到的swap(a, b)语义完全一致同样受到只能串行执行的约束。3.4 使用示例#include oneapi/tbb/concurrent_hash_map.h using map_type oneapi::tbb::concurrent_hash_mapint, double; void demo_swap() { map_type a, b; a.emplace(1, 1.0); b.emplace(2, 2.0); // 前提此刻没有任何其它线程在执行 a 或 b 的任何操作 a.swap(b); // 之后 a 含 {2, 2.0}b 含 {1, 1.0} // 或者swap(a, b); // 交换后旧迭代器全部失效必须重新通过 find/insert 获取访问器 }如果自定义了分配器务必保证两个容器使用相等的分配器或让分配器声明propagate_on_container_swap/is_always_equal否则程序处于未定义行为区间。四、为什么 clear/swap 不能并发与并发安全操作的机制对比要真正理解串行专属分区的存在意义需要对照并发安全操作的设计原理。modifiers.rst 中定义的insert/emplace/erase之所以可以并发是因为它们被设计成细粒度、局部化的操作每个键值对由独立的node承载通过**每桶锁per-bucket locking**与访问器accessor/const_accessor见 accessors.rst机制保护并发插入/删除只影响少数相关桶操作粒度小可以靠锁与原子操作安全地交错执行哈希表在并发增长扩容时也通过分段segment设计让增长操作只影响正在扩展的那一段。而clear()与swap()则完全不同clear()需要线性遍历全表所有段、所有桶、所有链表节点并逐一销毁同时回收整段内存、重置掩码——这是一个覆盖全结构的操作与任何正在进行的插入/删除哪怕只涉及一个桶都必然产生交错破坏swap()需要整体交换掩码、计数、内嵌桶段与扩展段表——它把两个容器的内部结构对调任何持有旧结构引用的并发操作都会立即指向错误的位置源码注释也明确给出警告swap会使迭代器失效concurrent_hash_map.h。从源码结构可以推断oneTBB 为这两类操作选择了不同的线程安全策略对结构性整体变更不提供任何并发保护而是把并发责任完全交给调用者并配合规范文档中的UB声明避免为全表扫描/整体交换这类罕见操作付出常驻的性能代价例如为每个桶加全局大锁。这是典型的零开销抽象设计取舍。五、测试验证swap 与 clear 的语义在测试中的体现本仓库内嵌 oneTBB 的测试 test_concurrent_hash_map.cpp 中有直接覆盖这两个操作的用例c2.swap( c ); CHECK(CompareTablesvalue_type::IsEqual( c2, const_c )); CHECK(c.size() 5); // ... c2.clear(); CHECK(CompareTablesvalue_type::IsEqual( c2, Table() ));见 test_concurrent_hash_map.cpp对swap交换后用CompareTables::IsEqual逐元素比对确认两个容器内容确实互换对clear清空后用空表Table()比对验证clear()后容器与全新构造的空容器完全一致。此外在移动语义与emplace相关的测试段落中test_concurrent_hash_map.cpp多次穿插调用map1.clear(); map2.clear();来复用容器实例这也间接验证了串行阶段清空、随后继续并发使用的典型生命周期模式。从这些测试可以看出clear/swap的正确性验证都发生在单线程、无并发交错的场景下进一步印证了规范中对只能串行执行的定位。六、实战建议与注意事项总结综合规范文档、源码实现与测试用例在真实项目中安全使用clear与swap应遵循以下原则严格串行化调用clear()/swap()时必须保证没有其它线程在执行该容器的任何方法包括并发安全的insert、erase、find、range遍历等。常见做法包括在容器还处于单线程阶段如程序初始化/收尾时完成清空与交换用一个外部std::mutex把所有触碰该容器的操作含串行专属操作统一串行化通过任务调度保证这些操作与其它访问不存在时间交叠。注意迭代器与访问器失效swap会失效全部迭代器源码注释明确声明clear之后所有之前获取的accessor/const_accessor与迭代器自然也不再指向有效元素。交换/清空后应重新通过find/insert获取访问器。分配器规则swap只在propagate_on_container_swap为真时交换分配器若该值为假则要求两个容器分配器相等get_allocator() other.get_allocator()否则行为未定义。源码在 debug 构建下用__TBB_ASSERT(this-my_allocator other.my_allocator, nullptr)捕获该错误concurrent_hash_map.h。性能预期clear()会遍历并销毁全表所有节点、回收段内存并把桶规模重置回内嵌初始大小my_mask重置为embedded_buckets - 1开销与容器规模成正比不宜在热路径上频繁调用若只是要逻辑清空而希望保留容量需要结合业务自行权衡oneTBB 未提供独立保留容量的串行清空接口。与标准容器的差异与std::unordered_map::clear/swap相比concurrent_hash_map的这两个操作有额外的并发约束UB 声明与迭代器失效差异移植代码时不要默认二者行为一致。规范一致性如果团队以本仓库的 unsafe_modifiers.rst 为 API 契约那么clear/swap 仅串行这一约束就是所有使用方必须共同遵守的接口前提可在 Code Review 与静态检查中显式标注。把clear与swap当作需要外部同步保护的全局结构操作来使用而不是线程安全容器的普通方法是避免未定义行为、发挥concurrent_hash_map并发能力的关键。【免费下载链接】moldmold: A Modern Linker 项目地址: https://gitcode.com/GitHub_Trending/mo/mold创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表