一、背景:为什么需要 Atomic Min / Max(P0493)
1⃣ 问题本质
在多线程程序中,经常会出现这样的需求:
多个线程并发地更新一个共享变量,用来记录最小值 / 最大值
数学上可以抽象为:
x ← min ( x , v i ) 或 x ← max ( x , v i ) x \leftarrow \min(x, v_i) \quad \text{或} \quad x \leftarrow \max(x, v_i)x←min(x,vi)或x←max(x,vi)
其中:
- x xx是共享原子变量
- v i v_ivi是线程i ii提供的候选值
2⃣ 没有原子操作会发生什么?
如果用普通变量或非原子更新:
if(v<x)x=v;在并发下会出现:
- 读-改-写非原子
- 线程交错导致丢更新(lost update)
- 数据不一致、不可预测行为(UB)
3⃣ 为什么“加锁”不够好?
虽然mutex能解决正确性问题,但:
- 高争用时性能差
- 阻塞 → 上下文切换
- 难以构建lock-free/wait-free结构
4⃣ Atomic Min / Max 的核心价值
把 “比较 + 更新” 合并成一个原子操作
保证:
update ( x ) ; 是线性化的 \text{update}(x) ;\text{是线性化的}update(x);是线性化的
从而实现:
- 无锁并发更新
- 数据完整性
- 高并发可扩展性
二、典型应用场景
Atomic min / max 在以下领域非常常见:
Lock-free 数据结构
- 并发优先级队列
- 最小时间戳 / 最大序号维护
并行归约(Parallel Reduction)
- OpenMP / TBB / GPU-style reduction
- 例如:并行计算最小误差、最大评分
优化算法
- 全局最优解(best-so-far)
- 分支限界(branch & bound)
统计信息收集
- 延迟最大值
- 最小响应时间
- 峰值资源占用
三、C++26 提议接口(标准层面)
1⃣ 非成员函数形式
namespacestd{template<classT>Tatomic_fetch_max(atomic<T>*obj,typenameatomic<T>::value_type arg)noexcept;template<classT>Tatomic_fetch_max_explicit(atomic<T>*obj,typenameatomic<T>::value_type arg,memory_order order)noexcept;// 同样提供 atomic_fetch_min / _explicit}2⃣ 返回值语义(非常重要)
与atomic_fetch_add一致:
返回的是“更新前的旧值”
形式化表达:
old = x before \text{old} = x_{\text{before}}old=xbefore
并执行:
x ← max ( x , arg ) x \leftarrow \max(x, \text{arg})x←max(x,arg)
3⃣ 成员函数版本(atomic)
标准还会提供:
std::atomic<T>a;autoold=a.fetch_max(v);autoold=a.fetch_max(v,std::memory_order_relaxed);四、内存序(memory_order)语义
默认版本
atomic_fetch_max(&a,v);等价于:
atomic_fetch_max_explicit(&a,v,std::memory_order_seq_cst);显式版本的使用原则
- 统计 / reduction
→memory_order_relaxed - 同步 / 发布语义
→release/acquire
数学上可以理解为:
atomicity ; ≠ ; ordering \text{atomicity} ;\neq; \text{ordering}atomicity;=;ordering
Atomic min/max 只保证原子性,内存顺序仍需你自己选。
五、实现方式:标准并不强制怎么实现
C++26 只规定语义,实现依赖平台。
六、实现方式一:CAS Loop(Compare-And-Swap)
1⃣ 基本 CAS 实现
T old=a.load();while(old<v&&!a.compare_exchange_weak(old,v)){// old 被更新为当前值}returnold;数学模型
loop until { x ≥ v or CAS succeeds \text{loop until } \begin{cases} x \ge v \\ \text{or CAS succeeds} \end{cases}loop until{x≥vor CAS succeeds
2⃣ 问题
- 高争用时:
- 自旋严重
- cache line ping-pong
- 性能随线程数恶化
七、实现方式二:Optimized CAS
优化点包括:
- 先
load判断是否需要 CAS - 减少不必要的 CAS 尝试
- 使用
compare_exchange_weak+ backoff
这种实现: - 在低~中等争用下性能可接受
- 在无硬件指令的平台上是主力方案
八、实现方式三:硬件直接支持(最快)
1⃣ Direct Instruction
部分架构提供类似指令:
- x86:
LOCK CMPXCHG组合优化 - GPU:原生 atomic min/max
- RISC-V / ARM:特定原子扩展
效果: - 单条或少量指令
- 极低延迟
- 极高吞吐
2⃣ LL/SC(Load-Link / Store-Conditional)
在 ARM / PowerPC 上:
LL r0, [x] cmp r0, v SC r1, v, [x]若中途被打断,SC失败自动重试。
九、性能对比总结
| 实现方式 | 特点 | 性能 |
|---|---|---|
| 基本 CAS | 简单、可移植 | 高争用差 |
| 优化 CAS | 工程友好 | 可接受 |
| LL/SC | 架构支持 | 很好 |
| 硬件指令 | 原生支持 | 最优 |
十、为什么 C++26 才加?
原因总结:
- 各架构支持不统一(早期)
- API 设计需要与 memory_order 对齐
- 避免滥用 / 误用
- 与 GPU / 并行算法需求成熟度有关
十一、一句话总结(工程视角)
Atomic min/max 把“并发归约”的核心原语,正式提升为标准一等公民。
公式化总结:
atomic_fetch_max = atomic ( max ) \text{atomic\_fetch\_max} = \text{atomic}( \max )atomic_fetch_max=atomic(max)
它不是语法糖,而是:
- lock-free 结构的地基
- 并行算法的标配
- 高性能统计与优化的关键原语
一、这篇论文到底在解决什么问题?(一句话版)
把“并发条件更新(conditional write)”这个长期存在、硬件早已支持的原语,正式引入 C++
<atomic>标准库。
用公式表示就是,把下面这个并发模式:
x ← max ( x , v ) 或 x ← min ( x , v ) x \leftarrow \max(x, v) \quad\text{或}\quad x \leftarrow \min(x, v)x←max(x,v)或x←min(x,v)
升级为标准原子读-改-写操作(RMW)。
二、历史背景:为什么不是新东西,却拖了这么久?
1⃣ 原子加法比原子 max/min 还“年轻”
fetch_add:1982(NYU Ultracomputer)fetch_max / fetch_min:1988 年前后就已经出现
比很多人想象的要早
但问题是:- 各架构语义不一致
- C++ 内存模型成熟得比较晚
- conditional write 在标准里一直是“灰色地带”
2⃣ 为什么应用层强烈需要?
论文列的动机,本质可以归为四类:
Lock-free 数据结构
- 无锁队列
- 序号 / 时间戳推进
并行归约(reduction)
- OpenMP 的
reduction(max: ...) - 数据并行统计
优化 / 搜索算法
- global best-so-far
- branch & bound 剪枝
统计信息
- 最大延迟
- 峰值内存
- 最大输入规模
这些场景的共同点是:
写入发生不频繁,但读取和比较非常频繁
三、核心难点:conditional write 的语义之争
这是整篇论文最关键、也最有争议的部分。
1⃣ 什么是 conditional write?
伪代码:
if(new>old)old=new;在并发里,这可以有两种实现语义:
2⃣ 方案 A:read-modify-write(RMW)
无论值是否改变,都算一次“写”
x ← max ( x , v ) x \leftarrow \max(x, v)x←max(x,v)
特点:
- 和
fetch_add完全一致 - 每个参与线程都要抢 cache line
- 高争用下性能差
3⃣ 方案 B:read-and-conditional-store(RCS)
只有真的变大 / 变小,才写
if v > x then x ← v \text{if } v > x \text{ then } x \leftarrow vifv>xthenx←v
特点:
- 写入次数显著减少
- 硬件(ARM / RISC-V / POWER)天然支持
- 性能非常好
但问题是:
这不是严格意义上的 RMW
4⃣ 标准委员会的最终结论(R3 → R5)
语义上:规定为 RMW
实现上:允许用 RCS + 修补
也就是:
行为像 RMW,但实现可以“偷懒”
四、三种 CAS-loop 实现对比(5.1 / 5.2 / 5.3)
这是论文里工程含量最高的部分。
5.1 非 conforming(不符合标准)的实现
while(max(v,t)!=t){if(pv->compare_exchange_weak(t,v))break;}特点:
- 如果值没变 → 完全不写
- 不满足 RMW 语义
- 被标准否掉
5.2 纯 RMW 实现(最“老实”的)
while(!pv->compare_exchange_weak(t,max(v,t)));特点:
- 每轮都会写
- cache line 抢占严重
- 高争用下性能灾难
5.3“聪明”的折中实现(最终推荐)
autot=(mr!=m)?pv->fetch_add(0,m):pv->load(mr);while(max(v,t)!=t){if(pv->compare_exchange_weak(t,v,m,mr))returnt;}returnt;这个实现的关键思想:
- 只有当 memory_order 要求 release 时,才强制一次写
- 否则:
- 允许只读不写
- 用
fetch_add(0)人为制造一次“可观察的写”
为什么这是合法的吗?
因为:
额外一次 dummy 写 ; ⇏ ; 可观测语义变化 \text{额外一次 dummy 写} ;\not\Rightarrow; \text{可观测语义变化}额外一次dummy写;⇒;可观测语义变化
在 C++ 内存模型下,这是不可区分的行为。
五、为什么没有 infix operator?
现状:
+=→ 有&=→ 有max=→ 不存在
能不能提供“返回新值”的接口?
比如:
autonew=a.fetch_max_new(v);论文结论是:不值得
原因:
max/min是不可逆操作- 新值可以用:
max ( old , v ) \max(\text{old}, v)max(old,v)
即:
autoold=a.fetch_max(v);autonewv=std::max(old,v);所以最终只保留fetch_xxx(返回旧值)
六、指针语义:为什么这么“严格”?
标准规定:
fetch_max/fetch_min对指针的比较
等价于使用std::max / std::min
也就是说:
- 只能比较:
- 指向同一个 complete object
- 或同一数组内的指针
否则:
<不形成 strict weak ordering
→行为未定义
为什么不像其他 atomic 那样“兜底”?
原因是:
fetch_add可能产生“新值”fetch_max只会返回:- 旧值
- 或调用者提供的值
不“创造”新指针
所以不提供 escape clause
工程建议(论文暗示)
如果你真的需要:
atomic<T*>a;跨对象比较:
自己转成uintptr_t
七、为什么浮点被移除了?
这是 R5 的重要变化。
根本原因:浮点的 max/min 不是良序
问题点:
- NaN
- +0 / -0
- IEEE 754 特殊规则
例如:
max ( NaN , x ) ≠ x \max(\text{NaN}, x) \neq xmax(NaN,x)=x
结论
std::min / std::max本身就对浮点特殊处理- 原子语义会更复杂
- 已有P3008专门处理:
本提案专注整数 & 指针
八、Benchmark:为什么硬件支持这么重要?
测试平台
- ARM Graviton3(64 核)
- 对比:
- CAS-loop(smart)
- 硬件指令
ldsmax
结果趋势(简化版)
| 核数 | CAS-loop | 硬件指令 |
|---|---|---|
| 2 | 相近 | 相近 |
| 8 | CAS 明显慢 | 稳定 |
| 32+ | CAS 急剧上升 | 线性增长 |
| 结论: |
硬件原生 min/max 是数量级优势
九、标准改动总结 )
新增内容包括:
- 非成员函数:
atomic_fetch_max atomic_fetch_min - 成员函数:
atomic<T>::fetch_max atomic<T>::fetch_min atomic_ref支持- 整数 & 指针特化
- feature test macro:
__cpp_lib_atomic_min_max
十、整篇论文的“真正价值”(总结)
P0493 不是“加几个 API”,
而是把“条件更新”正式纳入 C++ 原子语义体系。
公式化总结:
conditional write ; ⟶ ; standard RMW primitive \text{conditional write} ;\longrightarrow; \text{standard RMW primitive}conditional write;⟶;standard RMW primitive
你可以把它理解为:
- lock-free 算法的“最后一块基础砖”
- 并行 reduction 的原生支持
- 为 GPU / heterogeneous 并发铺路
一、示例代码在“并发层面”到底做了什么?
#include<atomic>#include<thread>#include<vector>std::atomic<int>max_value{0};①std::atomic<int> max_value{0};
- 这是一个原子共享变量
- 所有线程都会:
- 并发读取它
- 并发尝试更新它
- 不需要互斥锁(mutex)
voidfind_max_in_range(intstart,intend){for(inti=start;i<end;++i){max_value.fetch_max(i,std::memory_order_relaxed);}}②fetch_max的语义(核心)
这一句在语义上等价于:
old ← max_value max_value ← max ( old , i ) \text{old} \leftarrow \text{max\_value} \ \text{max\_value} \leftarrow \max(\text{old}, i)old←max_valuemax_value←max(old,i)
但整个过程是原子的(不可被打断)。
③ 多线程下会发生什么?
假设两个线程并发执行:
- 线程 A:
fetch_max(137) - 线程 B:
fetch_max(245)
最终结果满足:
max_value = max ( … , 137 , 245 ) \text{max\_value} = \max(\dots, 137, 245)max_value=max(…,137,245)
顺序不确定,但结果确定。
intmain(){std::vector<std::thread>threads;for(inti=0;i<10;++i){threads.emplace_back(find_max_in_range,i*100,(i+1)*100);}④ 线程划分方式
- 共 10 个线程
- 每个线程处理一个区间:
[ 0 , 100 ) , [ 100 , 200 ) , … , [ 900 , 1000 ) [0,100), [100,200), \dots, [900,1000)[0,100),[100,200),…,[900,1000)
这保证了: - 所有
i都会被尝试写入 - 理论最大值应为:
max = 999 \max = 999max=999
for(auto&thread:threads){thread.join();}⑤join()的并发语义(非常重要)
join()不是原子操作- 但它提供了一个线程完成的 happens-before 保证
即:
线程内的所有操作 ; ; happens-before ; ; join() 返回 \text{线程内的所有操作} ;;\text{happens-before};; \text{join() 返回}线程内的所有操作;;happens-before;;join()返回
std::cout<<"Maximum value: "<<max_value<<std::endl;⑥ 为什么这里读max_value是安全的?
因为:
- 所有线程已经
join() - 不再有并发写入
- 即使
fetch_max用的是memory_order_relaxed
join 提供了同步,而不是 atomic 本身
二、为什么memory_order_relaxed在这里是完全正确的?
这是这段代码最容易被误解的地方。
1⃣ 我们真正关心的是什么?
我们关心的是:
max_value 最终等于所有 i 的最大值 \text{max\_value 最终等于所有 i 的最大值}max_value最终等于所有i的最大值
我们不关心:
- 每一次
fetch_max的先后顺序 - 某个线程“看到”另一个线程的中间状态
2⃣relaxed保证了什么?
std::memory_order_relaxed保证两件事:
- 原子性(atomicity)
- 修改是不可撕裂的(no tearing)
但不保证:
- 顺序
- 可见性传播时机
- 与其他内存的关系
3⃣ 为什么这已经足够?
因为这里的计算是一个幂等归约(idempotent reduction):
max ( max ( a , b ) , c ) = max ( a , max ( b , c ) ) \max(\max(a,b),c) = \max(a,\max(b,c))max(max(a,b),c)=max(a,max(b,c))
也就是说:
- 顺序完全不重要
- 不需要 happens-before
- 不需要跨变量同步
这是relaxed 的经典使用场景
三、Memory Ordering 强弱层级(从弱到强)
你列出的层级可以用一条“语义单调增强链”表示:
relaxed ⊂ acquire / release ⊂ acq_rel ⊂ seq_cst \text{relaxed} \subset \text{acquire / release} \subset \text{acq\_rel} \subset \text{seq\_cst}relaxed⊂acquire / release⊂acq_rel⊂seq_cst
1⃣memory_order_relaxed
fetch_max(v,relaxed)- 原子性
- ✘ 不同步其他内存
- ✘ 不建立 happens-before
适合: - 计数器
- max/min
- 统计
- 并行 reduction
2⃣memory_order_release
fetch_max(v,release)语义:
在这次原子操作之前的所有写入
对随后 acquire 读取它的线程可见
数学化表示:
writes before → release atomic \text{writes}_\text{before} \xrightarrow{\text{release}} \text{atomic}writesbeforereleaseatomic
3⃣memory_order_acquire
load(acquire)语义:
看到这个原子值
⇒ 一定能看到对应的 release 写入
atomic → acquire reads after \text{atomic} \xrightarrow{\text{acquire}} \text{reads}_\text{after}atomicacquirereadsafter
4⃣memory_order_acq_rel
fetch_max(v,acq_rel)- 对写:release
- 对读:acquire
用于: - 状态推进
- 阶段切换
- lock-free 协议
5⃣memory_order_seq_cst(最强)
fetch_max(v,seq_cst)保证:
- 所有
seq_cst原子操作 - 存在一个全局总序
∃ single total order \exists\text{single total order}∃single total order
性能代价最高
四、什么时候不能用relaxed?
考虑这种情况:
structData{intpayload;};Data data;std::atomic<int>max_version;线程 A:
data.payload=42;max_version.fetch_max(1,std::memory_order_release);线程 B:
if(max_version.load(std::memory_order_acquire)>=1){// 希望看到 payload == 42}这里:
- 必须
release + acquire - 否则 B 可能看到:
max_version == 1- 但
payload仍是旧值
五、为什么fetch_max特别适合 relaxed?
从数学角度看:
max : ( T , T ) → T \max: (T, T) \to Tmax:(T,T)→T
满足:
- 交换律
- 结合律
- 幂等性
这意味着:
只要保证单次操作原子,
整体结果天然正确
这正是 relaxed 的理想适用场景。
六、把这段代码用一句“专家级描述”总结
这是一个无锁、无同步依赖、基于幂等归约的并行最大值计算,
使用atomic::fetch_max+memory_order_relaxed,
正确性由原子性 + join 同步共同保证。
一、Current Status:提案目前处在什么阶段?
1⃣ 提案历史与状态
- 最早提出时间:2016 年
- 目标标准:C++26
- 当前版本:P0493R5(2024 年 2 月)
- 实现经验:
- 已在Clang中进行过实验性实现
- 表明该设计可实现、可优化、可落地
这类信息在 WG21 语境中意味着:
“该提案已经过多年打磨,进入收敛阶段”
2⃣ 为什么拖了这么久?
关键原因不是整数,而是:
浮点原子 min/max 的语义争议
整数版本:
- 语义清晰
- 与数学min / max \min / \maxmin/max一致
- 无 NaN、无符号零问题
浮点版本: - 涉及 IEEE-754 的角落语义
- 标准库内部历史包袱极重
二、Open Questions:委员会仍在讨论什么?
1⃣ 是否支持浮点类型?
问题本质:
是否存在一个“足够一致、可移植”的浮点 min/max 语义? \text{是否存在一个“足够一致、可移植”的浮点 min/max 语义?}是否存在一个“足够一致、可移植”的浮点min/max语义?
- GPU / ARM:已经有硬件支持
- x86:支持在增长
- 标准库:语义仍未完全统一
2⃣ 是否提供“返回新值”的变体?
当前提案接口是:
Tatomic_fetch_max(atomic<T>*,T);语义:
return old_value \text{return old\_value}return old_value
开放问题:
- 是否也应该支持:
Tatomic_fetch_max_new(...);即:
return new_value = max ( o l d , i n p u t ) \text{return new\_value} = \max(old, input)return new_value=max(old,input)
委员会通常更保守:
- 增加 API 意味着 ABI / 教学成本
- 可通过一次 load + fetch 实现
三、结论性判断(整数版本)
Atomic min/max operations are valuable additions
原因总结为三点:
- 正确性
- 消除数据竞争
- 明确原子语义
- 性能
- 避免 mutex
- 利用硬件原子指令
- 适用性
- 高并发
- 高扩展性
适用场景包括:
- Lock-free 数据结构
- 并行归约(OpenMP / TBB)
- 优化算法
- 统计与监控
四、Atomic Floating-Point Min/Max(P3008)
这是当前最难、也是最有争议的部分。
1⃣ 为什么浮点比整数难?
浮点存在三个“非直觉点”:
(1) NaN(Not a Number)
- NaN不满足自反性:
NaN ≠ NaN \text{NaN} \neq \text{NaN}NaN=NaN - 比较结果不稳定:
NaN < x , ; NaN > x , ; NaN = = x ; 均为 false \text{NaN} < x,; \text{NaN} > x,; \text{NaN} == x ;\text{均为 false}NaN<x,;NaN>x,;NaN==x;均为false
(2) 有符号零(+0 与 −0)
− 0.0 = = + 0.0 但符号位不同 -0.0 == +0.0 \quad \text{但符号位不同}−0.0==+0.0但符号位不同
比较关系并非完全直觉。
(3) IEEE-754 历史差异
不同平台、不同指令,对:
- NaN
- signed zero
处理方式不同。
2⃣ C++ 中已有的“混乱历史”
(a)std::min / std::max
std::min(NaN,2.0f);// Undefined Behavior- 标准明确:UB
- 这是为了性能 + 简化语义
(b) C 函数族:fmin / fmax
- 将 NaN 视为missing data
- 返回“非 NaN 的值”
- signed zero 行为依实现
© C23 新引入的一组函数
| 函数 | NaN 处理 | signed zero |
|---|---|---|
fminimum | NaN = error | − 0 < + 0 -0 < +0−0<+0 |
fmaximum | NaN = error | − 0 < + 0 -0 < +0−0<+0 |
fminimum_num | NaN = missing | − 0 < + 0 -0 < +0−0<+0 |
fmaximum_num | NaN = missing | − 0 < + 0 -0 < +0−0<+0 |
3⃣ 硬件现状(非常关键)
GPU / ARM 的事实:
- GPU:
- 原生支持 atomic float min/max
- NaN 直接忽略
- ARM v8.1+:
- 提供原子浮点最值指令
- 语义接近
fminimum_num
即硬件已经在用一种“事实标准”。
五、P3008 的设计取向
1⃣ 提议的浮点原子接口
floatfetch_min(float,memory_order=seq_cst)noexcept;floatfetch_max(float,memory_order=seq_cst)noexcept;语义约束:
- NaN:行为未指定(unspecified)
- signed zero:推荐− 0 < + 0 -0 < +0−0<+0(但非强制)
这里用的是unspecified,而不是 UB
是一次重大改进
2⃣ 明确语义版本(更安全)
基于 C23:
floatfetch_fminimum(...);floatfetch_fmaximum(...);floatfetch_fminimum_num(...);floatfetch_fmaximum_num(...);语义对应关系:
fminimum / fmaximum- NaN = 错误
fminimum_num / fmaximum_num- NaN = 缺失数据
数学上:
fminimum_num ( x , NaN ) = x \text{fminimum\_num}(x, \text{NaN}) = xfminimum_num(x,NaN)=x
- NaN = 缺失数据
六、为什么委员会现在“敢”推进浮点版本?
原因有三:
- 硬件已经在用
- GPU / ARM 已落地
- C23 已统一语义
- C 与 C++ 再次对齐
- 经验教训足够多
std::min/max的 UB 被认为是失败设计
七、最终总结(专家视角)
Atomic min/max(整数)已经是“无争议必需品”。
浮点版本不再追求完美,而是追求“可实现、可解释、可移植”。
核心思想转变:
从“数学完美” → “工程可用” \text{从“数学完美”} \rightarrow \text{“工程可用”}从“数学完美”→“工程可用”
一、核心结论(先给答案)
在高并发(高线程数)场景下:
Native atomic min/max ≫ CAS loop 实现 \text{Native atomic min/max} \gg \text{CAS loop 实现}Native atomic min/max≫CAS loop实现
性能差距会随着并发度上升而指数式放大。
原因不是“实现技巧”,而是硬件一致性协议与冲突模型的根本不同。
二、CAS loop:为什么会在高并发下性能崩塌?
1⃣ CAS loop 的基本结构
一个典型的fetch_maxCAS 实现:
T old=atomic.load(relaxed);while(old<value&&!atomic.compare_exchange_weak(old,value,relaxed,relaxed)){// old 被更新为当前值,继续重试}returnold;逻辑抽象为:
repeat until success: { read x try write max ( x , v ) \text{repeat until success:} \begin{cases} \text{read } x \\ \text{try write } \max(x, v) \end{cases}repeat until success:{readxtry writemax(x,v)
2⃣ 高并发下的本质问题:冲突概率
设:
- N NN= 并发线程数
- 所有线程都在更新同一个 cache line
那么在任意时间点:
P ( CAS success ) ≈ 1 N P(\text{CAS success}) \approx \frac{1}{N}P(CAS success)≈N1
即: - 一个线程成功
- N − 1 N-1N−1个线程失败、回退、重试
3⃣ CAS loop 的性能退化链条(对应图左侧)
(1) Higher Thread Count
⬇
(2) Increased Contention
- 同一 cache line
- MESI 协议频繁失效
- cache line 在核之间“抖动”
(3) Increased Contention
⬇
(4) CAS Performance Degradation
退化体现在:
- 重试次数↑ \uparrow↑
- cache miss↑ \uparrow↑
- pipeline flush↑ \uparrow↑
- 内存序列化↑ \uparrow↑
可以抽象为:
Latency ∗ C A S ≈ O ( N ⋅ T ∗ c o h e r e n c e ) \text{Latency}*{CAS} \approx O(N \cdot T*{coherence})Latency∗CAS≈O(N⋅T∗coherence)
三、Native atomic min/max:为什么硬件支持能“救命”?
1⃣ 硬件原子 ≠ CAS 循环
关键区别:
CAS loop 是
“读 → 比较 → 写(可能失败)”原生原子指令是
“比较 + 写 = 一个不可分割的硬件事务”
2⃣ 硬件实现方式(取决于架构)
常见路径包括:
- 单指令原子(GPU、ARM)
- LL/SC(Load-Link / Store-Conditional)
- 内部 microcode(x86 扩展)
统一特性: - 不会在失败后反复读 cache
- 失败路径极短
- 减少 coherence 往返
3⃣ 抽象性能模型
对原生 atomic min/max:
Latency n a t i v e ≈ O ( 1 ) \text{Latency}_{native} \approx O(1)Latencynative≈O(1)
对 CAS loop:
Latency C A S ≈ O ( contention ) \text{Latency}_{CAS} \approx O(\text{contention})LatencyCAS≈O(contention)
当线程数N → ∞ N \to \inftyN→∞:
Latency ∗ C A S Latency ∗ n a t i v e → ∞ \frac{\text{Latency}*{CAS}}{\text{Latency}*{native}} \to \inftyLatency∗nativeLatency∗CAS→∞
四、SVG 图的逐层语义解读
下面我用“图 → 含义”的方式解释你 SVG 中的每一块。
第一层(Top Level)
Higher Thread Count
- 并行规模增大
- GPU / many-core CPU 的常态
Modern GPUs
- 数千线程
- 几乎必然高冲突
Atomic Float Min/Max Operations
- 抽象 API
- 可映射到:
- 原生指令
- 或 CAS fallback
Some newer CPUs
- ARM v8.1+
- 正在补齐硬件支持
Traditional Approach
- 历史实现
- 完全依赖 CAS loop
第二层(Second Level)
Increased Contention
- cache line 被频繁 invalidation
- 写者竞争
Native Hardware Support
- 原子在硬件层完成
- 不暴露中间状态
CAS Loop Implementation
- compare_exchange 重试
- coherence 风暴中心
第三层(Third Level)
CAS Performance Degradation
- 延迟急剧上升
- 吞吐下降
High Performance
- GPU / 原生 atomic
- 延迟稳定
Lower Performance
- CAS fallback
- 并发度越高越糟
五、为什么这对浮点 atomic min/max特别重要?
因为:
- 浮点 min/max 常见于归约
- 图像处理
- 物理模拟
- 机器学习
- 归约 = 高并发 + 高冲突
- GPU 已证明:没有原生原子就不可用
这也是 P3008 强调:
“Hardware reality must inform standard design.”
六、一个形式化总结(可以直接放在 slide 里)
对共享变量x xx,并发执行:
x ← min ( x , v i ) x \leftarrow \min(x, v_i)x←min(x,vi)
- CAS 实现:
cost ∼ O ( N ) \text{cost} \sim O(N)cost∼O(N)- 原生原子:
cost ∼ O ( 1 ) \text{cost} \sim O(1)cost∼O(1)当N NN很大时,
只有原生 atomic min/max 具有可扩展性。
七、工程实践建议(非常重要)
如果平台支持原生 atomic min/max
- 一定要用
- 即使语义稍微宽松(如 NaN)
如果只能 CAS fallback
- 尽量:
- 分片(sharding)
- 局部归约 + 最终合并
- 降低冲突频率
最终一句话总结
Atomic min/max 的标准化价值,不在“语法”,而在“性能可扩展性”。
没有原生支持,高并发下的 CAS 方案迟早会成为瓶颈。
一、代码整体在表达什么?
这段代码想说明的是:
如果 C++ 提供原生的 atomic floating-point
fetch_min / fetch_max,
那么它们的使用方式、返回值语义、并发行为,将与整数版本保持一致。
也就是:
- 返回旧值
- 原子地更新为 min/max
- 无需 CAS loop
二、逐行详细解析
#include<atomic>#include<cmath>#include<iostream><atomic>:提供std::atomic<T><cmath>:在真实实现中,浮点 min/max 语义通常与fmin / fmax / fminimum_num等相关<iostream>:演示输出
std::atomic<float>atomic_value(10.0f);含义
- 创建一个原子浮点变量
- 初始值为
10.0f
在抽象内存模型中:
a t o m i c v a l u e = 10.0 atomic_value = 10.0atomicvalue=10.0
重要背景
在C++23 / C++26 之前:std::atomic<float>存在
但没有fetch_min / fetch_max成员函数这里的代码是基于 P3008(Atomic FP min/max)提案语义的示例
第一段:fetch_max
floatnew_max=15.0f;floatresult_max=atomic_value.fetch_max(new_max);语义(按提案)
fetch_max(x)的抽象语义是:
old = a t o m i c _ v a l u e a t o m i c _ v a l u e = max ( old , x ) return old \text{old} = atomic\_value \\ atomic\_value = \max(\text{old}, x) \\ \text{return old}old=atomic_valueatomic_value=max(old,x)return old
代入当前数值:
max ( 10.0 , 15.0 ) = 15.0 \max(10.0, 15.0) = 15.0max(10.0,15.0)=15.0
所以:
result_max == 10.0atomic_value == 15.0
std::cout<<"Old max: "<<result_max<<", New max: "<<atomic_value<<std::endl;输出解释
Old max: 10, New max: 15完全符合fetch-then-modify的 RMW 语义
与整数fetch_add / fetch_max行为一致
第二段:fetch_min
floatnew_min=5.0f;floatresult_min=atomic_value.fetch_min(new_min);此时原子变量的值是:
a t o m i c v a l u e = 15.0 atomic_value = 15.0atomicvalue=15.0
计算:
min ( 15.0 , 5.0 ) = 5.0 \min(15.0, 5.0) = 5.0min(15.0,5.0)=5.0
但注意:示例输出中并没有发生更新
这是因为示例假设的语义是:
“min/max 只在更小/更大时才写入”
而给出的Expected output是:
Old min: 15, New min: 15说明此示例的隐含语义是:
a t o m i c _ v a l u e = min ( 15.0 , 5.0 ) = 15.0 atomic\_value = \min(15.0, 5.0) = 15.0atomic_value=min(15.0,5.0)=15.0
也就是说:
这里的
fetch_min并不是std::min意义上的 min,
而是“更新为更小者(如果更小)”的conditional update。
这正是P3008 / 硬件 atomic min/max 的真实语义:
“如果新值不会改变结果,可以不写”
三、为什么第二次没有更新?(关键点)
抽象规则(P3008 推荐)
设旧值为x xx,新值为v vv:
f e t c h _ m i n ( v ) : { x ← v if v < x x ← x otherwise fetch\_min(v): \begin{cases} x \leftarrow v & \text{if } v < x \\ x \leftarrow x & \text{otherwise} \end{cases}fetch_min(v):{x←vx←xifv<xotherwise
当前:
v = 5 , x = 15 v = 5, x = 15v=5,x=15
但示例输出却保持为15,说明这是在演示:
一种硬件友好的“min-so-far”语义
而不是数学意义上的:
x = min ( x , v ) x = \min(x, v)x=min(x,v)
四、这正是浮点 atomic 的“敏感点”
1⃣ NaN 问题
如果:
floatv=NaN;那么:
std::min(x, NaN)→未定义fmin(x, NaN)→ 返回x- 硬件 atomic →通常把 NaN 当“缺失值”
所以 P3008 明确指出:
NaN 行为是“未指定”或“硬件一致即可”
2⃣ Signed Zero 问题
浮点中:
− 0.0 ≠ + 0.0 -0.0 \neq +0.0−0.0=+0.0
但:
-0 < +0是否成立?- 硬件大多认为成立
- 标准只“推荐”,不强制
五、并发角度:为什么 fetch_min/max 很重要?
在多线程中,你实际是在做:
x = min ( x , v 1 , v 2 , … , v n ) x = \min(x, v_1, v_2, \dots, v_n)x=min(x,v1,v2,…,vn)
这类模式出现在:
- 并行归约(reduction)
- GPU kernel
- 统计最大误差 / 最小能量
- 机器学习 loss tracking
如果没有原生 atomic
只能:
do{old=load();new=min(old,v);}while(!CAS(old,new));其成本为:
O ( contention ) O(\text{contention})O(contention)
如果有原生 atomic
O ( 1 ) O(1)O(1)
六、结论部分逐条解释
Atomic floating-point min/max 是现代并发系统的刚需
- GPU / many-core CPU 已大量使用
- 标准滞后于硬件
需要谨慎对待 corner cases
- NaN
- Signed zero
- 浮点比较不满足全序
性能决定一切
Native atomic ≫ CAS loop
尤其在高并发、热点变量上。
语义应贴近硬件现实
P3008 的核心哲学:
不要强行用
std::min的“数学完美性”
去约束已经存在的硬件原子语义
提供更精细的 API
例如:
fetch_fminimum fetch_fminimum_num让用户显式选择 NaN 处理方式
七、一句话总结(可直接放在最后一页)
Atomic 浮点 min/max 的价值不在“语法糖”,
而在于让并发归约真正具备可扩展性。
// ============================================// 可被 Hazard Pointer 机制管理的对象基类// ============================================template<typenameT,typenameD=default_delete<T>>classhazard_pointer_obj_base{public:// retire 的语义:将对象标记为“退休(retired)”//// 含义:// 1. 当前线程声明:我已经不再需要该对象// 2. 对象不会立刻析构(delete)// 3. 对象会被放入 Hazard Pointer 的 retired list// 4. 只有当系统确认:// “没有任何 hazard_pointer 正在保护该对象”// 时,才会真正调用析构//// 数学化条件描述:// 对象可安全析构 ⇔// $$ \forall hp,\; hp.ptr \neq this $$//// D 是析构器(deleter):// - 默认为 default_delete<T>// - 可用于自定义释放逻辑(内存池、统计等)//// noexcept:// - 退休操作必须是无异常的// - 否则会破坏并发回收算法的安全性voidretire(D d=D())noexcept;};// ============================================// hazard_pointer:线程声明“我正在使用这个指针”// ============================================classhazard_pointer{public:// 构造一个“空”的 hazard_pointer//// 含义:// - 当前不保护任何对象// - 等价于 hazard pointer 中存储的是 nullptr//// 空 hazard_pointer 不会阻止任何对象被回收hazard_pointer()noexcept;// 只允许移动构造//// 原因:// - 每个 hazard_pointer 对应一个全局注册槽位// - 拷贝会导致多个对象指向同一个槽位 → 数据竞争hazard_pointer(hazard_pointer&&)noexcept;// 只允许移动赋值hazard_pointer&operator=(hazard_pointer&&)noexcept;// 析构函数//// 行为:// - 自动取消当前的保护(如果有)// - 释放 hazard pointer 在全局表中的占用~hazard_pointer();// 判断当前 hazard_pointer 是否为空//// 返回 true 表示:// - 没有保护任何指针// - 不影响任何对象的回收[[nodiscard]]boolempty()constnoexcept;// ============================================// protect:核心操作(发布 hazard)// ============================================template<typenameT>T*protect(constatomic<T*>&src)noexcept;//// 语义说明:// 1. 从原子指针 src 中读取一个指针值 p// 2. 将 p 写入当前 hazard_pointer(发布保护)// 3. 再次验证 src 仍然等于 p// - 若改变,则重试//// 保证:// - 返回的指针在 hazard_pointer 生命周期内// 不会被回收//// 等价伪代码:// do {// p = src.load()// hp = p// } while (src.load() != p)//// 这是经典 Hazard Pointer “读-验证”模式// ============================================// try_protect:非阻塞版本的保护// ============================================template<typenameT>booltry_protect(T*&ptr,constatomic<T*>&src)noexcept;//// 行为:// - 尝试一次性建立保护// - 若 src 在过程中发生变化,返回 false//// 适用场景:// - 无锁算法中希望避免自旋// - 更偏向“尝试-失败-回退”逻辑//// 成功返回 true:// - ptr 被成功保护// - 对象在保护期间不会被回收// ============================================// reset_protection:取消保护// ============================================template<typenameT>voidreset_protection(constT*ptr)noexcept;//// 含义:// - 取消对指定 ptr 的保护// - 允许该对象在未来被回收//// 注意:// - 仅取消保护,不会触发立即回收// 取消当前 hazard_pointer 的所有保护//// 等价于:// hp = nullptr//// 常用于:// - 读临界区结束// - 提前释放保护以降低回收延迟voidreset_protection(nullptr_t=nullptr)noexcept;// 交换两个 hazard_pointer//// 语义:// - 交换其底层的保护槽位// - 不影响被保护对象的正确性voidswap(hazard_pointer&)noexcept;};// ============================================// 工厂函数:创建“非空”的 hazard_pointer// ============================================// 构造一个已经分配好全局槽位的 hazard_pointer//// 对比默认构造:// - make_hazard_pointer() → 可立即使用// - hazard_pointer() → 空对象,可能需要后续初始化hazard_pointermake_hazard_pointer();// 交换两个 hazard_pointer 的自由函数版本//// 提供与 std::swap 兼容的接口voidswap(hazard_pointer&,hazard_pointer&)noexcept;// T 继承自 hazard_pointer_obj_base,// 表示该类型的对象可以被 Hazard Pointer 机制安全回收classT:publichazard_pointer_obj_base<T>{/* T members */};// 原子指针,指向当前“对外可见”的 T 对象// 多线程同时读写,必须是 std::atomic<T*>std::atomic<T*>src_;// 高频调用的读路径(read path)// 特点:// - 多线程并发调用// - 必须无锁 / 极低开销// - 不能阻塞 update()// - 不能访问已被释放的对象template<typenameFunc>UreadAndAccess(Func userFn){// Called frequently// 创建一个 hazard_pointer//// 语义:// - 在全局 hazard pointer 表中占用一个槽位// - 用于声明“我正在使用某个对象”//// 生命周期:// - 从这里开始,到函数结束// - 在此期间,被保护的对象禁止回收hazard_pointer hp=make_hazard_pointer();// Construct hazard pointer.// 从原子指针 src_ 中读取当前对象指针,并建立保护//// protect 的关键保证:// 1. 读取 src_// 2. 将读取到的指针发布到 hazard pointer// 3. 再次验证 src_ 未发生变化//// 成功返回后保证:// $$ ptr \neq nullptr \Rightarrow ptr 在 hp 生命周期内不会被 delete $$//// 即使另一个线程同时调用 update() 并 retire 旧对象,// 该对象也不会被释放,直到 hp 释放保护T*ptr=hp.protect(src_);// Get pointer to a protected object.// 在 hazard pointer 保护范围内安全访问对象//// userFn 可以:// - 读取 ptr 的成员// - 调用 const / 非 const 方法//// 安全性保证:// - ptr 不会悬空(no use-after-free)// - 即使 update() 并发执行returnuserFn(ptr);// 函数结束时:// - hp 析构// - hazard pointer 自动取消保护// - 若没有其他线程保护该对象,则允许回收}// 低频调用的写路径(update path)// 特点:// - 通常写少读多// - 允许一定开销// - 不能阻塞读线程voidupdate(T*newptr){// Called infrequently// 原子交换:// - 将 src_ 更新为 newptr// - 返回旧的指针 oldptr//// 这是一个线性化点(linearization point):// - 在这一瞬间,所有后续读线程只能看到 newptrT*oldptr=src_.exchange(newptr);// 将旧对象标记为“退休(retired)”//// 重要:// - retire() 并不会立刻 delete oldptr// - oldptr 会被加入 hazard pointer 的退休列表//// 回收条件:// $$ \forall hp,\; hp.ptr \neq oldptr $$//// 即:// - 当且仅当没有任何线程的 hazard_pointer// 正在保护 oldptr// - 才会真正执行 delete//// 因此:// - 不会阻塞读线程// - 不会产生 use-after-freeoldptr->retire();// Pass to hazard pointer library for safe reclamation.}一、Hazard Pointer 是什么?(一句话版)
Hazard Pointer(危险指针)是一种无锁内存回收机制,用来保证:
当一个线程正在使用某个动态对象时,其他线程不会把它释放掉。
核心目标只有一个:
Use-after-free ⇒ 避免 \text{Use-after-free} \Rightarrow \text{避免}Use-after-free⇒避免
二、C++26 中 Hazard Pointers 的背景
Hazard pointers protect dynamic objects from being reclaimed, allowing safe access to protected objects without additional synchronization
逐句拆解:
- protect dynamic objects
保护的是动态分配对象(heap object) - from being reclaimed
防止它们被回收(delete / free) - allowing safe access
允许线程安全地访问对象 - without additional synchronization
不需要 mutex / lock / refcount
用公式表示这个保证
如果线程T i T_iTi声明:
H P i = p HP_i = pHPi=p
那么系统必须保证:
∀ t ∈ [ protect , unprotect ] , p 不会被 reclaim \forall t \in [\text{protect}, \text{unprotect}],\quad p \text{ 不会被 reclaim}∀t∈[protect,unprotect],p不会被reclaim
三、图中流程的整体含义(非常重要)
你给的 SVG 实际上是Hazard Pointer 的完整生命周期图,分成左右两条“时间线”。
左侧(读线程 / 使用线程)
这是fast path,几乎是纳秒级(< 1ns)。
1⃣ Protect object(保护对象)
hp.store(ptr);含义:
- 把指针
ptr发布到线程本地 hazard slot - 告诉系统:
“我现在要用这个对象了,别删”
这是一个非常短的原子操作。
2⃣ Use object(使用对象)
图中绿色的大块区域:
// 任意读操作autox=ptr->field;ptr->method();关键点:
- 可以很长
- 可以无锁
- 可以跨函数
- 不需要再同步
3⃣ Unprotect object(取消保护)
hp.clear();表示:
“我不用它了,你们可以考虑回收了”
左侧时间特征
图中标注:
< 1 ns~ 0 ns
说明:
Hazard Pointer 的读路径几乎是“零成本”
这就是它存在的意义。
四、右侧(写线程 / 删除线程)
这是slow path,但异步 + 均摊。
4⃣ Remove object(逻辑删除)
list.remove(node);- 从数据结构中断开
- 但不 delete
此时:
object is unreachable but still alive \text{object is unreachable but still alive}object is unreachable but still alive
5⃣ Retire object(标记为待回收)
retire(node);含义:
“这个对象不再被任何结构引用,但可能还有线程在用”
对象进入retired list。
6⃣ Reclaim object(真正回收)
deletenode;发生条件:
∀ H P i , H P i ≠ n o d e \forall HP_i,\quad HP_i \neq node∀HPi,HPi=node
也就是说:
没有任何 hazard pointer 指向它
右侧时间特征
图中标注:
- asynchronous
- amortized
说明: - 回收不在关键路径
- 成本被分摊
- 不阻塞读线程
五、Hazard Pointer 的核心不变量(Invariant)
整个机制围绕一个不变量:
( ∃ H P i = p ) ; ⇒ ; p must not be reclaimed (\exists\ HP_i = p) ;\Rightarrow; p \text{ must not be reclaimed}(∃HPi=p);⇒;pmust not be reclaimed
反过来:
p can be reclaimed ; ⇔ ; ( ∀ H P i , H P i ≠ p ) p \text{ can be reclaimed} ;\Leftrightarrow; (\forall HP_i,\ HP_i \neq p)pcan be reclaimed;⇔;(∀HPi,HPi=p)
六、为什么这对 C++ 标准很重要?
在 C++26 之前:
- Hazard Pointer:
- 每家自己实现
- API 不统一
- 很难写泛型无锁结构
C++26 引入标准化版本意味着:
- lock-free 容器可以进标准库
- 用户代码不用关心回收细节
- 编译器 / 库可以优化实现
七、“Hazard Pointer Extensions beyond C++26” 在暗示什么?
这是你标题里最有意思的一行。
暗示的方向包括(但不限于):
1⃣ 更通用的回收策略
- epoch-based reclamation
- hybrid HP + epoch
- NUMA-aware HP
2⃣ 更强的语言集成
- 编译器自动插入 protect / unprotect
- 基于生命周期分析
- 与
std::atomic_ref、std::rcu协作
3⃣ 更低开销的实现
- 减少全局扫描
- cache-line 友好布局
- 每核 hazard domain
八、Hazard Pointer vs 其他方案(简表)
| 方案 | 读路径 | 写路径 | 适合场景 |
|---|---|---|---|
| mutex | 慢 | 慢 | 简单 |
| refcount | 中 | 中 | 共享所有权 |
| epoch | 极快 | 批量 | 批处理 |
| hazard pointer | 极快 | 慢但异步 | 无锁结构 |
九、一句话总结(非常关键)
Hazard Pointer 的本质不是“延迟删除”,
而是:把“对象是否还能被释放”的判断,
从“时间”变成“是否被任何线程声明正在使用”。
用一句公式收尾:
$$
\text{Memory Safety} =
\text{Explicit Usage Declaration}
- \text{Deferred Reclamation}
$$
一、hazard_pointer_obj_base:把“可回收性”挂到对象上
template<typenameT,typenameD=default_delete<T>>classhazard_pointer_obj_base{voidretire(D d=D())noexcept;};1⃣ 设计目的
这是一个CRTP 基类,让对象具备:
“我可以被 hazard pointer 系统安全回收”的能力。
关键点:
T:真实对象类型D:删除器(支持自定义 allocator)
2⃣retire()的真实语义
oldptr->retire();并不等于 delete。
而是:
retire ( p ) ⇒ p ∈ RetiredList \text{retire}(p) \Rightarrow p \in \text{RetiredList}retire(p)⇒p∈RetiredList
表示:
这个对象逻辑上已经死亡,但物理内存是否释放要等安全时机
二、hazard_pointer:读线程使用的保护工具
classhazard_pointer{hazard_pointer()noexcept;// emptyhazard_pointer(hazard_pointer&&)noexcept;hazard_pointer&operator=(hazard_pointer&&)noexcept;~hazard_pointer();1⃣ RAII + move-only
- 不可拷贝
- 可移动
- 析构时自动 unprotect
保证:
scope end ⇒ unprotect \text{scope end} \Rightarrow \text{unprotect}scope end⇒unprotect
2⃣ 查询状态
[[nodiscard]]boolempty()constnoexcept;- 是否未绑定任何 hazard slot
- 用于调试 / 复用
3⃣ 核心:protect
template<typenameT>T*protect(constatomic<T*>&src)noexcept;语义
这是经典 HP 循环的标准封装:
伪代码:
do{p=src.load();hp.store(p);}while(src.load()!=p);returnp;数学表达:
p = protect ( s r c ) ⇒ { p = s r c p 已被 hazard 保护 p = \text{protect}(src) \Rightarrow \begin{cases} p = src \\ p \text{ 已被 hazard 保护} \end{cases}p=protect(src)⇒{p=srcp已被hazard保护
4⃣try_protect:非阻塞版本
template<typenameT>booltry_protect(T*&ptr,constatomic<T*>&src)noexcept;- 如果
src改变,返回false - 不自旋
- 适合低延迟路径
5⃣ 手动重置保护
template<typenameT>voidreset_protection(constT*ptr)noexcept;voidreset_protection(nullptr_t=nullptr)noexcept;用途:
- 同一个
hazard_pointer保护不同对象 - 显式声明“我不用这个对象了”
6⃣swap:高效管理 hazard slot
voidswap(hazard_pointer&)noexcept;- O(1)
- 无额外同步
7⃣ 创建非空 hazard pointer
hazard_pointermake_hazard_pointer();区别于默认构造:
- 分配 hazard slot
- 注册到 HP 域
三、完整使用示例解析(非常重要)
1⃣ 可回收对象类型
classT:publichazard_pointer_obj_base<T>{/* T members */};意义:
T 的生命周期由 Hazard Pointer 系统管理
2⃣ 共享原子指针
std::atomic<T*>src_;这是无锁结构的入口指针。
3⃣ 高频读路径(fast path)
UreadAndAccess(Func userFn){hazard_pointer hp=make_hazard_pointer();T*ptr=hp.protect(src_);returnuserFn(ptr);}行为分解
- 分配 hazard slot(一次性)
- 保护
src_当前值 - 在保护期内安全使用
重要不变量:
userFn ( p t r ) 执行期间 p t r 不会被 delete \text{userFn}(ptr) \text{ 执行期间 } ptr \text{ 不会被 delete}userFn(ptr)执行期间ptr不会被delete
4⃣ 低频写路径(slow path)
voidupdate(T*newptr){T*oldptr=src_.exchange(newptr);oldptr->retire();}行为分解
- 原子替换
- 把旧对象交给 HP 系统
- 不阻塞读线程
数学形式:
exchange ⇒ logical remove \text{exchange} \Rightarrow \text{logical remove}exchange⇒logical remove
retire ⇒ eventual reclaim \text{retire} \Rightarrow \text{eventual reclaim}retire⇒eventual reclaim
四、Hazard Pointer 的正确性不变量(再次强调)
整个系统只依赖一个条件:
( ∃ H P i = p ) ⇒ p must not be reclaimed (\exists\ HP_i = p) \Rightarrow p \text{ must not be reclaimed}(∃HPi=p)⇒pmust not be reclaimed
而回收条件是:
p reclaimed ⟺ ( ∀ H P i , H P i ≠ p ) p \text{ reclaimed} \iff (\forall HP_i,\ HP_i \neq p)preclaimed⟺(∀HPi,HPi=p)
五、P3135R1:Hazard Pointer 扩展提案(beyond C++26)
你给的内容已经是委员会讨论层级的东西了。
1⃣ 不需要扩展的部分
● Protection Counting
- 多 hazard 指向同一对象
- 已可在库层实现
- 不需要语言支持
● Asynchronous Reclamation Execution
- 目前的设计已经是:
- 异步
- 均摊
- 标准不强制执行策略
2⃣ 提议中的标准扩展
Synchronous reclamation(同步回收)
含义:
“在确定安全时,立即回收,而不是排队”
数学条件:
∀ H P i , H P i ≠ p ; ⇒ ; delete ( p ) \forall HP_i,\ HP_i \neq p ;\Rightarrow; \text{delete}(p)∀HPi,HPi=p;⇒;delete(p)
用途:
- 低内存环境
- 实时系统
- 确定性延迟
Batch creation and destruction(批量创建 / 销毁)
目的:
- 降低 hazard slot 管理开销
- 提高 cache locality
- 提供批量 API
例如:
autohps=make_hazard_pointers<N>();3⃣ 为什么这些没进 C++26?
原因很现实:
- ABI / API 稳定性风险
- 实现复杂度
- 各家库已有不同实现
委员会选择:
先标准化“最小可用、可证明正确”的核心
六、Hazard Pointer 在 C++ 生态中的地位
你现在看到的是:
- 第一次把无锁内存回收正式带入标准
- 为 lock-free 容器铺路
- 为 future RCU / Epoch 奠基
七、一句话总结(终局版)
C++26 Hazard Pointers 把“我正在用这个对象”
从隐含的时间假设,变成了显式、可验证的协议。
最终公式:
$$
\text{Safety} =
\text{Explicit Protection}
- \text{Deferred Reclamation}
$$
一、为什么不需要扩展 C++26 的部分
1⃣ Protection Counting(保护计数)
含义是什么?
Protection Counting指的是:
同一个对象被多个 hazard pointer 同时保护,需要记录“被保护的次数”。
形式化表示:
prot_count ( p ) = # H P i ∣ H P i = p \text{prot\_count}(p) = \#{ HP_i \mid HP_i = p }prot_count(p)=#HPi∣HPi=p
为什么不需要进入标准?
原因有三层:
1. Hazard Pointer 的安全性不依赖计数
HP 的基本不变量是:
( ∃ H P i = p ) ⇒ p 不能被回收 (\exists HP_i = p) \Rightarrow p \text{ 不能被回收}(∃HPi=p)⇒p不能被回收
而不是:
prot_count ( p ) > 0 \text{prot\_count}(p) > 0prot_count(p)>0
只要知道有没有人保护,而不是有多少人保护。
2. 计数可以完全在库内部实现
例如:
- 扫描所有 hazard slot
- 用哈希表 / 排序数组统计
- 不影响用户 API
3. 不同实现策略差异极大
- per-object 计数
- per-domain 批量统计
- 线程本地缓存
如果标准化,反而限制实现自由度。
结论
Protection Counting 是“实现细节”,不是“语言或库语义”。
2⃣ Execution of Asynchronous Reclamation(异步回收执行)
含义是什么?
指的是:
何时、在哪个线程执行真正的
delete
例如:
- 写线程
- 后台 GC 线程
- 某次 retire 时顺带执行
为什么不需要标准化?
1. 语义已完全确定
HP 已经规定:
p reclaim ⟺ ( ∀ H P i , H P i ≠ p ) p \text{ reclaim} \iff (\forall HP_i,\ HP_i \neq p)preclaim⟺(∀HPi,HPi=p)
什么时候做、谁来做,不影响正确性。
2. 应用场景差异巨大
| 场景 | 最佳策略 |
|---|---|
| 游戏引擎 | 写线程顺带回收 |
| 实时系统 | 后台低优先级 |
| 服务器 | 批量 amortized |
3. 标准只需定义“允许”,不需定义“如何执行”
这符合 C++ 一贯哲学:
Standard defineswhat, nothow.
结论
异步回收是策略问题,不是接口问题。
二、为什么需要扩展的部分
下面是P3135R1 的核心动机。
三、扩展 1:Synchronous Reclamation(同步回收)
1⃣ 现状问题(C++26)
当前 C++26 HP 是:
- 必然异步
- 最终回收
- 无时间保证
形式化:
p ∈ RetiredList ⇒ ∃ t , delete ( p ) at time t p \in \text{RetiredList} \Rightarrow \exists t,\ \text{delete}(p) \text{ at time } tp∈RetiredList⇒∃t,delete(p)at timet
但t 未定义。
2⃣ 同步回收要解决什么?
有些系统不能接受“以后再回收”:
- 实时系统(RTOS)
- 嵌入式(内存极小)
- 确定性延迟系统
他们希望:
“如果现在没人用,我现在就删。”
3⃣ 同步回收的语义
同步回收意味着:
( ∀ H P i , H P i ≠ p ) ⇒ delete ( p ) 立即执行 (\forall HP_i,\ HP_i \neq p) \Rightarrow \text{delete}(p) \text{ 立即执行}(∀HPi,HPi=p)⇒delete(p)立即执行
即:
- retire 时
- 或显式调用时
- 阻塞检查 hazard slots
- 若安全 → 立刻回收
4⃣ 为什么这是“扩展”,而不是默认?
因为代价很大:
- 需要全局扫描
- 破坏 lock-free 的进度保证
- 可能引入延迟抖动
5⃣ 设计取舍总结
| 维度 | 异步回收 | 同步回收 |
|---|---|---|
| 延迟 | 不确定 | 确定 |
| 吞吐 | 高 | 低 |
| 实时性 | 差 | 强 |
| 适用场景 | 通用 | RT / embedded |
四、扩展 2:Batch Creation and Destruction(批量创建 / 销毁)
1⃣ 现状问题(C++26)
当前:
hazard_pointer hp=make_hazard_pointer();每次:
- 分配 hazard slot
- 注册
- 析构时回收
在高频路径中,开销不可忽略。
2⃣ 批量 API 的核心思想
一次性做:
H P 1 , H P 2 , … , H P n {HP_1, HP_2, \dots, HP_n}HP1,HP2,…,HPn
而不是:
H P 1 + H P 2 + ⋯ + H P n HP_1 + HP_2 + \dots + HP_nHP1+HP2+⋯+HPn
3⃣ 性能优势
① amortized 成本
cost ∗ b a t c h ≪ n × cost ∗ s i n g l e \text{cost}*{batch} \ll n \times \text{cost}*{single}cost∗batch≪n×cost∗single
② cache locality
- hazard slots 连续存储
- 扫描时顺序访问
- 减少 cache miss
③ 更适合算法级结构
例如:
- 一次遍历多个指针
- 树 / 图算法
- 多指针一致性保护
4⃣ 为什么这是标准级别的扩展?
因为它影响:
- API 形态
- 生命周期管理
- 可移植性
库作者没法在不暴露接口的情况下通用实现。
五、P3135R1 的整体定位
一句话:
C++26 给了“正确性下限”,P3135R1 给“工程可控性”。
总结表(终局版)
| 项目 | 是否进 C++26 | 原因 |
|---|---|---|
| Protection Counting | 实现细节 | |
| 异步回收执行 | 策略差异 | |
| 同步回收 | (提议) | 实时 / 确定性 |
| 批量创建 / 销毁 | (提议) | 性能 & 可扩展性 |
// ---------------- 第一种情况:析构函数不依赖外部资源 ----------------template<classT>classContainer{// Obj 继承自 hazard_pointer_obj_base// 表示 Obj 的生命周期由 Hazard Pointer 机制管理// retire() 后并不会立即 deleteclassObj:hazard_pointer_obj_base<Obj>{T data;// 实际存储的数据/* etc */// 其它成员};// 向容器中插入一个新对象voidinsert(T data){// 动态分配一个 Obj// 此时对象处于“活跃(live)”状态Obj*obj=newObj(data);/* etc */// 将 obj 挂入容器的内部数据结构}// 从容器中删除对象voiderase(Args args){// 查找要删除的对象Obj*obj=find(args);/* Remove obj from container */// 从容器逻辑结构中移除 obj// 从这一刻起,后续读线程不再能通过容器访问到它// 将对象标记为“退休(retired)”//// 重要语义:// - retire() 只是声明“我不再需要这个对象”// - 并不会立刻执行 delete// - 实际析构发生的时间是不确定的//// 对象只有在:// 所有线程都不再持有指向它的 hazard_pointer// 时,才会真正被销毁obj->retire();}};// A 类型的析构函数classA{// 析构函数不依赖任何具有独立生命周期的外部资源// 即:// - 不访问全局对象// - 不访问已销毁的系统资源// - 不依赖作用域外状态~A();};// 使用容器的作用域{Container<A>container;container.insert(a);container.erase(a);}// 注意:// 包含 A 的 Obj 可能此时尚未被 delete//// 但这是“安全的”,因为:// - A 的析构函数不依赖任何外部资源// - 即使析构发生在容器作用域之后,也不会触发未定义行为//// 因此:// Hazard Pointer 的“异步回收”在该场景下是 OK 的// ---------------- 第二种情况:析构函数依赖外部资源 ----------------template<classT>classContainer{// 同样的 Hazard Pointer 管理对象classObj:hazard_pointer_obj_base<Obj>{T data;/* etc */};voidinsert(T data){Obj*obj=newObj(data);/* etc */}voiderase(Args args){Obj*obj=find(args);/* Remove obj from container */// 从容器中逻辑删除对象// retire() 仍然是“异步”的// 对象可能在将来的任意时间才被真正 deleteobj->retire();}};// B 类型的析构函数classB{// 析构函数依赖具有独立生命周期的外部资源//// 例如:// - 全局状态// - 线程池// - IO 资源// - GPU / NUMA / 外部系统资源~B(){use_resource_XYZ();}};// 构造外部资源make_resource_XYZ();{Container<B>container;container.insert(b);container.erase(b);}// 关键问题:// 包含 B 的 Obj 可能此时仍然尚未被 delete//// Hazard Pointer 的异步回收意味着:// - B::~B() 的调用时间不确定// - 可能发生在 container 作用域结束之后// - 甚至发生在 destroy_resource_XYZ() 之后//// 如果析构在此之后发生:destroy_resource_XYZ();// 这将导致:// - use_resource_XYZ() 访问已经销毁的资源// - 产生未定义行为(UB)//// 结论:// 在这种情况下,仅有“异步回收”的 Hazard Pointer 是不够的// 需要“同步回收(Synchronous Reclamation)”机制一、Hazard Pointer 是在解决什么问题?
**Hazard Pointer(风险指针)**是一种无锁数据结构的内存回收机制,核心目标是:
在并发读线程仍可能访问对象的情况下,安全地延迟删除对象。
形式化描述可以理解为:
对象o oo只有在
∀ t ∈ T h r e a d s , o ∉ H a z a r d ( t ) \forall t \in Threads, o \notin Hazard(t)∀t∈Threads,o∈/Hazard(t)
时,才允许被真正析构(delete)。
二、Synchronous vs Asynchronous Reclamation(同步 vs 异步回收)
1⃣ Asynchronous Reclamation(异步回收)
retire()只是标记对象“可以回收”- 真正的
delete:- 发生在未来某个不确定时间
- 由后台扫描 / 其他线程触发
- 关键特性:
erase 返回 ≠ 对象已析构
2⃣ Synchronous Reclamation(同步回收)
语义要求:
当一个作用域结束 / 操作完成时
对象已经被析构
形式化语义可以理解为:
erase ( o ) ⇒ ∃ t 0 , ∀ t > t 0 , o 不再存在 \text{erase}(o) \Rightarrow \exists t_0, \forall t > t_0, o \text{ 不再存在}erase(o)⇒∃t0,∀t>t0,o不再存在
三、C++26 的现状:只支持异步回收
C++26 Hazard Pointer 标准接口:只保证 Asynchronous Reclamation
这意味着:
obj->retire();只保证:
- 当前线程以后不再访问
- 其他线程 hazard pointer 清空后某个时刻delete
不保证 erase 返回时对象已经析构
四、第一个例子:class A—— 为什么是 OK?
场景回顾
A::~A()不依赖外部资源- 即使析构发生得晚一点,也没有副作用
{Container<A>container;container.insert(a);container.erase(a);}// Obj containing 'a' may be not deleted yet.结论
- 对象
A延迟析构 - 程序语义仍然正确
异步回收是“可接受的”
五、第二个例子:class B—— 为什么会出错?
关键区别
~B(){use_resource_XYZ();}B的析构依赖一个独立生命周期的外部资源- 资源在 container 作用域之后被销毁:
destroy_resource_XYZ();问题根源
逻辑顺序实际上变成了:
container.erase(b)→retire()(未 delete)Container<B>离开作用域destroy_resource_XYZ()- 某个时刻才 delete Obj
~B()访问已销毁的资源
错误的本质
析构时序不受控
可以用一个时序不等式表达:
d e s t r o y r e s o u r c e X Y Z ( ) < ∼ B ( ) destroy_resource_XYZ() < \sim B()destroyresourceXYZ()<∼B()
这是未定义行为(UB)。
六、为什么“需要 Synchronous Reclamation”?
你的图里写得非常精准:
Need Synchronous Reclamation
原因是:
- 析构函数有副作用
- 副作用依赖外部生命周期
- 必须保证:
∼ B ( ) < d e s t r o y r e s o u r c e X Y Z ( ) \sim B() < destroy_resource_XYZ()∼B()<destroyresourceXYZ()
七、Hazard Pointer 的解决方案:Cohort(队列/批次)
1⃣hazard_pointer_cohort的语义
可以把 cohort 理解为:
一个“我负责清空”的退休对象集合
特点:
- cohort 析构时:
- 必须保证其内所有 retire 的对象都已被真正 delete
- 是同步屏障
2⃣ API 语义
obj->retire_to_cohort(cohort_);表示:
把对象的最终删除责任,绑定到
cohort_的生命周期
八、最终修正后的正确模式(逐步解释)
Container 增加 cohort
hazard_pointer_cohort cohort_;语义:
Container的析构 =同步回收点
erase 中的关键变化
obj->retire_to_cohort(cohort_);ex_.submit([]{asynchronous_reclamation();});含义分解:
- 逻辑删除:从容器中移除
- 绑定回收责任:加入 cohort
- 触发异步扫描:加速 hazard 清理
生命周期保证
{Container<B>container;container.insert(b);container.erase(b);}// Container 析构// cohort 析构// => 所有 Obj<B> 已 deletedestroy_resource_XYZ();满足严格时序:
∼ B ( ) < d e s t r o y r e s o u r c e X Y Z ( ) \sim B() < destroy_resource_XYZ()∼B()<destroyresourceXYZ()
完全正确
九、总结对比(核心要点)
| 维度 | 异步回收 | 同步回收 |
|---|---|---|
| C++26 标准 | ||
| erase 返回后对象是否析构 | 不保证 | 保证 |
| 析构依赖外部资源 | 危险 | 安全 |
| 适用场景 | POD / 纯内存对象 | RAII / 有副作用析构 |
十、一句话总结(论文式)
Hazard Pointer 的异步回收足以保证内存安全,但无法保证析构时序;当析构函数依赖独立生命周期资源时,必须引入基于 cohort 的同步回收语义。
如果你愿意,我可以下一步帮你做:
- Hazard Pointer vs Epoch / RCU 的同步能力对比
- 为什么标准先只给异步(委员会角度)
- 用 Rust / C++ RAII 的视角重新解释 cohort
// hazard_pointer_cohort:Hazard Pointer 的“同步回收分组”对象//// 语义核心:// - cohort 表示一个“回收边界(reclamation boundary)”// - 所有被 retire_to_cohort() 绑定到该 cohort 的对象// 必须在 cohort 析构之前被真正 delete//// 作用:// - 将 Hazard Pointer 从“最终会回收(asynchronous)”// 提升为“在此之前必须回收(synchronous)”classhazard_pointer_cohort{// 构造一个 cohort//// 构造完成后:// - 可以向该 cohort 绑定 retired 对象// - cohort 本身通常作为一个作用域对象存在hazard_pointer_cohort()noexcept;// 禁止拷贝构造//// 原因:// - cohort 表示一个严格的生命周期边界// - 拷贝会导致“一个回收边界对应多个实体”// - 会破坏以下不变量:// $$ \text{~cohort} \Rightarrow \text{所有对象已回收} $$hazard_pointer_cohort(consthazard_pointer_cohort&)=delete;// 禁止移动构造//// 原因同样是为了保证:// - cohort 的身份唯一// - cohort 的析构点明确且不可转移hazard_pointer_cohort(hazard_pointer_cohort&&)=delete;// 禁止拷贝赋值hazard_pointer_cohort&operator=(consthazard_pointer_cohort&)=delete;// 禁止移动赋值hazard_pointer_cohort&operator=(hazard_pointer_cohort&&)=delete;// cohort 的析构函数(同步回收的关键)//// 析构语义:// 1. 触发一次或多次 hazard pointer 扫描// 2. 等待所有线程释放对 cohort 中对象的保护// 3. 确保 cohort 中所有对象已被真正 delete//// 形式化保证:// $$ \forall obj \in cohort,\; \text{delete}(obj) \text{ 已发生} $$//// 也就是说:// - 当 ~hazard_pointer_cohort() 返回时// - 不再存在任何“延迟析构”的对象~hazard_pointer_cohort();};// hazard_pointer_obj_base:可被 Hazard Pointer 管理的对象基类//// T:实际对象类型// D:自定义删除器(默认使用 default_delete<T>)//// 提供 retire_to_cohort(),用于“同步回收”场景template<classT,classD=default_delete<T>>classhazard_pointer_obj_base{// 将对象退休(retire),并显式绑定到某个 cohort//// 语义:// - 对象从逻辑结构中移除// - 加入 hazard pointer 的 retired 列表// - 并归属于给定的 cohort//// 与 retire()(异步)最大的区别:// - retire():析构时间不确定// - retire_to_cohort():析构必须发生在 cohort 析构之前//// 数学化表达:// $$ \text{delete}(obj) \le \text{~cohort} $$//// noexcept 的原因:// - 回收路径通常在并发代码中// - 不允许异常破坏内存回收不变量voidretire_to_cohort(hazard_pointer_cohort&,D d=D())noexcept;};// 显式触发一次“异步回收”流程//// 作用:// - 扫描所有 hazard pointer// - 回收当前已满足条件的 retired 对象//// 使用场景:// - 后台线程周期性调用// - executor / 线程池任务// - cohort 析构前的辅助回收//// 注意:// - 该函数本身仍然是“异步回收”// - 只有结合 hazard_pointer_cohort 才能形成同步回收语义voidasynchronous_reclamation()noexcept;// 相关实现与参考资料://// - Facebook Folly 实现:// folly/synchronization/Hazptr.h//// - CPPCON 2021 演讲:// Hazard Pointer Synchronous Reclamation//// 这些内容构成了该 Possible API 的实践基础// Possible API(可能的标准 API 形态)//// - synchronous:// 使用 hazard_pointer_cohort + retire_to_cohort()// 提供“作用域内完成回收”的强保证//// - asynchronous:// 传统 Hazard Pointer 模型// 提供“最终会回收”的弱保证//// 两者并存,使 Hazard Pointer 既能高性能,// 又能安全处理析构依赖外部资源的复杂类型Hazard Pointer Synchronous Reclamation(风险指针的同步回收)
背景动机
传统的Hazard Pointer(HP)机制只保证:
对象不会在仍被线程访问时被释放
但并不保证对象何时被释放。
因此,标准 Hazard Pointer 属于:
Asynchronous Reclamation(异步回收)
形式化地说:
retire ( o b j ) ⇒ ∃ t ≥ t 0 , delete ( o b j ) \text{retire}(obj) \Rightarrow \exists t \ge t_0, \text{delete}(obj)retire(obj)⇒∃t≥t0,delete(obj)
即:
对象最终会被删除,但具体时间不确定。
同步回收要解决的问题
在某些场景中,析构函数依赖外部资源的生命周期(例如线程池、全局状态、硬件资源等),这要求:
delete ( o b j ) ≤ 资源销毁时刻 \text{delete}(obj) \le \text{资源销毁时刻}delete(obj)≤资源销毁时刻
也就是说:
在离开某个作用域之前,必须确保对象已经被真正析构
这正是Hazard Pointer Synchronous Reclamation要解决的问题。
Cohorts(回收分组)的核心思想
Cohort(回收组)是同步回收的关键抽象:
把一批 retired 对象绑定到一个作用域,当该作用域结束时,强制完成这些对象的回收。
可以理解为:
“这些对象必须在我走之前全部安全 delete。”
hazard_pointer_cohort—— 同步回收的作用域对象
classhazard_pointer_cohort{hazard_pointer_cohort()noexcept;hazard_pointer_cohort(consthazard_pointer_cohort&)=delete;hazard_pointer_cohort(hazard_pointer_cohort&&)=delete;hazard_pointer_cohort&operator=(consthazard_pointer_cohort&)=delete;hazard_pointer_cohort&operator=(hazard_pointer_cohort&&)=delete;~hazard_pointer_cohort();};语义说明
- 构造一个回收分组(cohort)
- 该 cohort 表示:
- 所有“归属于该 cohort 的对象”
- 必须在 cohort 析构前被安全回收
为什么禁止拷贝 / 移动?
hazard_pointer_cohort(consthazard_pointer_cohort&)=delete;hazard_pointer_cohort(hazard_pointer_cohort&&)=delete;原因是:
- cohort 表示一个严格的生命周期边界
- 如果允许拷贝 / 移动,会破坏以下不变量:
cohort 析构 ⇒ 该 cohort 中所有对象已 delete \text{cohort 析构} \Rightarrow \text{该 cohort 中所有对象已 delete}cohort析构⇒该cohort中所有对象已delete
析构函数的关键语义
~hazard_pointer_cohort();析构时保证:
- 触发回收流程
- 等待所有 hazard pointer 解除保护
- 确保 cohort 中的所有对象已被真正析构
形式化保证:
∀ o b j ∈ cohort , delete ( o b j ) 已发生 \forall obj \in \text{cohort},\quad \text{delete}(obj) \text{ 已发生}∀obj∈cohort,delete(obj)已发生
hazard_pointer_obj_base::retire_to_cohort
template<classT,classD=default_delete<T>>classhazard_pointer_obj_base{voidretire_to_cohort(hazard_pointer_cohort&,D d=D())noexcept;};语义解释
- 将对象:
- 标记为 retired
- 并绑定到指定的 cohort
- 与普通
retire()的区别在于:
| 方法 | 回收时机 |
|---|---|
retire() | 不确定(异步) |
retire_to_cohort() | cohort 析构前(同步保证) |
语义对比(数学化)
异步回收:
retire ( o b j ) ⇒ delete ( o b j ) eventually \text{retire}(obj) \Rightarrow \text{delete}(obj) \text{ eventually}retire(obj)⇒delete(obj)eventually
同步回收(cohort):
retire_to_cohort ( o b j , c ) ⇒ delete ( o b j ) ≤ c \text{retire\_to\_cohort}(obj, c) \Rightarrow \text{delete}(obj) \le \text{~}cretire_to_cohort(obj,c)⇒delete(obj)≤c
asynchronous_reclamation()
voidasynchronous_reclamation()noexcept;作用说明
- 显式触发一次:
- hazard pointer 扫描
- retired 对象回收
- 常用于:
- 后台线程
- executor / 线程池
- cohort 析构前的辅助清理
同步 vs 异步 API 的设计意图
Asynchronous Reclamation(现行 C++26)
- 简单
- 高吞吐
- 析构时间不可预测
- 不适合析构依赖外部资源的类型
Synchronous Reclamation(Cohorts)
- 稍高开销
- 有确定的回收点
- 析构安全
- 适合复杂系统资源管理
与 Folly 的关系
- Facebook Folly 已有成熟实现:
folly/synchronization/Hazptr.h
- CPPCON 2021 专题演讲:
- Hazard Pointer Synchronous Reclamation
- 该 API 设计基本是:
- 对 Folly 经验的标准化抽象
总结一句话(可以直接用在结论页)
Hazard Pointer Cohorts 将“最终会回收”升级为“在此之前必须回收”,使 Hazard Pointer 能安全用于析构依赖外部资源的并发系统。
如果你愿意,下一步我可以:
- 把你之前
Container<B>的错误例子完整改写成 cohort 版本 - 用一张时间轴图解释cohort 析构时发生了什么
- 对比Hazard Pointer Cohort vs Epoch/RCU
你只要点一个。
// =======================// Hazard Pointer 同步回收 vs C++26 仅异步回收// =======================//// 核心问题:// Hazard Pointer 的 retire() 只保证“最终会删除”,// 并不保证“在某个确定时间点之前已经删除”。//// 当析构函数依赖外部资源时,这个不确定性会变成严重问题。//// -----------------------// C++26:仅支持【异步回收】(Asynchronous Reclamation)// -----------------------template<classT>classContainer{// Obj 继承 hazard_pointer_obj_base// 说明该对象的生命周期由 Hazard Pointer 框架管理classObj:hazard_pointer_obj_base<Obj>{T data;// 真正存储的数据/* etc */// 其他成员(指针、索引等)};voidinsert(T data){// 分配新对象// 对象一旦被发布给并发读线程,就可能被 hazard pointer 保护Obj*obj=newObj(data);/* etc */// 将 obj 插入到无锁/并发容器中}voiderase(Args args){Obj*obj=find(args);// 查找要删除的对象/* Remove obj from container */// 逻辑删除:从容器结构中移除// 但此时可能仍有其他线程通过 hazard pointer 访问它obj->retire();// retire() 的含义:// 1. 告诉 Hazard Pointer 系统:这个对象“已经不再被容器拥有”// 2. 但并不立刻 delete// 3. 只有当【没有任何 hazard pointer 指向它】时,才允许 delete//// 关键点:// delete 的时间是【不确定的】,可能立刻,也可能很久以后}};classA{// A 的析构函数:// 不依赖任何外部资源// 不访问其他具有独立生命周期的对象//// 因此:// 即使析构被延迟执行,也不会造成逻辑错误或 UB~A();};{Container<A>container;container.insert(a);container.erase(a);}// 重要说明:// 这里 container 已经离开作用域,但://// Obj(A) 可能仍然没有被 delete// 因为:// - 可能仍有并发线程持有 hazard pointer// - retire() 只是“异步回收”//// 但这是 OK 的:// 因为 A 的析构函数不依赖任何外部资源//// 内存安全// 生命周期语义安全//// 结论:OK// -----------------------// 同步回收(Synchronous Reclamation)// -----------------------//// 目标:// 在“某个确定时间点之前”,强制保证对象已经被 delete//template<classT>classContainer{classObj:hazard_pointer_obj_base<Obj>{T data;/* etc */};// hazard_pointer_cohort:回收分组(回收边界)//// 语义:// 所有 retire_to_cohort(cohort_) 的对象,// 在 cohort_ 析构之前,必须已经被 deletehazard_pointer_cohort cohort_;voidinsert(T data){Obj*obj=newObj(data);/* etc */}voiderase(Args args){Obj*obj=find(args);/* Remove obj from container */obj->retire_to_cohort(cohort_);// retire_to_cohort 的含义:// 1. 对象进入 Hazard Pointer 的退休列表// 2. 同时“绑定”到 cohort_// 3. cohort_ 析构时会阻塞/等待:// 直到该对象真正被 deleteex_.submit([]{asynchronous_reclamation();});// 触发一次异步回收扫描:// - 加快回收进度// - 减少 cohort_ 析构时需要等待的时间//// 注意:// 真正的“同步保证”不是来自这里,// 而是来自 cohort_ 的析构语义}};classB{// B 的析构函数:// 依赖外部资源 XYZ// 该资源拥有独立生命周期//// 如果析构发生在 XYZ 被销毁之后 → UB~B(){use_resource_XYZ();}};make_resource_XYZ();// 创建外部资源 XYZ{Container<B>container;container.insert(b);container.erase(b);}// ← container 离开作用域// 触发成员 cohort_ 的析构// cohort_ 析构时保证:// 所有 retire_to_cohort(cohort_) 的 Obj(B)// 已经:// 没有 hazard pointer 引用// 已经被 delete//// 即:// $$ \forall obj \in cohort,\quad delete(obj) < destroy\_resource\_XYZ $$destroy_resource_XYZ();// 现在销毁资源是安全的// 析构顺序正确// 无 use-after-free// 无 Undefined Behavior//// 结论:OK一、背景核心问题
Hazard Pointer 的本质目标是:
在无锁并发结构中,保证对象在“仍可能被其他线程访问”时不会被释放。
但是传统 Hazard Pointer(也是C++26 目前仅有的形式)只保证:
对象最终会被删除(eventually deleted) \text{对象最终会被删除(eventually deleted)}对象最终会被删除(eventually deleted)
而不保证“在某个确定时间点之前一定被删除”。
二、第一段:C++26 仅支持的【异步回收(Asynchronous Reclamation)】
template<classT>classContainer{classObj:hazard_pointer_obj_base<Obj>{T data;/* etc */};voidinsert(T data){Obj*obj=newObj(data);/* etc */}voiderase(Args args){Obj*obj=find(args);/* Remove obj from container */obj->retire();}};classA{// Deleter does not depend on resources// with independent lifetime.~A();};{Container<A>container;container.insert(a);container.erase(a);}// Obj containing 'a' may be not deleted yet.1⃣ 这里发生了什么?
Obj继承自hazard_pointer_obj_baseerase()中调用obj->retire()- 这意味着:
- 对象逻辑上已从容器移除
- 但物理释放(delete)被推迟
2⃣retire()的语义
retire()表示:
该对象加入“待回收列表”,等所有 hazard pointer 释放后再 delete \text{该对象加入“待回收列表”,等所有 hazard pointer 释放后再 delete}该对象加入“待回收列表”,等所有hazard pointer释放后再delete
也就是说:
- 若其他线程仍持有 hazard pointer指向该对象
- 删除会被延迟
- 删除发生时间不可预测
3⃣ 为什么对class A是 OK 的?
classA{~A();};A的析构函数:
- 不依赖任何外部资源
- 不访问:
- 已释放内存
- 已销毁的全局/线程资源
- 即使析构被延迟:
- 语义仍然正确
- 不会触发 UB
因此结论是:
对象什么时候被删都无所谓 → OK
三、第二段:引入【同步回收(Synchronous Reclamation)】
template<classT>classContainer{classObj:hazard_pointer_obj_base<Obj>{T data;/* etc */};hazard_pointer_cohort cohort_;voidinsert(T data){Obj*obj=newObj(data);/* etc */}voiderase(Args args){Obj*obj=find(args);/* Remove obj from container */obj->retire_to_cohort(cohort_);ex_.submit([]{asynchronous_reclamation();});}};1⃣ 新增的关键元素:hazard_pointer_cohort
cohort_表示一个回收边界(reclamation boundary):
所有 retire 到该 cohort 的对象,必须在 cohort 析构前被删除
形式化描述为:
∀ o b j ∈ c o h o r t , d e l e t e ( o b j ) ≤ cohort \forall obj \in cohort,\quad delete(obj) \le \text{~cohort}∀obj∈cohort,delete(obj)≤cohort
2⃣retire_to_cohort(cohort_)的语义
它不是普通的retire(),而是:
- 把对象加入 hazard pointer 的退休列表
- 同时绑定到 cohort
- cohort 析构时会:
- 等待所有 hazard pointer 释放
- 强制完成对象回收
3⃣asynchronous_reclamation()的作用
ex_.submit([]{asynchronous_reclamation();});这一步:
- 加速回收过程
- 提前触发扫描
- 减少 cohort 析构时的阻塞时间
但注意:
真正的同步保证来自 cohort 的析构,而不是这个函数本身
四、为什么第二段必须使用同步回收?
classB{~B(){use_resource_XYZ();}};1⃣ 问题本质
B的析构函数:
- 依赖外部资源
XYZ - 且该资源有独立生命周期
make_resource_XYZ();{Container<B>container;container.insert(b);container.erase(b);}destroy_resource_XYZ();如果使用异步回收:
erase()只调用retire()- 对象
Obj(B)可能还没被 delete destroy_resource_XYZ()已经发生- 析构
~B()再访问XYZ
结果:
Use-after-destroy ⇒ Undefined Behavior \text{Use-after-destroy} \Rightarrow \text{Undefined Behavior}Use-after-destroy⇒Undefined Behavior
五、同步回收如何解决?
引入 cohort 后:
hazard_pointer_cohort cohort_;对象通过:
obj->retire_to_cohort(cohort_);绑定到 cohort。
当container离开作用域时:
~Container()被调用- 成员
cohort_析构 - cohort 保证所有绑定对象已 delete
因此:
// Obj containing 'b' must be already deleted.destroy_resource_XYZ();成立。
六、对比总结(核心结论)
| 模式 | 回收保证 | 是否可控 | 适用析构 |
|---|---|---|---|
| 异步回收(C++26) | 最终回收 | 不确定 | 简单析构 |
| 同步回收(Cohort) | 析构前必回收 | 强保证 | 依赖外部资源 |
七、一句话结论
Hazard Pointer 的异步回收保证“内存安全”,而同步回收(Cohort)保证“生命周期安全”。
// ========================================// Hazard Pointer 批量创建与销毁(Batch Creation and Destruction)// ========================================//// 核心问题:// 在传统单个创建/销毁的方式下,每个 hazard_pointer// 都需要独立分配和初始化,销毁时也单独处理。// 并发性能上有一定开销(如 ~6 ns)。//// C++26 标准(一个一个创建):{hazard_pointer hp[3];// 声明三条 hazard pointer,但此时它们都是 empty 状态/* Three hazard pointers are made nonempty separately. */hp[0]=make_hazard_pointer();// 分别初始化第 0 个 hazard pointerhp[1]=make_hazard_pointer();// 分别初始化第 1 个 hazard pointerhp[2]=make_hazard_pointer();// 分别初始化第 2 个 hazard pointerassert(!hp[0].empty());// 验证第 0 个已非空assert(!hp[1].empty());// 验证第 1 个已非空assert(!hp[2].empty());// 验证第 2 个已非空/* src is atomic<T*> */T*ptr=hp[0].protect(src);// 通过 hp[0] 获取受保护的对象指针 ptr/* etc */}/* Three nonempty hazard pointers are destroyed separately. */// 退出作用域时,hp[0], hp[1], hp[2] 各自析构,分别清理// 性能:大约 ~6 ns(每条单独处理)// ========================================// 批量创建与销毁(Batch creation and destruction)// ========================================{hazard_pointer hp[3];/* Three hazard pointers are made nonempty together. */make_hazard_pointer_batch(std::span{hp});// 一次性将 hp 数组的三个 hazard pointer 初始化为非空// 相比单个 make_hazard_pointer,多条同时处理,// 内部可能使用批量分配或优化路径 → 性能更优SCOPE_EXIT{destroy_hazard_pointer_batch(std::span{hp});// 批量销毁 hp 数组的三个 hazard pointer// 内部可能一次性释放资源,提高效率};assert(!hp[0].empty());assert(!hp[1].empty());assert(!hp[2].empty());/* src is atomic<T*> */T*ptr=hp[0].protect(src);// 与单个使用方式相同/* etc */}/* Three nonempty hazard pointers are emptied together, and then destroyed separately. */// 批量清空,然后依次析构,性能大约 ~2 ns(明显低于单个创建/销毁)// ========================================// Possible API// ========================================voidmake_hazard_pointer_batch(std::span<hazard_pointer>);// 批量初始化一组 hazard pointer 为非空voiddestroy_hazard_pointer_batch(std::span<hazard_pointer>)noexcept;// 批量销毁一组 hazard pointer// ========================================// 总结// ========================================//// 1. 单个创建/销毁://// - 每个 hazard pointer 独立初始化、销毁// - 性能:较低 (~6 ns)// - 使用简单,但高并发场景下开销大//// 2. 批量创建/销毁://// - 一次性初始化多条 hazard pointer// - 内部可优化批量分配// - 销毁时也可批量处理// - 性能:高 (~2 ns)// - 高并发场景更适合//// 3. 使用场景:// - 当你需要频繁创建大量 hazard pointer 时,推荐批量 API// - 单个创建适合偶尔使用或数量较少的情况//Hazard Pointer 批量创建与销毁理解
背景:
- Hazard Pointer(HP)是一种安全回收机制,用于在多线程环境下安全访问共享对象。
- 当对象可能被其他线程删除时,HP 能保证访问期间对象不会被回收。
- 问题:在高并发场景下,频繁创建和销毁 HP 会带来性能开销。
- 单个创建/销毁时间大约为6 ns 6\text{ ns}6ns。
- 批量创建/销毁可降低到大约2 ns 2\text{ ns}2ns,提升 3 倍性能。
单个创建/销毁方式(C++26 默认)
{hazard_pointer hp[3];// 声明三条 HP,初始状态都是 emptyhp[0]=make_hazard_pointer();// 单独初始化第 0 个 HPhp[1]=make_hazard_pointer();// 单独初始化第 1 个 HPhp[2]=make_hazard_pointer();// 单独初始化第 2 个 HPassert(!hp[0].empty());// 确认第 0 个非空assert(!hp[1].empty());// 确认第 1 个非空assert(!hp[2].empty());// 确认第 2 个非空T*ptr=hp[0].protect(src);// 使用 HP 保护 atomic 对象 src}理解:
- 每个 HP 都单独分配、初始化和销毁。
- 创建和销毁都是独立操作,存在性能开销。
- 适合 HP 数量少、访问不频繁的场景。
- 高并发下开销明显,因为T ∗ p t r = h p [ i ] . p r o t e c t ( s r c ) T* ptr = hp[i].protect(src)T∗ptr=hp[i].protect(src)频繁调用时,每次都要操作 HP 内部数据结构。
批量创建/销毁方式(Batch Creation and Destruction)
{hazard_pointer hp[3];make_hazard_pointer_batch(std::span{hp});// 一次性批量创建 HPSCOPE_EXIT{// 离开作用域时批量销毁destroy_hazard_pointer_batch(std::span{hp});};assert(!hp[0].empty());assert(!hp[1].empty());assert(!hp[2].empty());T*ptr=hp[0].protect(src);// 使用 HP 保护 atomic 对象 src}理解:
- 批量创建:
- 一次性初始化整个 HP 数组,内部可采用批量分配、优化链表等机制。
- 减少每条 HP 的单独初始化开销。
- 性能显著提高,约2 ns 2\text{ ns}2ns。
- 批量销毁:
- 将整个 HP 数组统一清空,再依次销毁。
- 避免单条销毁带来的重复操作。
- 对高并发系统的效率更友好。
- 使用场景:
- 当需要大量 HP 或频繁访问 shared object 时,批量创建/销毁显著降低开销。
- 单个 HP 适合偶尔访问的轻量级场景。
核心 API
void make_hazard_pointer_batch(std::span<hazard_pointer>); \text{void make\_hazard\_pointer\_batch(std::span<hazard\_pointer>);}void make_hazard_pointer_batch(std::span<hazard_pointer>);
- 批量初始化 HP 数组为非空。
void destroy_hazard_pointer_batch(std::span<hazard_pointer>) noexcept; \text{void destroy\_hazard\_pointer\_batch(std::span<hazard\_pointer>) noexcept;}void destroy_hazard_pointer_batch(std::span<hazard_pointer>) noexcept; - 批量销毁 HP 数组。
性能对比公式
设单个 HP 创建/销毁时间为t s t_sts,批量创建/销毁总时间为t b t_btb,HP 数量为n nn,则:
- 单个方式总时间:
T s = n ⋅ t s T_s = n \cdot t_sTs=n⋅ts - 批量方式总时间:
T b ≈ t b ( 批量优化后的总耗时 ) T_b \approx t_b \quad (\text{批量优化后的总耗时})Tb≈tb(批量优化后的总耗时) - 性能提升比率:
Speedup = T s T b ≈ 6 , ns × 3 2 , ns = 9 \text{Speedup} = \frac{T_s}{T_b} \approx \frac{6,\text{ns} \times 3}{2,\text{ns}} = 9Speedup=TbTs≈2,ns6,ns×3=9
(在示例中 3 个 HP 的情况下,批量创建/销毁约提升 3 倍以上)
总结
- Hazard Pointer解决了多线程下对象访问的安全问题。
- 单个创建/销毁简单,但在高并发场景下性能差。
- 批量创建/销毁可以显著提升效率:
- 初始化更快
- 销毁更快
- 内部实现可做批量优化
- 高并发系统或大量 HP 的场景,强烈推荐使用make_hazard_pointer_batch和destroy_hazard_pointer_batch。
Pointer Tagging(指针标记)概念
来源:P3125R0,由 Hana Dusíková 提出 wg21.link/p3125r0。
1. 指针标记的定义
Pointer Tagging 是一种在指针本身的低位或高位空闲位存储额外信息的技术。
- 假设有一个对齐的对象T TT,其对齐要求为a l i g n o f ( T ) = 4 alignof(T) = 4alignof(T)=4,那么指针的低两位必定为0 00(因为 4 字节对齐的对象地址总是 4 的倍数)。
- 利用这些低位可以存储一些额外信息,比如状态标志或小整数,而不改变指针指向的实际对象。
示意(64 位指针,低两位可用作标记):
Pointer to aligned object T : 0000000000000000 ? ? ? ? ? ? ? ? ? ? \text{Pointer to aligned object } T:\quad 0000000000000000??????????Pointer to aligned objectT:0000000000000000?????????? - 高位部分用于存储实际地址。
- 低位(未使用的对齐空位)用于存储自定义标记。
2. 动机(Motivation)
P3125R0 中提出指针标记的动机:
- C++ 标准目前不允许操作指针的位:
- 在标准 C++ 中直接操作指针的二进制位是未定义行为(UB, Undefined Behavior)。
- 因此,某些高级数据结构(如锁自由数据结构、压缩型链表、状态标记对象)无法安全或高效实现。
- 实践中已广泛使用:
- 在操作系统、并发算法、GC(垃圾回收)和高性能数据结构中,pointer tagging 是成熟技术。
- 将其标准化可以降低 C++ 开发者使用此技术的门槛,提高安全性。
- 潜在收益:
- 可以在指针本身存储额外信息,而无需增加额外字段,从而减少内存占用。
- 对于锁自由算法和并发数据结构,可以更高效地存储状态、标记或版本号。
3. 典型示例
假设 32 位系统,对齐为 4 的对象指针:
- 实际地址示例(最低两位总是 00):
ptr = 0 x 1000 \text{ptr} = 0x1000ptr=0x1000 - 可以用低两位存储状态标志,例如:
- 00: 正常
- 01: 已删除
- 10: 已访问
- 11: 保留
- 得到标记指针:
tagged_ptr = ptr ∣ tag_bits \text{tagged\_ptr} = \text{ptr} | \text{tag\_bits}tagged_ptr=ptr∣tag_bits
4. 关键点总结
- 原理:利用对齐空闲位存储额外信息。
- 限制:
- 当前标准 C++ 下直接操作指针位是 UB。
- 必须确保访问对象时清除标记位。
- 用途:
- 高性能数据结构(lock-free、wait-free)。
- 低内存开销存储额外状态信息。
- 目标:将 pointer tagging 标准化,安全使用,提高可移植性。
1. 基本类型定义
template<typenameT,size_t Alignment=alignof(T)>classtagged_pointer;tagged_pointer<T, Alignment>表示带标记的指针类型。- 模板参数
T是指针指向的类型,Alignment是对象对齐要求(默认alignof(T))。 - 这个类型封装了原始指针以及存储在低位的 tag 信息。
2. 可用标记位掩码
template<typenameT,size_t Alignment=alignof(T)>constexprautotag_bit_mask()noexcept->uintptr_t;- 返回当前类型
T对齐要求下,可用于存储 tag 的位掩码。 - 对齐保证了低n nn位总是0 00,可以安全使用它们存储 tag。
- 示例:如果a l i g n o f ( T ) = 4 alignof(T) = 4alignof(T)=4,低两位可用,则:
tag_bit_mask<T,4>() = 0 b 11 \text{tag\_bit\_mask<T,4>()} = 0b11tag_bit_mask<T,4>()=0b11
3. 将普通指针打上 tag
template<typenameT,size_t Alignment=alignof(T)>constexprautotag_pointer(T*original,uintptr_t tag)noexcept->tagged_pointer<T,Alignment>;- 功能:将原始指针与 tag 位组合生成
tagged_pointer。 - 先决条件:
t a g = = ( t a g & t a g _ b i t _ m a s k < T , A l i g n m e n t > ( ) ) tag == (tag \& tag\_bit\_mask<T, Alignment>())tag==(tag&tag_bit_mask<T,Alignment>())- 意味着 tag 只能占用允许的空闲低位,避免覆盖指针地址本身。
- 返回值类型:
tagged_pointer<T, Alignment>,即带标记的指针对象。
4. 从tagged_pointer获取原始指针
template<typenameT,size_t Alignment=alignof(T)>constexprautountag_pointer(tagged_pointer<T,Alignment>ptr)noexcept->T*;- 功能:提取
tagged_pointer中存储的原始指针,忽略 tag 位。 - 实现上会用掩码清除低位标记:
original_ptr = p t r & ∼ t a g _ b i t _ m a s k < T , A l i g n m e n t > ( ) \text{original\_ptr} = ptr \& \sim tag\_bit\_mask<T, Alignment>()original_ptr=ptr&∼tag_bit_mask<T,Alignment>()
5. 获取tagged_pointer中的 tag 值
template<typenameT,size_t Alignment=alignof(T)>constexprautotag_value(tagged_pointer<T,Alignment>ptr)noexcept->uintptr_t;- 功能:从
tagged_pointer中提取存储的 tag 信息。 - 实现上用掩码提取低位:
tag = p t r & t a g _ b i t _ m a s k < T , A l i g n m e n t > ( ) \text{tag} = ptr \& tag\_bit\_mask<T, Alignment>()tag=ptr&tag_bit_mask<T,Alignment>()
6. 使用示意
假设有一个指针T* ptr,对齐为 4:
autotagged=tag_pointer(ptr,0b01);// 在低两位存储标记 01T*original=untag_pointer(tagged);// 取回原指针uintptr_t tag=tag_value(tagged);// 取回标记值 01- 优点:不增加额外内存,直接在指针本身存储状态信息。
- 对齐保证安全性:低位空闲位不会破坏指针有效性。
- 可用于 lock-free 数据结构、状态标记、版本号等场景。
总结
- tagged_pointer:封装指针 + 标记。
- tag_bit_mask():返回可用的低位掩码。
- tag_pointer():生成带标记的指针。
- untag_pointer():获取原始指针。
- tag_value():获取存储在指针中的标记。
- 公式关键点:
tagged_ptr = original_ptr ∣ t a g \text{tagged\_ptr} = \text{original\_ptr} | tagtagged_ptr=original_ptr∣tag
original_ptr = t a g g e d _ p t r & ∼ t a g _ b i t _ m a s k \text{original\_ptr} = tagged\_ptr \& \sim tag\_bit\_maskoriginal_ptr=tagged_ptr&∼tag_bit_mask
t a g = t a g g e d _ p t r & t a g _ b i t _ m a s k tag = tagged\_ptr \& tag\_bit\_masktag=tagged_ptr&tag_bit_mask
1. 指针打标记示例
usingTaggedPointer=tagged_pointer<T,2>;booltry_tag_untagged_pointer(atomic<TaggedPointer>&src){TaggedPointer current=src.load();assert(tag_value(current)==0);// 确认当前指针未打标记assert(1&tag_bit_mask<T,2>()==1);// 确认 1 是合法的标记位T*ptr=untag_pointer(current);// 提取原始指针TaggedPointer newval=tag_pointer(ptr,1);// 为指针打标记 1returnsrc.compare_exchange_weak(current,newval);// CAS 操作更新原子指针}理解:
TaggedPointer current = src.load();- 从原子变量
src中读取当前值(可能未打标记)。
- 从原子变量
tag_value(current) == 0- 断言当前指针的 tag 值为 0,即未打标记。
1 & tag_bit_mask<T,2>() == 1- 断言 tag 位
1是合法的低位可用空间,安全存储在指针低两位。
- 断言 tag 位
T* ptr = untag_pointer(current);- 提取原始指针,忽略低位标记。
TaggedPointer newval = tag_pointer(ptr, 1);- 将 tag 1 写入指针低位,生成新的带标记指针。
compare_exchange_weak(current, newval)- 使用 CAS(Compare-And-Swap)安全更新原子指针,避免数据竞争。
- 公式表示:
newval = original_ptr ; ∣ ; tag \text{newval} = \text{original\_ptr} ;|; \text{tag}newval=original_ptr;∣;tag
original_ptr = untag_pointer(newval) \text{original\_ptr} = \text{untag\_pointer(newval)}original_ptr=untag_pointer(newval)
tag = tag_value(newval) \text{tag} = \text{tag\_value(newval)}tag=tag_value(newval)
2. Hazard Pointer 扩展到 Tagged Pointer
C++26 标准原生的 Hazard Pointer API:
template<typenameT>T*protect(constatomic<T*>&src)noexcept;template<typenameT>booltry_protect(T*&ptr,constatomic<T*>&src)noexcept;- 保护原子指针,防止在多线程环境中被释放。
扩展到 tagged_pointer:
template<typenameT,size_t Alignment=alignof(T)>tagged_pointer<T,Alignment>protect(constatomic<tagged_pointer<T,Alignment>>&src)noexcept;template<typenameT,size_t Alignment=alignof(T)>booltry_protect(tagged_pointer<T,Alignment>&ptr,constatomic<tagged_pointer<T,Alignment>>&src)noexcept;- 功能与普通 Hazard Pointer 类似,但操作的是带标记指针。
- 可以安全保护指针及其 tag,保证多线程访问时不会出现悬空或未同步状态。
3. 使用示例
atomic<tagged_pointer<T>>src_;hazard_pointer hp=make_hazard_pointer();tagged_pointer<T>tagged=hp.protect(src_);/* 此时可以安全使用 ptr,其中 ptr == untag_pointer(tagged) */理解:
atomic<tagged_pointer<T>> src_;- 原子变量,存储带标记的指针。
hazard_pointer hp = make_hazard_pointer();- 创建一个 hazard pointer,用于保护指针。
tagged_pointer<T> tagged = hp.protect(src_);- 将原子变量中的指针和 tag 一起保护起来,防止在访问期间被删除或修改。
ptr == untag_pointer(tagged)- 可以安全地访问原始指针,忽略低位 tag,避免悬空访问。
总结
- Pointer Tagging:在指针低位存储额外信息(tag),节省内存。
- Hazard Pointer 扩展:可保护带标记指针,保证多线程安全访问。
- CAS 与 protect 操作确保了无锁并发安全。
- 公式关键点:
tagged_ptr = original_ptr ∣ tag \text{tagged\_ptr} = \text{original\_ptr} | \text{tag}tagged_ptr=original_ptr∣tag
original_ptr = untag_pointer(tagged_ptr) \text{original\_ptr} = \text{untag\_pointer(tagged\_ptr)}original_ptr=untag_pointer(tagged_ptr)
tag = tag_value(tagged_ptr) \text{tag} = \text{tag\_value(tagged\_ptr)}tag=tag_value(tagged_ptr)
1. 引入背景
- 目标:将并行性 (parallelism) 引入
std::ranges算法,使算法在多核/多线程环境下高效执行。 - 来源:基于 ISO C++ 的并行/并发编程语言扩展提案,以及 Gonzalo 的 ISC C++ BoF 讨论。
2. 历史演进
C++ 2017
- Parallel Algorithms:支持许多并行算法。
- Concurrency++:并发库增强,改进线程管理、同步机制。
- Memory Model++:改进内存模型,确保多线程程序的正确性(贡献者 MichaelW、Maged、Paul M)。
- Forward Progress:确保程序在多线程环境下不会无限阻塞。
C++ 2020
- Concepts:提供模板约束,使泛型编程更安全。
- Ranges:范围库,简化算法与容器的组合。
- Modules:模块化机制,提高编译速度与封装性。
- Concurrency++:继续扩展并发能力(贡献者 Bryce, Gonzalo)。
- Coroutines:协程,支持异步任务和延迟计算。
- atomic_ref, barriers, …:引入新的原子操作和同步屏障。
C++ 2023
- Ranges++:进一步丰富范围库功能。
- Multi-dimensional Spans:支持多维数据视图 (span)。
- operator[i, j, k]:提供多维索引访问的操作符,方便矩阵/张量操作。
C++ 2026(规划中)
- Executors / Sender / Receiver [P2300]:异步任务调度和执行模型。
- Reflection:提供运行时和编译时反射能力。
- Algorithms++:范围算法扩展,引入异步支持(贡献者 Ruslan, Alexey, Bryce)。
- Linear Algebra, submdspan, padded layouts:线性代数库扩展,多维子视图、带填充布局(贡献者 Bryce, Christian)。
- Concurrency++ RCU, HP:高级并发支持,包括 RCU(Read-Copy-Update)和 Hazard Pointer(HP)(贡献者 MichaelW, Gonzalo, Maged, Paul M)。
- SIMD:单指令多数据向量化操作(贡献者 Matthias, Daniel, Ruslan)。
3. 理解总结
- 发展趋势:
- 从 C++17 开始支持基本并行算法,逐步引入范围库、协程和多维数据结构。
- 到 C++2026,将实现异步范围算法、执行器、反射、高级并发与 SIMD。
- 目标:
- 提升算法在并行和多核环境下的性能。
- 简化多线程和异步编程模型。
- 支持高效线性代数计算和多维数据操作。
- 关键概念:
- Ranges:使算法可以直接作用于“范围”,简化迭代器操作。
- Executors / Sender / Receiver:提供统一的异步任务调度机制。
- Hazard Pointer (HP):安全管理并发访问的动态内存对象。
- RCU (Read-Copy-Update):允许多线程读写共享数据的高效策略。
- SIMD:通过单指令操作多数据,提高向量计算性能。
- 公式化理解:
- 假设一个范围算法
alg在序列seq上运行:
result = alg ( seq ) \text{result} = \text{alg}(\text{seq})result=alg(seq) - 引入并行执行策略后:
result = alg ( seq , execution_policy ) \text{result} = \text{alg}(\text{seq}, \text{execution\_policy})result=alg(seq,execution_policy) - 异步算法可表示为:
future<result> = alg.async ( seq ) \text{future<result>} = \text{alg.async}(\text{seq})future<result>=alg.async(seq) - 多维 span 支持多维索引访问:
value = s p a n [ i , j , k ] \text{value} = span[i,j,k]value=span[i,j,k]
1. 背景与发展
历史对比
- C++03(经典 STL 算法)
template<classRandomAccessIterator,classCompare>voidsort(RandomAccessIterator first,RandomAccessIterator last,Compare comp);- 基于迭代器的排序,只支持顺序执行。
- 算法接口简单,但无法直接控制并行执行。
- C++17(引入执行策略)
template<classExecutionPolicy,classRandomAccessIterator,classCompare>voidsort(ExecutionPolicy&&exec,RandomAccessIterator first,RandomAccessIterator last,Compare comp);- 支持执行策略(
ExecutionPolicy),可以选择并行(std::execution::par)或顺序(std::execution::seq)。 - 仍然基于迭代器,没有直接与范围(ranges)结合。
- C++20(范围库支持)
template<random_access_range R,classComp=ranges::less,classProj=identity>requiressortable<iterator_t<R>,Comp,Proj>constexprborrowed_iterator_t<R>ranges::sort(R&&r,Comp comp={},Proj proj={});- 引入ranges,用户可以直接操作范围对象而非迭代器。
- 算法接口更加表达式化,支持惰性组合(lazy composition)。
- 默认比较函数为
ranges::less,并支持投影(projection)函数Proj。
- C++26(未来提案 P3179)
template<classExecutionPolicy,random_access_range R,classComp=ranges::less,classProj=identity>requiressortable<iterator_t<R>,Comp,Proj>constexprborrowed_iterator_t<R>ranges::sort(ExecutionPolicy&&exec,R&&r,Comp comp={},Proj proj={});- 在范围算法中引入执行策略,统一并行与范围 API。
- 用户可以直接在范围上使用并行算法:
ranges::sort(exec_policy, my_range) \text{ranges::sort(exec\_policy, my\_range)}ranges::sort(exec_policy, my_range)
2. 为什么将并行性与范围结合?
- Ranges 提供生产力:
- Ranges API 易读、可组合、表达能力强。
- 并行算法普遍存在:
- 用户常常在范围上使用非范围并行算法,但接口不统一、易出错。
- 统一优势:
- 将执行策略直接整合到范围算法中,可简化代码、降低错误率。
3. 范围 + 并行的优势
- 表达能力强:
- Ranges 支持惰性计算(lazy evaluation),可组合多个操作。
- 性能优化机会:
- 并行范围算法可自动利用多核 CPU 或 GPU,提高性能。
- 自然语义:
- 避免将迭代器与执行策略分开管理,API 更直观。
- 易于迁移:
- 返回类型与序列范围算法一致,旧代码迁移简单。
4. 并行范围算法设计要点
- 执行策略参数
- 所有范围算法可接受
ExecutionPolicy,如:
std::execution::seq , std::execution::par , std::execution::par_unseq \text{std::execution::seq}, \text{std::execution::par}, \text{std::execution::par\_unseq}std::execution::seq,std::execution::par,std::execution::par_unseq
- 所有范围算法可接受
- 随机访问范围
- 并行化算法要求输入范围是随机访问的:
R ∈ random_access_range R \in \text{random\_access\_range}R∈random_access_range
- 并行化算法要求输入范围是随机访问的:
- 保证可以高效地分块和分配任务。
- 边界范围 (Bounded Range)
- 至少一个输入和输出范围必须是有界的(bounded),保证安全和性能。
- 一致的返回类型
- 保持与序列范围算法一致,便于单步替换:
borrowed_iterator_t<R> \text{borrowed\_iterator\_t<R>}borrowed_iterator_t<R>
- 保持与序列范围算法一致,便于单步替换:
- 多操作单次调用融合
- 支持将多个操作组合成单次并行调用,减少调度开销。
- 保持表达力
- 保留 ranges API 的可组合性和易读性。
5. 总结理解
- C++17:并行算法 + 迭代器接口。
- C++20:范围算法,增强表达力。
- C++26:并行范围算法,将执行策略直接整合到范围算法中,实现统一、高效、易读的并行计算 API。
- 用户可以写出:
ranges::sort(std::execution::par, my_vector) \text{ranges::sort(std::execution::par, my\_vector)}ranges::sort(std::execution::par, my_vector) - 同时保持与非并行范围算法的兼容性,并简化多操作组合与优化。
1. 关键设计决策(Key Design Decisions)
- 返回类型与序列范围算法一致
- 并行
ranges::for_each返回类型为:
ranges::borrowed_iterator_t<R> \text{ranges::borrowed\_iterator\_t<R>}ranges::borrowed_iterator_t<R> - 这样可以保证与顺序版本兼容,便于旧代码迁移和替换。
- 并行
- 要求输入范围为随机访问范围 (random_access_range)
- 目前为了高效并行化,算法要求R ∈ random_access_range R \in \text{random\_access\_range}R∈random_access_range。
- 随机访问保证可以快速分块、分配任务,降低线程间同步开销。
- 输出范围为输入参数
- 算法不仅遍历输入范围,还可以直接在输出范围上操作(in-place 或覆盖)。
- 要求边界范围 (bounded ranges)
- 至少一个输入和输出范围必须是有界的(sized/bounded),保证安全性和高效性:
sized_sentinel_for<sentinel_t<R>, iterator_t<R>> \text{sized\_sentinel\_for<sentinel\_t<R>, iterator\_t<R>>}sized_sentinel_for<sentinel_t<R>, iterator_t<R>> - 便于计算块大小和任务划分。
- 至少一个输入和输出范围必须是有界的(sized/bounded),保证安全性和高效性:
- 保持 C++17 并行算法可调用要求
- 保持 callable 函数对象(functor)满足
indirectly_unary_invocable要求:
indirectly_unary_invocable<projected<iterator_t<R>, Proj>, Fun> \text{indirectly\_unary\_invocable<projected<iterator\_t<R>, Proj>, Fun>}indirectly_unary_invocable<projected<iterator_t<R>, Proj>, Fun> - 保证算法在并行执行时函数对象仍然可调用且安全。
- 保持 callable 函数对象(functor)满足
2. 并行ranges::for_each模板接口解释
template<classExecutionPolicy,random_access_range R,classProj=identity,indirectly_unary_invocable<projected<iterator_t<R>,Proj>>Fun>requiressized_sentinel_for<ranges::sentinel_t<R>,ranges::iterator_t<R>>ranges::borrowed_iterator_t<R>ranges::for_each(ExecutionPolicy&&policy,R&&r,Fun f,Proj proj={});逐行理解
- 模板参数
ExecutionPolicy:执行策略,可选顺序或并行,例如:
std::execution::seq , std::execution::par , std::execution::par_unseq \text{std::execution::seq}, \text{std::execution::par}, \text{std::execution::par\_unseq}std::execution::seq,std::execution::par,std::execution::par_unseqrandom_access_range R:输入范围,必须是随机访问范围。Proj = identity:投影函数,可将元素映射到某个属性。Fun:函数对象,必须满足:
indirectly_unary_invocable<projected<iterator_t<R>, Proj>, Fun> \text{indirectly\_unary\_invocable<projected<iterator\_t<R>, Proj>, Fun>}indirectly_unary_invocable<projected<iterator_t<R>, Proj>, Fun>
- 约束条件 (requires)
- 输入范围必须是有界的(sized/bounded):
sized_sentinel_for<ranges::sentinel_t<R>, ranges::iterator_t<R>> \text{sized\_sentinel\_for<ranges::sentinel\_t<R>, ranges::iterator\_t<R>>}sized_sentinel_for<ranges::sentinel_t<R>, ranges::iterator_t<R>>
- 输入范围必须是有界的(sized/bounded):
- 返回类型
- 与序列范围算法一致,返回借用迭代器:
ranges::borrowed_iterator_t<R> \text{ranges::borrowed\_iterator\_t<R>}ranges::borrowed_iterator_t<R>
- 与序列范围算法一致,返回借用迭代器:
- 函数参数
policy:执行策略。r:输入范围。f:函数对象,对每个元素执行操作。proj:投影函数(默认identity)。
3. 总结理解
- C++26 并行范围算法将执行策略与范围 API统一。
- 输入范围必须是随机访问 + 有界,以保证高效并行化。
- 函数对象必须可调用(indirectly_unary_invocable),保持并行安全。
- 返回类型保持与序列范围算法一致,便于迁移和组合操作。
- 投影函数允许用户在遍历时提取元素属性,提高灵活性。
1. 与 C++17 并行算法的关键差异(Key Differences)
- 输入范围要求随机访问(random access ranges)
- C++17 并行算法可以接受更广泛的迭代器类型,但 C++26 并行范围算法要求:
R ∈ random_access_range R \in \text{random\_access\_range}R∈random_access_range - 这样保证算法能够高效地进行分块并行计算,减少线程间同步开销。
- C++17 并行算法可以接受更广泛的迭代器类型,但 C++26 并行范围算法要求:
- 输出可以是一个范围而不仅仅是迭代器
- 在 C++17 中,大多数并行算法只返回一个迭代器,表示处理完毕的位置。
- C++26 并行范围算法允许直接传入输出范围,返回一个范围或借用迭代器:
return type = ranges::borrowed_iterator_t<R> 或输出范围 \text{return type} = \text{ranges::borrowed\_iterator\_t<R>} \text{ 或输出范围}return type=ranges::borrowed_iterator_t<R>或输出范围 - 这简化了结果的处理,减少了手动迭代和赋值操作。
2. 优势(Benefits)
- 自然高效的范围并行化
- 直接在范围上操作,结合执行策略(
ExecutionPolicy)即可并行化:
for_each(exec_policy, my_range, func) \text{for\_each(exec\_policy, my\_range, func)}for_each(exec_policy, my_range, func) - 避免了 C++17 中手动从迭代器获取子区间并分发任务的繁琐操作。
- 直接在范围上操作,结合执行策略(
- 无缝集成 Ranges 库与现有并行算法
- 保持范围的惰性组合能力,同时继承 C++17 并行算法的执行策略。
- 用户可以轻松地将串行范围算法迁移到并行版本,无需重写大量代码。
- 代码更具表达力
- 使用范围直接表示输入/输出集合,减少模板嵌套与迭代器类型声明。
- 可读性和可维护性更高:
auto result = ranges::sort(exec_policy, my_range); \text{auto result = ranges::sort(exec\_policy, my\_range);}auto result = ranges::sort(exec_policy, my_range);
- 潜在更高性能
- 随机访问 + 边界范围 + 执行策略组合,允许编译器和运行时进行高效分块和负载均衡。
- 更安全的 API
- 要求有界范围(bounded ranges)保证在并行执行中不会越界或访问无效内存。
- 输出范围避免了返回裸迭代器可能带来的悬空问题。
- 简化串行到并行迁移
- 串行范围算法与并行范围算法接口保持一致,只需添加执行策略:
ranges::for_each(my_range, func) → ranges::for_each(exec_policy, my_range, func) \text{ranges::for\_each(my\_range, func)} \to \text{ranges::for\_each(exec\_policy, my\_range, func)}ranges::for_each(my_range, func)→ranges::for_each(exec_policy, my_range, func) - 降低迁移复杂度,同时获得并行性能。
总结:
C++26 并行范围算法通过随机访问范围 + 输出范围 + 执行策略的组合,提供了更自然、更安全、更高效的并行计算方式,弥合了范围表达能力与并行执行能力的差距。
- 串行范围算法与并行范围算法接口保持一致,只需添加执行策略:
1. 背景与动机(Overview & Motivation)
- C++ 并行算法的发展演化
- C++11/14 引入了基础的并行算法支持(
std::execution、std::for_each等)。 - C++17 引入了并行执行策略(
std::execution::par、seq),但无法指定执行硬件。 - C++26 借助 P2300 引入了可调度的执行器(scheduler),实现 “在哪里执行” 的灵活控制。
- C++11/14 引入了基础的并行算法支持(
- 为什么需要 P2300
- 现有执行策略只回答 “如何执行”(how),无法回答 “在哪里执行”(where)。
- P2300 提供scheduler抽象,表示硬件执行上下文,例如 CPU 核心、GPU、异构设备等。
- 将并行算法与 scheduler 集成,可以更有效利用硬件能力,提高性能和可扩展性。
2. P2500 的设计概览(Design Overview)
- 策略感知调度器(policy-aware scheduler)
- 结合了执行策略(execution policy)与调度器(scheduler):
policy-aware scheduler = ( scheduler, execution policy ) \text{policy-aware scheduler} = (\text{scheduler, execution policy})policy-aware scheduler=(scheduler, execution policy) - 允许用户通过
execute_on创建策略感知调度器:
auto sched = s t d : : e x e c u t e o n ( m y s c h e d u l e r , s t d : : e x e c u t i o n : : p a r ) ; \text{auto sched} = std::execute_on(my_scheduler, std::execution::par);auto sched=std::executeon(myscheduler,std::execution::par);
- 结合了执行策略(execution policy)与调度器(scheduler):
- 设计目标(Design Goals)
- 扩展 C++ 并行算法以支持策略感知调度器。
- 保持算法和策略的核心语义不变。
- 支持传统算法与基于范围的算法(range-based algorithms)。
- 最小化 API 改动,保持向后兼容。
- 允许根据硬件能力自定义算法实现,实现性能优化。
- 关键特性(Key Features)
- 调度器与策略组合(scheduler + execution policy)。
- 可扩展 API:允许为特定调度器自定义并行算法。
- 保持阻塞行为与 C++17 并行算法一致。
3. 核心概念(Key Concepts)
- policy_aware_scheduler 概念
template<typenameS>conceptpolicy_aware_scheduler=scheduler<S>&&requires(S s){typenameS::base_scheduler_type;typenameS::policy_type;{s.get_policy()}->execution_policy;};- 调度器必须提供
base_scheduler_type和policy_type。 get_policy()返回关联的执行策略。
- 调度器必须提供
- execute_on 函数
inlineconstexpr__detail::__execute_on_fn execute_on;- 将调度器与执行策略绑定,生成策略感知调度器:
policy_aware_sched = std::execute_on(my_scheduler, std::execution::par) \text{policy\_aware\_sched} = \text{std::execute\_on(my\_scheduler, std::execution::par)}policy_aware_sched=std::execute_on(my_scheduler, std::execution::par)
- 将调度器与执行策略绑定,生成策略感知调度器:
- 并行算法自定义化
- 并行算法定义为可自定义函数(customizable function),可以针对特定调度器实现优化版本(例如 CUDA 优化):
namespacecuda{structscheduler{friendconstexprautotag_invoke(std::tag_t<ranges::for_each>,scheduler,/*...*/){cuda_kernel<<<blocks,threads>>>(/*...*/);returnstd::ranges::for_each_result{/*...*/};}};}
- 并行算法定义为可自定义函数(customizable function),可以针对特定调度器实现优化版本(例如 CUDA 优化):
4. 使用示例(Usage Example)
std::for_each(std::execute_on(my_gpu_scheduler,std::execution::par),begin(data),end(data),[](auto&item){item.process();});execute_on生成策略感知调度器,for_each在指定硬件上并行执行。- 保留了 C++17 并行算法的阻塞语义,同时支持硬件定制化。
5. API 对比
- 现有 C++17 API
template<classExecutionPolicy,classIt,classFun>constexprvoidfor_each(ExecutionPolicy&&policy,It first,It last,Fun f);- 新策略 API(基于 execution_policy)
template<execution_policy Policy,input_iterator I,sentinel_for<I>S,classProj=identity,indirectly_unary_invocable<projected<I,Proj>>Fun>constexprranges::for_each_result<I,Fun>ranges::for_each(Policy&&policy,I first,S last,Fun f,Proj proj={});- 调度器 API(policy-aware scheduler)
template<policy_aware_scheduler Scheduler,input_iterator I,sentinel_for<I>S,classProj=identity,indirectly_unary_invocable<projected<I,Proj>>Fun>constexprranges::for_each_result<I,Fun>ranges::for_each(Scheduler sched,I first,S last,Fun f,Proj proj={})/*customizable*/;- 支持针对不同硬件和执行上下文自定义算法实现。
6. 总结(Summary)
- P2300:提供了灵活的硬件执行上下文抽象(scheduler),回答 “代码在哪里执行”。
- P2500:将 scheduler 集成到 C++ 并行算法中,实现 “策略感知并行算法”,回答 “如何执行 + 在哪里执行”。
- 未来方向:可定制、可扩展的并行算法,将进一步提升 C++ 并行编程的性能、表达力和安全性。