C++26 前瞻:RCU、Hazard Pointer 与 Hive
C++26 前瞻:RCU、Hazard Pointer 与 Hive
C++26 是一个仍在活跃演进中的标准,但如果用一个词来形容它的方向,那就是"并发基础设施的标准化"。C++11 给了我们线程和原子操作,C++20 给了我们 jthread 和信号量,C++23 开始探索 Senders/Receivers,而 C++26 则将目光投向更高阶的并发模式——无锁数据结构的安全内存回收(Safe Memory Reclamation, SMR)和针对特定并发访问模式优化的容器。
本文重点拆解 C++26 中最受关注的两个内存回收原语(RCU 和 Hazard Pointer)以及一个具有独特迭代器语义的新容器 std::hive。
1. 背景:无锁编程的内存回收难题
考虑一个经典的无锁栈:
template<typename T>
class LockFreeStack {
struct Node { T data; Node* next; };
std::atomic<Node*> head_{nullptr};
void push(T val) {
auto* node = new Node{std::move(val), head_.load()};
while (!head_.compare_exchange_weak(node->next, node));
}
std::optional<T> pop() {
Node* node = head_.load();
while (node && !head_.compare_exchange_weak(node, node->next));
if (!node) return std::nullopt;
T result = std::move(node->data);
delete node; // ← 危险!可能有其他线程还在读这个 node
return result;
}
};pop() 中的 delete node 是经典的 use-after-free 竞态:
- 线程 A 读
head_→ 获得节点node_a - 线程 B 完成
pop,CAS 成功后delete node_a - 线程 A 解引用
node_a->next→ use-after-free
这个问题揭示了无锁编程的一个根本矛盾:锁定数据结构用互斥量保证互斥访问,无锁数据结构则因为没有互斥,线程随时可能并发地访问同一块内存。在删除节点时,我们必须保证没有其他线程还持有指向它的指针。
这正是 Safe Memory Reclamation (SMR) 技术要解决的问题。C++26 标准化的两个 SMR 原语——RCU 和 Hazard Pointer——提供了确定性的、高效的内存回收语义。
2. Hazard Pointer:延迟回收
2.1 核心思想
Hazard Pointer 由 Maged Michael(IBM)在 2002 年提出。它的核心机制非常简单:
在访问一个无锁数据结构节点之前,线程公开声明自己正要访问哪个节点(将节点指针存到"hazard pointer"中)。删除线程在释放节点前,先检查所有线程的 hazard pointer 列表——如果目标节点还在任何列表里,推迟释放。
2.2 C++26 的 hzd::hazard_pointer API
#include <hazard_pointer> // C++26 <hazard_pointer> header
// 全局的 hazard pointer 域
std::hzd::hazard_pointer_domain& domain = std::hzd::hazard_pointer_domain::default_domain();
// 每个线程获取一个 hazard pointer 槽位
thread_local auto hp_guard = domain.acquire(); // RAII 注册/注销
template<typename T>
class LockFreeStack {
struct Node { T data; Node* next; };
std::atomic<Node*> head_{nullptr};
public:
std::optional<T> pop() {
Node* node;
do {
node = hp_guard.protect(head_); // 声明"我在关注这个指针"
// 即使此时 head_ 被 CAS 走了,node 所指向的内存也不会被回收
} while (node && !head_.compare_exchange_weak(node, node->next));
if (!node) return std::nullopt;
T result = std::move(node->data);
hp_guard.reset_protection(); // 不再需要保护
domain.retire(node); // 延迟回收——当所有 hp 都不再指向它时自动 delete
return result;
}
};关键 API:
| 操作 | 说明 |
|---|---|
hp_guard.protect(atomic_ptr) | 原子地读取指针并声明保护 |
hp_guard.reset_protection() | 释放保护声明 |
domain.retire(ptr) | 将 ptr 加入延迟回收队列,安全时自动 delete |
domain.acquire() | 获取一个 hazptr 槽位,返回 RAII guard |
domain.bulk_reclaim() | 显式触发回收检查(通常不需要手动调) |
2.3 三个设计要点
1. 保护必须是原子的。 protect() 内部是一个读-声明-验证(Read-Protect-Verify)的三步序列:
// protect() 的等价逻辑
Node* protect(std::atomic<Node*>& src) {
Node* node;
do {
node = src.load(std::memory_order_acquire); // 读
hazptr_.store(node); // 声明
// 必须验证:在读和声明之间,node 可能已经被另一个 pop 删除了
} while (node != src.load(std::memory_order_acquire)); // 验证
return node;
}没有验证步骤,线程可能在读到一个指针后、hazptr_ 更新前被抢占,而指针指向的节点在此期间被回收。
2. retire 不是 delete。 retire 将节点放入一个 per-domain 的内部列表。回收在以下时机发生:
- 列表累积到一定数量后批量回收
- 显式调用
bulk_reclaim() - 线程正常退出时
3. 这不是 GC。 Hazard Pointer 回收是确定性的——节点在"不被任何线程引用"的瞬间就可以被回收。与 GC 不同,它不扫描整个内存图,只检查一个紧凑的 hazard pointer 数组。
3. RCU(Read-Copy-Update):读端零开销
3.1 核心思想
RCU 由 Paul McKenney 在 Linux 内核中推广,它的设计哲学与 Hazard Pointer 截然相反:
写端拷贝并发布新版本,读端完全不执行任何同步操作。 旧版本在所有读端都完成当前临界区后回收。
RCU 的"不对称性"是它的最大优势,也是最大限制:
- 读端(读者):零原子操作、零内存屏障——和单线程代码完全一样快
- 写端(更新者):拷贝整个节点、原子地发布、等待宽限期(grace period)
3.2 C++26 的 RCU API
#include <rcu> // C++26
template<typename T>
class RCUProtectedList {
struct Node { T data; Node* next; };
std::atomic<Node*> head_{nullptr};
public:
// 读端:进入 RCU 读临界区
bool contains(const T& value) const {
std::rcu_lock guard; // 声明"我在读 RCU 保护的数据"
Node* current = head_.load(std::memory_order_consume);
while (current) {
if (current->data == value) return true;
current = current->next;
}
return false;
} // guard 析构:表明离开读临界区
// 写端:拷贝 → 修改 → 替换
void prepend(T value) {
auto* new_node = new Node{std::move(value), head_.load()};
while (!head_.compare_exchange_weak(new_node->next, new_node));
// 如果旧链表需要回收:
// 1. 调用 synchronize_rcu() —— 等待所有正在进行的读临界区完成
// 2. 此时旧节点保证不被任何读者引用,可以安全回收
}
};关键 API:
| 操作 | 说明 |
|---|---|
std::rcu_lock | RAII guard,标记读临界区的开始和结束 |
std::synchronize_rcu() | 阻塞直到所有正在进行的 rcu_lock 退出 |
std::call_rcu(ptr, deleter) | 异步注册:在下一个宽限期后调用 deleter(ptr) |
std::rcu_barrier() | 等待所有排队的 call_rcu 回调完成 |
3.3 宽限期(Grace Period)
RCU 的核心概念是宽限期:
一个宽限期 = 所有 CPU 都经历了一次上下文切换(或者等价于:每个 CPU 上没有任何"读临界区"跨越了这个宽限期的两端)。
线程 A(读者): [rcu_lock ... rcu_unlock]
线程 B(作者): [synchronize_rcu()——阻塞]
↑ 当 A 的 unlock 发生在此之后时,宽限期结束synchronize_rcu() 阻塞至所有在它开始前进入读临界区的线程都退出了临界区。这意味着在 synchronize_rcu() 之后,所有旧版本的内存都不再被任何读者引用。
RCU vs Hazard Pointer 的选择
| 维度 | Hazard Pointer | RCU |
|---|---|---|
| 读端开销 | 至少一次 protect()(含 memory_order_acquire) | 零原子操作 |
| 写端开销 | 检查所有线程的 hp 列表 | synchronize_rcu() 阻塞等宽限期 |
| 适合场景 | 读写比较均衡 | 读极多,写极少的"读主导"场景 |
| 内存压力 | 延迟回收,但列表大小有限 | 宽限期间隔内可能累积大量待回收节点 |
| 代表用户 | Folly 的 ConcurrentHashMap | Linux 内核的 VFS / 路由表 |
4. std::hive:稳定迭代器的动态容器
4.1 问题:为什么需要 hive?
std::vector 在中间插入/删除是 O(n),且会使所有迭代器失效。std::list 插入/删除是 O(1) 且迭代器不失效,但缓存局部性极差(指针跳转)。std::deque 是一个折中,但迭代器在插入时可能失效。
std::hive 是 C++26 引入的一种新型容器,它的核心承诺是:
元素位置可动态改变(打包整理碎片),但各元素的迭代器永不清零。
换言之,hive 内部可以重新排列内存——把活跃元素"挤在一起"以提高缓存利用率——但它会维护一个 indirection 层,保证每个元素的外部引用(迭代器)始终有效。
4.2 数据结构直觉
std::hive 内部基于一个或多个内存块(block),每个块里有一个跳表式的空闲链表(skipfield)。当元素被删除时,链接结构跳过它;当新元素被插入时,优先填充分配过的空洞。周期性地,hive 可以做块内紧缩(block compaction),把活跃元素向块的前端靠拢。
内存块:[A] [holes] [C] [D] [holes] [F] [G] [H]
↑ skipfield 指针跳过空洞
紧缩后:[A] [C] [D] [F] [G] [H] [-- 空闲空间 --]
↑ 但各自的外部迭代器依然有效4.3 典型 API
#include <hive>
std::hive<int> hv = {1, 3, 5, 7, 9};
auto it = hv.insert(6); // 返回迭代器,插入在合适位置
auto it2 = hv.erase(hv.begin()); // 删除,其他迭代器不受影响
hv.reshape(); // 显式触发包内紧缩
for (const auto& val : hv) { /* 连续遍历 */ }4.4 hive 和 node-based 容器的对比
| 容器 | 插入/删除 | 迭代器稳定性 | 缓存局部性 | 内存开销 |
|---|---|---|---|---|
std::vector | O(n) 中间 | 插入时全部失效 | 极佳 | 最小(紧凑) |
std::list | O(1) | 永稳定 | 差(每节点独立分配) | 高(每节点两个指针) |
std::deque | O(n) 中间 | 插入时可能失效 | 较好(块内连续) | 中等 |
std::hive | O(1) 摊销 | 永稳定 | 好(可紧缩) | 中等(skipfield overhead) |
hive 是"我需要 list 的迭代器稳定性,但不想接受它的缓存命中率"时的答案。游戏对象管理、粒子系统、事件队列——这些"创建和销毁频繁,但遍历更频繁"的场景是 hive 最理想的落地领域。
5. C++26 展望总结
除了本文详述的 RCU、Hazard Pointer、Hive,C++26 还有一组值得关注的特性正在标准化管道中:
- Reflection (P2996):编译期反射,可能是 C++26 最大的语法级新特性,允许在编译期枚举类型成员、获取名称、生成代码
- Contracts (P2900):前置条件、后置条件、断言,为函数接口添加可检查的契约
- std::simd (P1928):显式 SIMD 编程的数据并行类型
- std::execution / Senders-Receivers (P2300):统一的异步编程模型
这个版本的核心叙事是:把过去二十年间业界在并发、数据结构、元编程领域验证过的"最佳实践",提升到标准库从而为所有开发者所享有。
总结
RCU 和 Hazard Pointer 代表了无锁编程从"硬件提供的原子操作"迈向"类型系统保障的内存安全"的关键一步。它们解决的是同一个问题(无锁数据结构的内存回收),但采取了对称性完全相反的策略——RCU 优待读端,Hazard Pointer 平衡两端。
std::hive 则是一个长期被忽视的容器 niche(迭代器稳定性 + 缓存友好性)的标准化回应——它不替代 vector 或 list,但在二者的裂隙之间开辟了一片独有的领地。