深入理解 oneTBBconcurrent_lru_cache::handle:并发 LRU 缓存的值访问代理对象
【免费下载链接】moldmold: A Modern Linker 🦠项目地址: https://gitcode.com/GitHub_Trending/mo/mold
导读
concurrent_lru_cache::handle是 oneTBB(oneAPI Threading Building Blocks)并发 LRU 缓存容器对外暴露的唯一值访问入口:它既是“引用计数器”,又是“读写窗口”,用户必须通过它读取或修改缓存中存储的值。本文以 handle_object_cls.rst 规范文档为主体,结合本仓库内 oneTBB 的 concurrent_lru_cache.h 真实实现与 test_concurrent_lru_cache.cpp 测试用例,完整讲解 handle 的生命周期语义、C++11 前后的移动语义差异、全部成员函数契约,以及它在容器驱逐策略中的关键作用,帮助你在多线程场景下安全、正确地使用这个代理对象。
注:
concurrent_lru_cache是 oneTBB 的预览(preview)特性,位于本仓库 third-party/tbb 依赖目录中,使用前需定义宏TBB_PREVIEW_CONCURRENT_LRU_CACHE。
1. handle 是什么:缓存的“代理值”而不是“值本身”
concurrent_lru_cache容器将键映射到值,并允许限制存储的未使用值的数量。它不会直接把值交给调用方,而是返回一个代理对象——concurrent_lru_cache::handle。规范文档(handle_object_cls.rst)将其定义为:
Class that provides read and write access to values stored in
concurrent_lru_cache.
即:一个提供对concurrent_lru_cache中所存值的读写访问的类。
为什么容器要“绕一道”而不是直接返回引用?核心原因在于容器需要精确跟踪每个值的“在用”状态。从源码看,concurrent_lru_cache.h 中operator[]的实现先通过聚合器(aggregator)串行化检索操作,再将结果包装成 handle 返回:
handle operator[](key_type key) { retrieve_aggregator_operation op(key); my_aggregator.execute(&op); if (op.is_new_value_needed()) { op.result().second.my_value = my_value_function(key); op.result().second.my_is_ready.store(true, std::memory_order_release); } else { spin_wait_while_eq(op.result().second.my_is_ready, false); } return handle(*this, op.result()); }返回的 handle 内部持有两个指针:指向缓存对象的my_lru_cache_ptr和指向存储记录(map 节点)的my_map_record_ptr(见 handle_object 结构体)。正是这两个指针,让 handle 能够在析构/移动时向容器发出“使用结束”信号,从而驱动 LRU 历史列表的维护。
2. 生命周期语义:引用计数与“在用”判定
handle 最核心的语义是引用保持:
- 一个存活的
handle对象会阻止容器删除它引用的值; - 当 handle 被重新赋值或销毁时,引用被释放;
- 当一个值的最后一个引用被释放后,容器才被允许删除该值。
换句话说,concurrent_lru_cache的策略是:存储所有当前在用的值,加上有限数量的未用值;多余的未用值按“最近最少使用”(LRU)策略淘汰。一个值是否“在用”,完全由是否存在指向它的 handle 决定。
这一机制在 signal_end_of_usage_serial 中体现得淋漓尽致:当 handle 析构时调用signal_end_of_usage,通过聚合器执行引用计数递减;当my_ref_counter归零时,该值进入 LRU 历史列表my_history_list;若历史已满,则从链表尾部驱逐最旧项并擦除 map 记录:
// if it was the last reference, put it to the LRU history if (! --(map_it->second.my_ref_counter)) { // if the LRU history is full, evict the oldest items to get space if (my_history_list.size() >= my_history_list_capacity) { ... my_storage_map.erase(map_it_to_evict); } my_history_list.push_front(map_it); map_it->second.my_history_list_iterator = my_history_list.begin(); }而值的“领取”过程发生在 retrieve_serial:命中时先从 LRU 历史中摘除该记录(因为即将被使用),再执行++(map_it->second.my_ref_counter)。因此从源码结构可以推断:每创建一个 handle,引用计数加一;每个 handle 析构,引用计数减一,容器的驱逐与保留完全以此为准。
3. 不可复制:为什么 handle 只能移动
规范明确:handle对象不能被复制。这保证了同一份“引用”在同一时刻只存在于一个 handle 中,引用计数语义不会被复制操作破坏。
针对不同 C++ 标准,转移引用所有权的方式不同:
| C++ 标准 | 转移机制 | 说明 |
|---|---|---|
| C++11 及以后 | 移动语义(move semantics) | 移动构造与移动赋值转移引用 |
| C++11 之前 | handle_move_t辅助类型 | 通过到/来自handle_move_t的隐式转换转移引用 |
规范文档给出的完整类定义(handle_object_cls.rst)如下:
namespace oneapi { namespace tbb { template <typename key_type, typename value_type, typename value_functor_type> class concurrent_lru_cache<key_type, value_type, value_functor_type>::handle { public: handle(); ~handle(); // Supported since C++11 handle(handle&& src); handle& operator=(handle&& src); // Supported until C++11 handle(handle_move_t m); handle& operator=(handle_move_t m); operator handle_move_t(); friend handle_move_t move(handle& h); operator bool() const; value_type& value(); private: void operator=(handle&); handle(handle&); }; } // namespace tbb } // namespace oneapihandle_move_t是容器内部的一个辅助私有类型(定义于concurrent_lru_cache的 private 区域),用于在没有 C++11 移动语义的环境下传递引用,用户不得直接使用;C++11 引入移动语义后该类型已过时(见 concurrent_lru_cache_cls.rst 成员表)。
4. 成员函数逐项解读
4.1 默认构造与析构
handle():构造一个不引用任何值的 handle(空句柄)。规范中该类成员的默认构造由实现提供;在源码实现中,handle_object 的默认构造将两个指针成员置空:my_lru_cache_ptr(nullptr), my_map_record_ptr(nullptr)。~handle():释放(如果存在)对concurrent_lru_cache中值的引用。对应源码 析构函数:若my_lru_cache_ptr非空,则调用signal_end_of_usage(*my_map_record_ptr)通知容器该引用已结束。
4.2 移动构造与移动赋值(C++11 起)
handle(handle&& src):把src持有的引用转移给新对象。完成后src不再引用任何值。源码实现(handle_object 移动构造)直接搬运两个指针,并将src的指针置空;移动前通过__TBB_ASSERT校验源对象状态合法(两个指针要么同时非空、要么同时为空)。handle& operator=(handle&& src):把src的引用转移给*this;若*this此前已持有引用,则先释放旧引用(调用signal_end_of_usage),再接管新引用,最后将src置空(实现见 handle_object 移动赋值)。返回*this。
需要特别注意:移动赋值会先释放*this原有的引用,因此不要用它做“交换”操作,交换两个 handle 的引用请走std::move加临时对象或直接重建。
4.3 预 C++11 的转移机制
在 C++11 之前没有右值引用与移动语义,规范设计了handle_move_t通道:
- 构造与赋值:
handle(handle_move_t m)与handle& operator=(handle_move_t m)允许从handle_move_t构造/赋值 handle。 - 转换与转移:
operator handle_move_t()转换运算符,以及自由友元函数friend handle_move_t move(handle& h),将 handle 持有的引用转移到临时handle_move_t对象。规范特别提醒:转换运算符不应被直接调用,应使用move函数。完成后,原 handle 不再引用任何值。
两者配合即可在没有移动语义的环境下完成“引用转让”。在现代 C++ 项目中,请直接使用移动语义,无需关心这套旧通道(当前实现也仅保留 C++11 移动版本,见 handle_object 中= delete的拷贝操作)。
4.4 状态查询与值访问
operator bool() const:检查 handle 是否持有引用。返回true当且仅当*this引用着缓存中的某个值。源码实现(operator bool)等价于return (my_lru_cache_ptr && my_map_record_ptr);。这特别适合用来判断“移动后残留的源对象”是否为空句柄。value_type& value():返回所引用value_type对象的引用。规范明确:对一个不引用任何值的 handle 调用value()是未定义行为(UB)。源码通过__TBB_ASSERT(value() 实现)在调试构建中拦截“从已移动对象取值”等错误,但发布构建下不保证安全,调用方必须先用operator bool()确认。
4.5 小结:成员函数一览表
| 成员 | 作用 | 关键语义 |
|---|---|---|
handle() | 构造空句柄 | 不引用任何值 |
~handle() | 释放引用 | 触发容器引用计数递减与 LRU 维护 |
handle(handle&&) | 移动构造 | 转移引用,源对象变空(C++11 起) |
operator=(handle&&) | 移动赋值 | 先释放旧引用再接管新引用(C++11 起) |
handle(handle_move_t)等 | 旧标准转移通道 | C++11 之前使用,已过时 |
operator bool() const | 状态查询 | 是否持有有效引用 |
value_type& value() | 值访问 | 空句柄上调用为 UB |
5. 从测试用例看 handle 的正确用法
仓库中的 test_concurrent_lru_cache.cpp 是验证 handle 语义最直接的证据,其头文件 concurrent_lru_cache_common.h 定义了若干测试预置(preset)。几个关键场景:
1) 未用值的保留与上限:连续写入不超过number_of_lru_history_items个键后,值仍存活("cache should store some unused objects");一旦超过上限,多出的未用值立即被驱逐("cache should respect number of stored unused objects to number passed in constructor")——这正是 handle 析构释放引用后、LRU 历史容量起作用的体现。
2) LRU 驱逐顺序:预热键 0 使其“最近使用”,再写入新键触发驱逐,验证被驱逐的是最久未用的键 1 而非键 0("cache should evict items in lru order")。
3) 在用值不可驱逐:先preset::handle_type h = cache[0];保持键 0 在用,再触发驱逐,断言键 0 仍存活("cache should not evict items in use")——这是 handle 引用保持语义的直接验证。
4) 容量为 0 的边界:当number_of_lru_history_items == 0时,临时表达式&cache[1].value()取出的对象在表达式结束后立即被销毁;而一旦用具名 handleh持有引用,对象便持续存活(见 test 第 130-152 行)。这提醒我们:若希望值在取用后继续驻留,必须把 handle 保存到具名变量中,临时 handle 的析构会立刻释放引用。
6. 实战建议与注意事项
结合规范与源码,使用handle时有几条值得牢记的规则:
- 优先
auto承接返回值:auto h = cache[key];会把临时 handle 移动构造到具名对象中,避免“取到即释放”的陷阱。测试中preset::handle_type h = cache[0];正是标准写法。 - 用
operator bool()做安全检查:对可能为空的句柄(例如从容器或函数返回的 handle),访问前先判断,杜绝value()的 UB。 - 理解移动即“释放”:
handle b = std::move(a);后a变为空句柄,不要再对a.value()解引用;这由源码中移动操作把源指针置空保证。 - handle 生命周期决定缓存驻留:想让值留在缓存中供后续命中,就在作用域内保持一个 handle;不再需要时让它析构即可,容器会自动按 LRU 策略决定去留。
- 性能考量:
value()返回的是value_type&,读写都直接作用于缓存内部对象,不存在拷贝;但缓存内部检索与引用计数更新均通过聚合器串行化,高并发下对同一键的频繁operator[]会形成串行点。
7. 参考文档与源码索引
- 规范文档(本文主体):handle_object_cls.rst
- 容器类规范:concurrent_lru_cache_cls.rst
- 参考手册(API 速览):concurrent_lru_cache_cls.rst(reference 目录)
- 头文件实现:concurrent_lru_cache.h
- 测试用例:test_concurrent_lru_cache.cpp 与 concurrent_lru_cache_common.h
【免费下载链接】moldmold: A Modern Linker 🦠项目地址: https://gitcode.com/GitHub_Trending/mo/mold
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考