直接用 std::atomic 操作节点指针会出错,因其仅保证指针值原子性,不防护所指对象的构造/销毁安全、字段访问竞态及 aba 问题;需配合内存序、避免悬空指针、分步构造与 cas,并借助 hazard pointer 等机制确保安全回收。

为什么直接用 std::atomic<t></t> 操作节点指针会出错
因为 std::atomic<t></t> 只保证指针本身的读写原子性,不保证它所指向的内存结构(比如 Node)在多线程中被安全构造、销毁或字段访问。更关键的是:CAS 操作需要比较并交换整个指针值,但若节点被释放后地址复用,就可能引发 ABA 问题——而裸 std::atomic<node></node> 完全不提供任何防护。
实操建议:
- 绝不要对已
delete的节点指针做 CAS 比较,哪怕它看起来“没被改过” - 必须配合内存序(如
memory_order_acquire/memory_order_release)约束重排,否则生产者写的字段消费者可能看不到 - 用
std::atomic<node></node>时,load()默认是memory_order_seq_cst,性能高但过度严格;压测时可显式降级为memory_order_acquire - 别把
new Node和store()合并在一行——构造失败会导致指针未初始化却参与 CAS,触发未定义行为
如何正确实现 push() 中的双 CAS 循环
无锁队列的 push() 必须确保新节点被安全链接到 tail 后,并且 tail 指针准确推进。这不能靠单次 CAS 完成,因为 tail 可能在你读取后被其他线程更新,导致“写到旧 tail 后面”然后覆盖 tail 指针。
实操建议:
- 先
tail.load(memory_order_acquire)获取当前尾节点 - 再
tail->next.load(memory_order_acquire)检查是否为空;若非空,说明 tail 已滞后,需用compare_exchange_weak尝试更新 tail 到tail->next,循环重试 - 只有确认
tail->next为空,才用compare_exchange_weak(&tail->next, nullptr, new_node)链接新节点 - 链接成功后,再用另一个 CAS 更新
tail:不是直接赋值,而是compare_exchange_weak(&m_tail, tail, new_node),防止多个线程同时推进 tail 导致跳过节点 - 所有 CAS 失败都应继续循环,而非 break 或 return ——这是无锁算法“忙等”的必然代价
pop() 里为什么要用 dummy node + 两阶段 CAS
直接 CAS head 并取走数据会留下悬空指针风险:若线程 A 读到 head,B 立即 pop 并 delete 了该节点,A 后续访问 head->data 就是野指针。标准解法是引入 dummy node(哨兵),把实际数据节点的释放延迟到 next 指针被安全观测之后。
实操建议:
- 首次 CAS:用
compare_exchange_weak(&m_head, old_head, old_head->next)把 head 推进到下一个节点(此时 old_head 是待取走的节点) - 第二次 CAS 不操作指针,而是读取
old_head->next的值——若为非空,说明它已被后续线程推进过,可以安全delete old_head;若仍为空,说明它是逻辑尾节点,需保留供后续 pop 复用 - dummy node 必须在构造队列时就
new出来并设为 head/tail 初始值,且永不 delete - pop 返回值不能是指向栈上对象的引用,必须是移动语义的
T或std::optional<t></t>,避免生命周期错位
内存回收:为什么 hazard pointer 比 RCU 更适合 C++ 用户
无锁结构最棘手的不是同步逻辑,而是何时安全 delete 节点。引用计数在高竞争下开销大,RCU 在 C++ 标准库中无原生支持且需内核态配合,hazard pointer 是目前最平衡的选择——它用线程局部的指针数组标记“我正在访问哪些节点”,其他线程删除前先检查这些数组。
实操建议:
- 每个工作线程持有一个
hazard_pointer对象,调用protect()传入待访问的Node*,函数内部将其存入线程局部数组 - 全局维护一个待回收链表,每次
retire(node)时把 node 加入其中;定期(如每 100 次 pop 后)扫描所有线程的 hazard pointer 数组,把未被任何数组引用的 node 真正delete - 别试图自己实现 hazard pointer 的 lock-free 扫描逻辑——用
std::mutex保护回收链表即可,瓶颈不在这里 - 如果业务场景允许少量内存泄漏(如进程生命周期内只创建不销毁队列),可暂时跳过回收逻辑,但必须注释清楚:这不是“无锁”的缺陷,而是工程取舍
真正难的从来不是写出能编译的 CAS 循环,而是让每个 delete 都落在所有线程彻底放弃访问它的那个精确时刻。这个时刻无法靠时间判断,只能靠跨线程的可见性契约——而 hazard pointer 就是目前 C++ 里最贴近这个契约的实用工具。
C++免费学习笔记(深入):立即使用
在学习笔记中,你将探索 C++ 的入门与实战技巧!











