std::queue不能用于多线程无锁场景,因其操作非原子且内部状态竞争无法用atomic指针简单解决;必须基于CAS、内存序和安全内存回收机制构建无锁队列。

为什么 std::queue 不能直接用于多线程无锁场景
std::queue 本身不提供任何线程安全保证,它的所有操作(push、pop、front)都不是原子的。哪怕只用 std::atomic 包裹底层容器指针,也无法解决内部状态竞争——比如 pop 涉及“读取头节点 + 移动指针 + 释放内存”三步,中间任意一步被抢占都会导致 ABA 或悬垂指针。
常见错误现象包括:
- 程序偶发崩溃在
front()返回空指针或已释放内存 -
empty()返回true但紧接着pop()却成功(说明判空和取值之间状态已变) - 使用
std::shared_ptr管理节点仍出现双重释放(引用计数未覆盖所有路径)
这不是加个 std::mutex 就能绕开的问题;无锁本质是放弃阻塞等待,靠原子操作+内存序协同推进。
std::atomic + compare_exchange_weak 是唯一可行起点
所有实用的无锁队列(如 Michael-Scott 队列)都基于 CAS(Compare-And-Swap)构建。C++11 起必须用 std::atomic<Node*> 管理节点指针,且每次修改前必须用 compare_exchange_weak 循环重试,不能依赖单次 load/store。
立即学习“C++免费学习笔记(深入)”;
关键细节:
-
compare_exchange_weak可能伪失败(spurious failure),必须写在do-while或while循环里 - 读取头/尾指针时要用
memory_order_acquire,写入时用memory_order_release,CAS 本身默认是seq_cst,但可显式降级为acq_rel提升性能 - 不要试图对整块数据(如
std::atomic<T>)做 CAS——除非T是 trivially copyable 且大小 ≤ 指针宽(通常仅限int、void*等)
示例片段(简化版入队):
Node* new_node = new Node(data);
Node* tail;
do {
tail = tail_.load(std::memory_order_acquire);
} while (!tail_->compare_exchange_weak(tail, new_node, std::memory_order_acq_rel));
ABA 问题不是理论风险,而是必现 Bug
当一个节点被出队(A→B)、回收、再入队(B→A),而另一个线程还在用旧的 A 地址做 CAS 判断时,会误认为“指针没变”,跳过应做的同步逻辑。这是无锁队列 crash 的最常见根源。
解决方案只有两个:
- 使用带版本号的指针(如
std::atomic<uintptr_t>存(ptr << 8) | version,需手动拆包,注意对齐) - 改用 hazard pointer 或 epoch-based reclamation(EBR),避免立即
delete节点
不要相信“我队列很短,ABA 几乎不会发生”——在高并发下,几百万次操作中一次 ABA 就足以让服务进程崩掉。
别自己从零手撸,先看 moodycamel::ConcurrentQueue
它经过大量生产环境验证,支持批量操作、内存池、多种内存序策略,且明确标注了哪些函数是 lock-free(如 enqueue / try_dequeue),哪些只是 wait-free(如 size() 是近似值)。
你真正该花时间的地方是:
- 理解它的
ProducerToken/ConsumerToken机制(避免每调用一次都查 TLS) - 确认你的 T 类型满足 trivially destructible(否则析构逻辑需额外同步)
- 测试它在你的 CPU 架构(尤其是 ARM)下的表现——x86 的强内存模型会掩盖部分序错误
自己实现的最小可用无锁队列,代码量往往超过 500 行,且调试周期远超集成一个成熟库。除非你在写内核模块或嵌入式 runtime,否则没必要碰裸原子指令。
真正的难点不在写对第一个 CAS,而在于所有路径上的内存序一致性、回收时机控制、以及测试时如何稳定复现竞态——这些几乎无法靠单机单元测试覆盖。



















