环形缓冲区为什么是lock-free的?
环形缓冲区的 lock-free 原理是什么?
你的困惑非常典型,而且问到了点子上。
先直接回答:维基那段描述的"flag 方案"和"lock-free"是两回事,不要混为一谈。 维基那段说的是"如何判断 buffer 满还是空"的问题,不是 lock-free 本身的实现方案。
注意:文末有25个C++硬核项目推荐,可写简历。校招/社招简历都可以写。
一、先把问题拆开
环形缓冲区有两个独立的问题:
问题一:如何判断 buffer 是满还是空?
这是一个纯逻辑问题。当 head == tail 时,到底是满还是空?常见解法有三种:
- 浪费一个槽位(buffer 大小 N,实际只用 N-1 个槽)
- 额外维护一个
size计数器 - 维基说的 flag 标志位
这三种方案都是在回答"怎么区分满和空",跟 lock-free 没有直接关系。
问题二:多线程并发读写时,如何保证正确性?
这才是 lock-free 要解决的问题。
二、lock-free 的真正前提:SPSC
说"环形缓冲区是 lock-free 的",这个说法本身是有前提条件的,这个前提叫做:
SPSC — Single Producer Single Consumer(单生产者单消费者)
在 SPSC 场景下,环形缓冲区可以做到真正的 lock-free,而且不需要任何 CAS 原子操作,只需要普通的 std::atomic load/store 就够了。
为什么?核心洞察在这里:
Producer 只写 write_pos(写指针)
Consumer 只写 read_pos(读指针)写指针只被一个线程修改,读指针只被另一个线程修改。
这意味着:
- Producer 读
read_pos判断是否满 → 只读,不写 - Consumer 读
write_pos判断是否空 → 只读,不写
没有两个线程同时写同一个变量,自然不需要锁,也不需要 CAS。
三、具体实现
template<typename T, size_t N>
class SPSCQueue {
T buffer[N];
std::atomic<size_t> write_pos{0};
std::atomic<size_t> read_pos{0};
public:
// 生产者调用(只有一个线程调用)
bool push(const T& val) {
size_t cur_write = write_pos.load(std::memory_order_relaxed);
size_t next_write = (cur_write + 1) % N;
// 读 read_pos 判断是否满
if (next_write == read_pos.load(std::memory_order_acquire)) {
return false; // 满了
}
buffer[cur_write] = val;
// 发布写指针,让 Consumer 可见
write_pos.store(next_write, std::memory_order_release);
return true;
}
// 消费者调用(只有一个线程调用)
bool pop(T& val) {
size_t cur_read = read_pos.load(std::memory_order_relaxed);
// 读 write_pos 判断是否空
if (cur_read == write_pos.load(std::memory_order_acquire)) {
return false; // 空了
}
val = buffer[cur_read];
// 发布读指针,让 Producer 可见
read_pos.store((cur_read + 1) % N, std::memory_order_release);
return true;
}
};注意 memory order 的选择:
relaxed:读自己那侧的指针,不需要同步acquire:读对方的指针,需要看到对方之前的所有写操作release:更新自己的指针,发布自己之前的所有写操作
这里没有任何 mutex,没有任何 CAS,只有普通的原子 load/store。这就是 SPSC 环形队列 lock-free 的完整实现。
四、为什么维基那段说"需要同步"?
再回头看你引用的那段:
Read and write operations must share the flag, so it will probably require synchronization in multi-threaded situation.
维基说的 flag 方案问题在于:flag 需要被读写两侧共同访问和修改。
每次 push 之后要写 flag = "last was write",每次 pop 之后要写 flag = "last was read"。
这就意味着 Producer 和 Consumer 都要写同一个变量(flag),破坏了 SPSC 的关键前提——每个变量只被一个线程写。所以这个方案反而引入了竞争,需要同步。
这是一个典型的"为了解决一个问题,引入了更大的问题"的案例。
正确的区分满/空方法,在 SPSC 场景下,应该用"浪费一个槽位"的方案:buffer 大小 N,实际只存 N-1 个元素,(write+1)%N == read 表示满,write == read 表示空。简洁,无共享状态,天然 lock-free。
五、MPMC 的情况
如果是多生产者多消费者(MPMC),情况就复杂很多。
这时候 write_pos 会被多个 Producer 同时修改,read_pos 会被多个 Consumer 同时修改,不能用简单的 load/store,需要用 CAS(Compare-And-Swap):
// MPMC push 的核心逻辑(简化)
size_t cur = write_pos.load(std::memory_order_relaxed);
while (!write_pos.compare_exchange_weak(cur, cur + 1, ...)) {
// CAS 失败,重试
}这仍然是 lock-free 的(没有 mutex),但用了 CAS 原子操作,开销比 SPSC 大得多,而且实现复杂很多(需要处理槽位状态、ABA 问题等)。
六、总结一下
| 场景 | 实现方式 | 是否 lock-free |
|---|---|---|
| SPSC | atomic load/store + 内存序 | ✅ 是,且开销极低 |
| MPSC/SPMC | CAS | ✅ 是,开销较高 |
| MPMC | CAS + 槽位状态机 | ✅ 是,实现复杂 |
| 带 flag 判满方案 | 需要同步 flag | ❌ 不再 lock-free |
lock-free 的本质不是"不用任何同步原语",而是"不用互斥锁,系统整体总有线程在推进"。 SPSC 环形队列之所以优雅,是因为它利用了生产者和消费者天然的职责分离,让每个指针只被一个线程修改,从而连 CAS 都省掉了。
如果你对无锁数据结构感兴趣,想从理论到真正能跑的代码都搞通,可以关注我的两个项目课程:
SPSC LightningQueue(无锁队列单生产单消费版,100元)和 MPMC ThunderQueue(多生产多消费版,299元)——从内存模型、happens-before 语义讲起,到 cache line 对齐、false sharing 优化,再到完整实现和压测验证,每个设计决策都说清楚"为什么"。
这两个是我 25 个 C/C++ 硬核项目课程里的一部分。除了无锁队列,还有线程池、内存池、协程库、高性能网络库、LSM 存储引擎,以及最近刚完成的重头项目——
NginX0(miniNginx):参考 Nginx 1.26.2 源码,从空文件夹出发 29 天增量实现,覆盖内存池、epoll 事件循环、Phase Engine(11个阶段)、过滤器链、upstream 反代框架、Slab 共享内存 + 跨 Worker 限流、Rewrite 字节码虚拟机……核心代码 2.2 万行,单 Worker 静态文件 QPS 压测达到官方 Nginx 的 98%。这不是玩具项目,是真实可用的性能水平,也是能放进简历、在面试里展开讲的那种。
25 个项目累计带了 450+ 同学从 0 到 1 做完,覆盖校招应届生到工作多年的同学。
感兴趣加微信 jkfwdkf,备注「 项目实战 」查看完整目录,打包购买有优惠。
C++项目实战课程海报:

想系统补齐项目经验、丰富简历、提升面试可聊内容,可以点击下面这 篇查看完整介绍:
对C++项目实战课程感兴趣,欢迎微信搜索 jkfwdkf,备注「 项目实战 」,打包购买有优惠。
最近新开放的 C/C++ 项目实战课程(校招/社招简历都可以写):
一个能让面试官眼前一亮的C/C++项目:从零手写 NginX0 (Mini-Nginx),性能追平官方98%
XRPC:一个能写进简历的 C++ 高性能分布式 RPC 框架,QPS 13万+
2万行代码、36天、16万ops/s:我从零手写了一个Mini-Redis
VortexCache:一个能写进简历的 C++ 高性能分布式缓存服务器,多场景吞吐超越 memcached
16天手写 HTTP 服务器:从 Reactor 到 45万QPS 生产级实现
我把一个完整的C++ B/S 聊天室项目拆成了 16 天,每天增量实现,你跟着做一遍就彻底懂了