不能直接用 std::deque 做无锁双端队列,因其所有操作非原子,存在内存重排、aba 问题和边界竞争;可行方案是基于固定大小环形缓冲区,用两个 std::atomic 索引配合内存序与 careful 边界判断。

为什么不能直接用 std::deque 做无锁双端队列
因为 std::deque 的所有操作(包括 push_front、pop_back)都不是原子的,内部有多个指针/缓冲区管理逻辑,无法保证多线程并发读写时的一致性。哪怕只加一个 std::atomic 修饰其指针,也无法规避内存重排、ABA 问题和边界状态竞争——比如两个线程同时 push_front 可能导致缓冲区元数据错乱或内存泄漏。
真正可行的无锁双端 FIFO 必须基于固定大小的环形缓冲区(circular buffer),且两端使用独立的原子索引(std::atomic<size_t></size_t>),配合内存序约束和 careful 的边界判断。
- 必须预分配固定容量,避免运行时内存分配带来的同步开销和不确定性
- 头尾索引不能直接用
++或+=,需用fetch_add并检查 wrap-around - 读写端必须用
memory_order_acquire/memory_order_release配对,防止指令重排破坏可见性 - 空/满判定不能仅靠
head == tail(会歧义),得预留一个空槽,或额外用std::atomic<bool></bool>标记满状态
std::atomic 索引 + 模运算实现 push/pop 的关键细节
核心是把环形缓冲区建模为数组 + 两个原子索引:head_(消费者读位置)、tail_(生产者写位置)。每次 push/pop 都先 fetch_add 获取“申请到的位置”,再检查是否越界或冲突。
示例片段(简化版,省略内存序和 full/empty 判定):
template<typename t size_t n>
class LockFreeDeque {
alignas(64) std::array<t n> buf_;
alignas(64) std::atomic<size_t> head_{0};
alignas(64) std::atomic<size_t> tail_{0};
<pre class="brush:php;toolbar:false;">size_t mask() const noexcept { return N - 1; } // 要求 N 是 2 的幂
bool try_push_back(const T& val) {
size_t pos = tail_.fetch_add(1, std::memory_order_relaxed) & mask();
if ((pos + 1) & mask() == head_.load(std::memory_order_acquire)) {
// 满了,回退
tail_.fetch_sub(1, std::memory_order_relaxed);
return false;
}
new (&buf_[pos]) T(val); // placement new
std::atomic_thread_fence(std::memory_order_release);
return true;
}
};
- 必须用
N为 2 的幂,才能用位与& mask()替代取模,避免分支和除法开销 -
fetch_add用relaxed是为了性能,但后续的满/空检查必须搭配acquireload,否则其他线程的写可能不可见 - 对象构造必须用 placement new,不能直接赋值(避免默认构造+赋值两步引发竞态)
- 写入后需要
atomic_thread_fence(release)或对某个哨兵变量 store-release,确保写入对消费者可见
如何安全处理对象生命周期与异常(尤其非 trivial 类型)
如果 T 有析构函数、拷贝构造可能抛异常,或含 std::unique_ptr 等资源句柄,无锁队列立刻变得危险:push 过程中若构造失败,已 fetch_add 的 tail_ 无法回滚,会导致后续读取未初始化内存。
- 最稳妥做法:只允许 trivially_copyable 且无抛出构造/析构的类型(如
int、std::pair<int></int>) - 若必须支持复杂类型,应在 push 前做“预占位”:先 fetch_add,再在栈上构造临时对象,成功后再 move 到缓冲区并 commit(用 compare_exchange 更新状态)
- pop 时不能直接调用析构函数——要等确认该元素已被消费完毕(即被另一个线程 pop 成功并离开临界路径),才可安全析构;通常需引入引用计数或 hazard pointer
- 更现实的选择:改用 lock-free 单端队列(如 Michael-Scott),或接受细粒度锁(如每个 slot 加 spinlock)换取语义安全
实际项目中更推荐的替代方案
手写正确、高性能、泛型安全的 lock-free deque 极易出错,LLVM、Folly、moodycamel 的 ConcurrentQueue 等成熟实现都只提供单生产单消费(SPSC)或单端无锁,双端无锁极少落地。
- 若场景是“生产者 push_back,消费者 pop_front”,直接用 moodycamel::BlockingConcurrentQueue(它底层是 lock-free ringbuffer,且做了大量缓存行对齐和 padding)
- 若真需双端(如任务优先级插队),可拆成两个单端队列:
high_priority_queue(lock-free SPSC) +normal_queue(带锁),由调度器统一 dispatch - Linux 上可考虑
io_uring提供的用户态 ringbuffer,配合IORING_OP_PROVIDE_BUFFERS实现零拷贝任务提交,比纯用户态 lock-free 更高效也更可靠
真正难的不是原子操作本身,而是把内存序、对象生命周期、缓存一致性、调试可观测性全串起来——漏掉任意一环,程序可能在 99.9% 的负载下正常,剩下 0.1% 出现静默数据损坏。
C++免费学习笔记(深入):立即使用
在学习笔记中,你将探索 C++ 的入门与实战技巧!











