直接用 std::atomic 实现无锁栈会因 ABA 问题导致链表破坏、访问释放内存或崩溃;需用指针+版本号打包、hazard pointer 或 RCU 等方案解决,并严格配对 memory_order_acquire/release。

为什么直接用 std::atomic 实现栈会出问题
无锁栈的核心是用 compare_exchange_weak 原子地更新栈顶指针,但仅靠它无法防止 ABA 问题:某个节点 A 被弹出(变为闲置),又被新节点复用(地址相同),此时另一个线程还在尝试用旧的「A→B」快照做 CAS,会误认为状态未变而成功——结果链表被破坏。
典型现象是 pop() 返回错误节点、top() 访问已释放内存、程序崩溃或静默数据错乱。
关键不是“能不能编译”,而是“多线程高并发下行为是否可预测”。即使测试跑一万次不崩,也可能在生产环境每小时触发一次 ABA。
用 std::atomic + ABA 计数器绕过地址复用歧义
把指针和一个单调递增的版本号打包进一个足够宽的整数(如 64 位),高位存指针,低位存计数。每次修改栈顶时计数器自增,确保即使地址重复,组合值也不同。
立即学习“C++免费学习笔记(深入)”;
常见做法是用 uintptr_t 的低 16 位作计数器(支持 65536 次重用),剩余高位存指针——前提是系统指针地址天然对齐(如 x86_64 下指针最低 3 位恒为 0,实际可用更多位)。
-
push()时:读当前head→ 构造新节点 → 用atomic_load获取当前组合值 → 提取旧指针和计数 → 新组合 =(new_node_ptr -
pop()时:同样拆解组合值,CAS 比较整个uintptr_t,失败则重试 - 注意:必须保证节点分配器(如
new)不立即复用刚delete的内存;否则计数器没来得及增长,ABA 就重现
更稳妥的做法:用 hazard pointer 或 RCU 配合引用计数
ABA 的本质是内存回收时机失控。与其在指针上硬加版本号,不如显式管理节点生命周期:
- 每个线程维护自己的 hazard pointer,指向当前正在访问的节点(如 pop 中读到的
next) -
pop()流程变成:读head→ 写入 hazard pointer → 再次确认head未变 → CAS 更新 → 若成功,将旧头节点加入待回收队列 - 回收器定期扫描所有线程的 hazard pointer,只释放那些「不在任何 hazard pointer 中,且无其他引用」的节点
- 这比纯计数器方案稍重,但彻底消除 ABA,且兼容任意内存分配策略
标准库不提供 hazard pointer,需手写或用 libcds 等第三方库。C++20 的 std::atomic 也不能直接用于无锁栈——因为 shared_ptr 的控制块修改本身不是无锁的。
别忘了内存序:用 memory_order_acquire 和 memory_order_release 控制可见性
即使解决了 ABA,错误的内存序仍会导致乱序读写。例如:
Node* old_head = head.load(std::memory_order_acquire); // 如果这里不加 acquire,编译器/CPU 可能把后续对 old_head->next 的读取提前到 load 之前 Node* new_head = old_head->next; // CAS 必须用 release,确保 new_head 的写入对其他线程可见 head.compare_exchange_weak(old_head, new_head, std::memory_order_acq_rel);
常见错误是全用 memory_order_relaxed——它只保证原子性,不约束前后普通内存访问顺序,极易引发竞态。
真正难的从来不是写个能跑的无锁栈,而是让每个 load、store、CAS 的内存序都精准匹配硬件模型和算法逻辑。漏掉一个 acquire,就可能在某台 NUMA 机器上稳定复现崩溃。











