这篇文章整理几个无锁并发里经常一起出现的概念:CAS、busy polling、atomic ring buffer、release/acquire、store buffer 和 CPU cache warmup。它们看起来分散,其实是一条完整链路:硬件提供原子操作,程序用原子变量做同步,线程用轮询降低延迟,最后还要理解 cache 和内存模型带来的性能与可见性问题。
CAS:无锁编程的基本原子操作
CAS 是 Compare-And-Swap 的缩写,可以理解成一个硬件保证原子的「比较并交换」操作。
它有三个参数:
V:要修改的内存地址A:期望旧值B:准备写入的新值
伪代码如下:
1 | bool CAS(addr V, value A, value B) { |
关键点是:读旧值、比较、写新值这三个动作整体不可分割。如果多个线程同时执行,硬件保证同一时刻只有一个 CAS 能成功。
在 x86 上,CAS 通常对应 LOCK CMPXCHG 指令;在 ARM 上,常见实现是 LL/SC 语义,也就是 Load-Linked / Store-Conditional。
一个典型例子是无锁计数器:
1 | std::atomic<int> counter{0}; |
这就是乐观锁的思路:先假设没有冲突,直接尝试修改;如果失败,说明别人抢先改了,那就重新读取、重新计算、再次尝试。
ABA 问题
CAS 只关心「当前值是不是等于期望值」,但它不知道这个值中间有没有被改过。
比如:
1 | T1: 线程 1 读到值 A |
从线程 1 的视角看,值仍然是 A,CAS 可以成功;但实际上这个位置已经经历过 A -> B -> A 的变化。这就是 ABA 问题。
常见解决方式是加版本号,把比较对象从单个值变成二元组:
1 | (value, version) |
每次修改时版本号递增。这样即使值又变回 A,版本号也已经不同,CAS 就能发现中间发生过变化。Java 里的 AtomicStampedReference 就是这个思路。
Busy Polling:用 CPU 换延迟
Busy polling,也叫忙轮询,指线程不睡眠、不阻塞,而是在一个循环里不断检查条件是否满足。
1 | while (!condition_ready()) { |
它和阻塞等待的差异很直接:
| 等待方式 | 行为 | CPU 消耗 | 延迟 |
|---|---|---|---|
| Blocking | 睡眠,让出 CPU | 低 | 较高,有调度唤醒成本 |
| Busy polling | 一直检查条件 | 高 | 极低,条件满足后立刻响应 |
busy polling 的核心 trade-off 是:牺牲一个 CPU 核的利用率,换更低的响应延迟。
所以它常见于这些场景:
- 高频交易:绑定专用核心轮询网卡队列,尽量避免调度和中断延迟
- 网络驱动:Linux NAPI 会在高流量场景下从中断模式切到轮询模式
- 无锁队列消费者:消费者线程不断检查队列是否有新数据
- 自旋锁:本质上就是 busy polling 一个锁变量
实际工程里通常不会无限空转,而是采用混合策略:
1 | 先 spin 一小段时间 |
这样既能优化短等待场景,又不会在长等待时持续烧 CPU。
Atomic Ring Buffer:低延迟队列的常见形态
ring buffer 是一个固定大小的循环数组。生产者往里写,消费者从里读,读写位置走到数组尾部后再绕回开头。
1 | slots: [ _ | A | B | C | _ | _ ] |
基础结构大概是:
1 | template <typename T, size_t N> |
一般会让 read_idx 和 write_idx 永远递增,然后通过取模定位数组下标:
1 | size_t index = pos % N; |
这样可以更容易判断空和满:
1 | read_idx == write_idx -> 空 |
SPSC Ring Buffer:单生产者单消费者
SPSC 是 Single Producer Single Consumer。因为只有一个生产者写 write_idx,只有一个消费者写 read_idx,所以这个版本甚至不需要 CAS。
生产者:
1 | bool push(const T& value) { |
消费者:
1 | bool pop(T& value) { |
这里的重点是:
slots[...] = value必须发生在write_idx对消费者可见之前- 消费者看到新的
write_idx后,必须能看到对应 slot 里的数据
这就是 release store 和 acquire load 要解决的问题。
MPMC Ring Buffer:多生产者多消费者
MPMC 是 Multi Producer Multi Consumer。多个生产者会同时抢写入位置,多个消费者会同时抢读取位置,这时就需要 CAS。
工业级 MPMC ring buffer 常见设计是给每个 slot 加一个 sequence 字段:
1 | template <typename T> |
生产者大致流程是:
1 | 1. 读取 write_idx |
消费者则反过来:
1 | 1. 读取 read_idx |
这种结构比 mutex 队列复杂,但在低延迟、高吞吐场景下非常常见。LMAX Disruptor、很多交易系统和消息队列内部都能看到类似思路。
Release Store 与 Acquire Load
先看一个经典问题:
1 | // 线程 1 |
直觉上,线程 2 看到 flag == true 后,应该一定能看到 data == 42。但在多核 CPU 和编译器优化下,如果没有同步语义,这个保证并不成立。
正确写法是:
1 | // 线程 1 |
可以这样理解:
release store:这条 store 之前的读写,不能被重排到它之后;并且要对看到它的线程可见acquire load:这条 load 之后的读写,不能被重排到它之前;如果它看到了 release-store 写入的值,也能看到 release 之前的写入
当一个 acquire load 读到了另一个线程 release store 写入的值,它们之间就建立了 happens-before 关系。
在 ring buffer 里,这个语义非常关键:
1 | // producer |
如果把这里全部换成 memory_order_relaxed,消费者可能先看到 sequence 更新,却还看不到对应的 data 写入。
Store Buffer:为什么写入不会立刻被别的核心看到
现代 CPU 不会每次写内存都停下来等缓存一致性协议完成。为了提高性能,核心通常会先把写入放进 store buffer,然后继续执行后续指令。
可以把它想成这样:
1 | CPU Core |
这带来一个重要现象:当前核心能通过 store-to-load forwarding 看到自己刚写的值,但其他核心可能暂时看不到。
例如:
1 | // 初始 x = 0, y = 0 |
在弱内存模型下,可能出现:
1 | r1 == 0 && r2 == 0 |
因为两个核心的写入都还停留在各自的 store buffer 里,对方暂时看不到。
memory barrier、release/acquire 等机制,本质上就是在约束这些乱序和可见性问题:什么时候允许写入继续留在 buffer 里,什么时候必须让之前的写入对其他核心可见。
False Sharing:无锁结构里的隐形性能杀手
CPU cache 不是按单个变量加载的,而是按 cache line 加载。常见 cache line 大小是 64 字节。
如果两个频繁更新的原子变量刚好落在同一个 cache line 上,即使它们逻辑上毫无关系,也会互相拖慢。
比如:
1 | struct BadLayout { |
生产者不断写 write_idx,消费者不断写 read_idx。如果两个变量在同一条 cache line 上,每次一个核心写入,都会导致另一个核心对应 cache line 失效。
更好的做法是让它们分开:
1 | struct alignas(64) PaddedAtomicSize { |
在低延迟队列、线程池计数器、统计指标里,false sharing 经常是性能抖动的来源。
CPU Cache Warmup:为什么第一次跑总是慢
CPU cache warmup 指让数据和指令逐渐进入 cache 的过程。
程序刚启动时,相关数据往往不在 cache 里,第一次访问会经历多级 cache miss:
1 | L1 miss -> L2 miss -> L3 miss -> DRAM |
访问延迟大概可以这样理解:
1 | L1 cache: 几个 cycle |
所以同一段代码,第一次跑和预热后再跑,耗时可能差很多。
这也是 benchmark 需要 warmup 的原因:
1 | for (int i = 0; i < warmup_iters; ++i) { |
在 ring buffer 场景下,如果 slots 数组大小能放进 cache,跑过几轮后访问会稳定很多;如果 ring buffer 远大于 LLC,warmup 的收益就会明显下降,因为数据不断被换出 cache。
这些概念怎么串起来
可以用一个低延迟消息队列来串联:
1 | 1. 生产者用 CAS 抢占 ring buffer 的写入位置 |
它们不是孤立知识点,而是同一套低延迟并发系统里的不同层次:
- CAS 解决「多个线程怎么无锁抢同一个位置」
- release/acquire 解决「写入顺序和可见性怎么保证」
- store buffer 解释「为什么可见性不是天然成立的」
- busy polling 解决「如何避免阻塞唤醒延迟」
- ring buffer 提供「固定内存、cache-friendly 的队列结构」
- false sharing 和 cache warmup 处理「真实性能为什么和代码看起来不一样」
总结
无锁并发不是简单地把 mutex 换成 atomic。它真正难的地方在于:
- 正确性依赖原子操作和内存序。
- 性能依赖 cache line、store buffer、预取和调度行为。
- 低延迟通常不是免费得到的,而是用 CPU、复杂度和可维护性换来的。
所以工程上要先问清楚:
- 是否真的需要无锁?
- 竞争是低还是高?
- 等待时间是短还是长?
- 是追求吞吐,还是追求尾延迟?
- 数据结构能不能放进 cache?
如果只是普通业务并发,mutex、condition_variable 和线程池往往已经足够;如果是交易系统、网络包处理、实时音视频这类低延迟场景,CAS、busy polling、atomic ring buffer 和 cache-aware 优化才真正值得投入。