c++如何实现一个无锁(lock-free)栈? (ABA问题详解)

直接用 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 位),高位存指针,低位存计数。每次修改栈顶时计数器自增,确保即使地址重复,组合值也不同。

常见做法是用 uintptr_t 的低 16 位作计数器(支持 65536 次重用),剩余高位存指针——前提是系统指针地址天然对齐(如 x86_64 下指针最低 3 位恒为 0,实际可用更多位)。

  • push() 时:读当前 head → 构造新节点 → 用 atomic_load 获取当前组合值 → 提取旧指针和计数 → 新组合 = (new_node_ptr
  • pop() 时:同样拆解组合值,CAS 比较整个 uintptr_t,失败则重试
  • 注意:必须保证节点分配器(如 new)不立即复用刚 delete 的内存;否则计数器没来得及增长,ABA 就重现

更稳妥的做法:用 hazard pointerRCU 配合引用计数

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_acquirememory_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——它只保证原子性,不约束前后普通内存访问顺序,极易引发竞态。

真正难的从来不是写个能跑的无锁栈,而是让每个 loadstoreCAS 的内存序都精准匹配硬件模型和算法逻辑。漏掉一个 acquire,就可能在某台 NUMA 机器上稳定复现崩溃。