单纯分离指针和版本号无法防止ABA问题,因两次原子读非原子组合;必须用单个无锁原子结构体(如TaggedPtr)打包二者,并校验is_lock_free()。

单纯给指针加个 std::atomic<uint64_t></uint64_t> 版本号,根本防不住 ABA 问题——因为读指针和读版本号不是原子的,中间有竞态窗口。
为什么分开维护指针和版本号会失效
常见错误是定义两个独立的原子变量:std::atomic<node></node> 和 std::atomic<uint64_t></uint64_t>。线程 A 在 load() 指针后、load() 版本号前,可能被调度暂停;此时线程 B 已完成“弹出 → 释放 → 复用地址 → 入栈”,指针值回退到原值,版本号却已+2。线程 A 接着读到旧版本号,误判为“未变”,CAS 成功,use-after-free 就发生了。
关键点在于:compare_exchange_weak 只能保证单个 std::atomic 的读-改-写原子性,无法跨变量同步。
- 不能靠两次
load()拼凑“一致快照” - 也不能靠顺序调用
load()+compare_exchange_weak()来模拟——中间状态不可控 - 即使加内存序(如
memory_order_acquire),也无法消除两个变量间的时间差
必须用单个 lock-free 的复合结构体
正确做法是把指针和版本号打包进一个结构体,再用 std::atomic 包裹它,并确保该类型支持无锁原子操作。否则 fallback 到内部互斥锁,就不是无锁了,还可能死锁。
立即学习“C++免费学习笔记(深入)”;
示例结构体(x86-64 安全):
struct TaggedPtr {
uintptr_t ptr : 48;
uint64_t version : 16;
};
注意三点:
- 先检查
std::atomic<taggedptr>::is_lock_free()</taggedptr>,返回false就别硬上 - 位域顺序很重要:指针放低位、版本放高位,适配
cmpxchg16b对齐习惯 - 版本字段至少用
uint64_t,避免回绕(uint32_t在高并发下几秒就溢出)
std::atomic 的典型使用陷阱
很多人写完结构体就直接用,结果在 GCC 11 或旧版 Clang 上 silent fallback 到锁实现——因为 16 字节结构体在某些 ABI 下不满足无锁条件。这不是编译错误,而是运行时性能断崖。
安全写法必须带校验:
static_assert(sizeof(TaggedPtr) == 16, "TaggedPtr must be 16 bytes"); static_assert(std::atomic<TaggedPtr>::is_lock_free(), "TaggedPtr atomic must be lock-free");
若校验失败,说明平台不支持,得换方案——比如转向 hazard pointer 或 RCU,而不是强行压测。
- 别依赖
std::atomic<:pair uint64_t>></:pair>:标准库不保证 pair 原子性,且通常不 lock-free - 别手动拼
uint128_t:x86-64 虽支持cmpxchg16b,但需对齐到 16 字节边界,且部分 OS 内核禁用该指令 - 版本号递增必须在 CAS 失败重试路径里完成,否则多线程下可能漏增
真正难的是内存回收,不是版本号本身
版本号机制只解决“怎么识别 A 是不是原来的 A”,但不解决“B 被释放后内存被复用”这个根源问题。如果节点释放后立刻被新对象占用,哪怕版本号对得上,字段语义也早已错乱。
所以工业级方案往往组合使用:
- 短生命周期对象:用
hazard pointer延迟回收,让正在访问的线程能标记“我还在用这个地址” - 长读多写的场景:用
RCU,读侧零开销,写侧承担复制成本 - 简单服务模块:直接上
std::shared_ptr+ 引用计数,放弃极致性能换正确性
版本号只是拼图一角,脱离内存管理谈 ABA,就像修刹车只换卡钳不碰油路。


















