直接用std::queue加原子操作不行,因队列内部内存管理(如new节点、改指针)非原子,头尾指针原子化无法保证整体状态一致,且易受ABA问题影响导致链表断裂。

为什么直接用 std::queue 加原子操作不行
很多人一上来就想把 std::queue 的 push 和 pop 用 std::atomic 包一层,结果很快遇到崩溃或数据错乱。根本原因在于:队列内部的内存管理(比如 new 节点、修改指针)不是原子的,即使头尾指针本身是 std::atomic,中间状态仍可能被其他线程看到不一致视图。更麻烦的是 ABA 问题——一个指针被释放后又被重用,CAS 操作误判为“没变过”,导致链表断裂。
Michael-Scott 算法是实际能落地的起点
工业级无锁队列基本都基于 Michael-Scott(MS queue)算法,它只用两个 std::atomic<node></node>:head 和 tail,每个节点带一个 next 指针。关键设计点在于:
-
head指向的是**虚拟头节点**(dummy node),真正数据从head->next开始,这样pop时不用处理空队列的特殊 CAS 失败路径 -
tail不一定实时更新,但每次enqueue前会先尝试用 CAS 把tail->next设为新节点,失败就说明有竞争,再用 CAS 推进tail指针 - 所有 CAS 都带「循环重试」逻辑,没有锁,也没有等待,但也不保证严格无等待(wait-free)——某个线程可能因反复失败而饿死
struct Node {
T data;
std::atomic<Node*> next{nullptr};
};
class LockFreeQueue {
std::atomic<Node*> head_;
std::atomic<Node*> tail_;
// 构造时 new 一个 dummy node,head_ 和 tail_ 都指向它
};
ABA 问题必须靠 std::atomic<uintptr_t> 或 Hazard Pointer
CAS 对指针做比较时,只看地址值。如果节点 A 被弹出、析构、内存被复用为新节点 A',CAS 就无法区分。常见解法有两种:
- 用
std::atomic<uintptr_t>存储「指针 + 版本号」,高位存版本(如 16 位),每次修改指针就递增版本,CAS 变成对整数的原子操作 - 更通用但稍重的方案是 Hazard Pointer:每个线程声明自己正在访问哪些指针(hazard pointers),回收内存前检查是否还有线程在引用
- 别用
std::shared_ptr—— 它的引用计数操作本身不是 lock-free,且析构时机不可控
如果你用的是 C++20,std::atomic<Node*> 在支持 DCAS(double-word CAS)的平台(如 x86-64)上可配合 __atomic_compare_exchange 手动实现版本化,但跨平台时建议直接上 Hazard Pointer 库(如 folly::HazardPtr)。
组合式C++代码评审方案,融合静态分析、AI推理、多轮迭代评审和C++专项检查,适用于PR审查、增量代码审查、全项目评审和代码质量评分,触发词包括review cpp、cpp代码评审、C++review、代码审查。
内存释放比入队出队更难搞
无锁结构最难的从来不是“怎么放进去/取出来”,而是“怎么安全地 delete”。因为一个节点可能正被其他线程通过 next 指针读取,你却在另一端把它 free 了。
- 不能在
pop成功后立刻delete节点 —— 此时别的线程可能刚读到它的next字段,还没来得及用 - 也不能依赖引用计数,理由同上
- 实践中几乎都要引入延迟回收机制:把待删节点挂到本地链表,等确认“全局已无人访问该地址”再批量释放(Hazard Pointer 或 RCU 是主流选择)
- 如果业务允许,用内存池(object pool)预分配节点并禁止
delete,可绕过大部分回收难题,但牺牲灵活性
写到这儿你会发现,一个“看起来简单”的无锁队列,真正要上线,大概率得抄成熟实现(如 moodycamel::ConcurrentQueue)或者用 liblfds,自己从零撸容易在内存生命周期上翻车。

















