1. 先搞清楚锁到底慢在哪,再谈无锁是否值得
1.1 从一次"看起来很简单却卡住"的线上抖动说起
有段时间我负责一个中间件模块,核心链路是一批工作线程从队列取消息、做过滤和转发。现象很典型:正常时候单条消息处理只要几十微秒,但每天早上业务高峰会偶发抖动,一次请求延迟能飚到几百毫秒,甚至秒级。起初我怀疑是下游服务慢,翻日志发现网络耗时平稳,CPU也没跑满,但线程 dump 里能看到大量线程阻塞在同一个互斥锁的等待队列上。
那时候我意识到一个常被忽略的事实:锁竞争并不是"等一会儿就过去"那么简单。当竞争出现,线程会被操作系统调度器挂起,等锁释放后再唤醒。从挂起到唤醒,中间涉及内核态切换、运行队列调整、CPU 缓存失效,而这段时间往往比临界区本身的执行时间高几个数量级。尤其当临界区本来就短,比如只是往队列里 push 一个节点、更新一个计数器,锁的固定开销会直接成为瓶颈。
后来我把这条热点路径上的互斥锁换成了无锁数组加原子索引,抖动基本消失。这里我先不贴代码,而是想先讲清楚一个前提:无锁编程的目标从来不是"消灭锁",而是消灭锁竞争带来的线程调度延迟。如果你不理解这层动机,后面所有内存序、CAS 循环、ABA 问题的讨论都会缺少落脚点。
1.2 把锁开销拆开看:快速路径、系统调用与缓存行
一把锁的完整开销可以从三个层面看。
第一层是快速路径。一个没有被争用的互斥锁,lock/unlock 本身可能只需要几十纳秒,这通常对应一个原子 compare-and-swap 或者原子 exchange 操作。问题在于"没有被争用"这个前提:只要有第二个线程同时来抢,冲突就会发生。
第二层是内核态路径。pthread_mutex 在发生争用时一般会调用 futex 进入睡眠,由内核来维护等待队列。这个过程会切换上下文,相关线程的调度状态会变化。等到持锁线程释放锁,系统又要选择一个等待线程唤醒,唤醒动作本身可能还要经过运行队列的重新调度。从"锁被释放"到"等待线程真正跑起来",延迟达到几十微秒并不稀奇。
第三层是缓存一致性开销。即使在自旋锁或者原子操作层面,多个 CPU 同时修改同一个内存位置,也会导致缓存行在多个核心之间反复失效。每个核心写一次,其他核心持有的该缓存行副本就要作废,下次访问必须重新从缓存一致性协议中获取最新值。这个过程叫 cache line bouncing,它会让原本每个核心各自并行的访问串行化。
很多人设计系统时只算临界区本身的指令数,却忘了把线程调度延迟和缓存一致性协议的开销算进去。真实结论是:临界区越短,无锁方案相对锁方案的优势越明显。如果临界区本身就要执行几毫秒,加不加锁反而不重要,因为临界区耗时已经掩盖了锁的调度开销。
1.3 判断"是否该上无锁"的四个筛子
不是所有并发代码都值得改成无锁。我一般用四个问题过滤:
- 临界区是不是极短?如果只做几项变量更新、一次链表插入,就值得考虑。
- 并发度是不是很高?多个线程反复抢同一把锁,才需要担心调度延迟。
- 性能指标是否要求低延迟和低抖动?日志打印、离线统计这类场景偶尔卡一下没关系,但交易链路、实时音视频、网络转发这类场景对尾部延迟很敏感。
- 你愿意为正确性付出多少维护成本?无锁代码比普通锁代码难读难调,团队如果没有足够积累,贸然全盘替换很可能引入隐蔽的数据竞争。
如果四个问题里有一个回答是否定的,用锁往往是更理性的选择。我在工作中不会为了"炫技"去重写一个本身没有热点的模块,这是无锁编程最容易走偏的地方。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 原子操作在 CPU 层的真实机制:从 CMPXCHG 到 LL/SC
2.1 一条带 lock 前缀的指令做了什么
要理解无锁编程,先得理解原子操作为什么能"原子"。以 x86 为例,最经典的原子读改写指令是 lock cmpxchg,也就是比较并交换,通常简称 CAS。
这条指令做的事情可以概括为:比较某个内存位置的当前值和期望值,如果相等,就把新值写进去;如果不相等,什么都不写,并把该位置的当前值通过寄存器返回。整个过程对别的 CPU 核心来说,要么完全看不到,要么看到的是最终结果,不可能看到中间状态。
硬件是怎么保证这一点的?老一代 CPU 会用锁总线的方式,直接在总线层面阻止其他核心访问内存。现代 CPU 更聪明,通常是在缓存一致性协议层面对目标缓存行做"独占"处理。执行原子指令的核心先把包含目标地址的缓存行拿到 Exclusive 或 Modified 状态,其他核心对应地址的缓存行会被作废;在这个状态下执行读改写,就不会再被其他核心的并发修改干扰。所以 lock 前缀在现代实现中更像是在缓存一致性协议上做了一次事务性操作,而不是真的把整条总线锁死。
这个细节解释了为什么原子操作要求内存地址对齐。缓存行是缓存一致性协议的最小传输单位,如果目标地址跨了两个缓存行,CPU 就无法用单一缓存行状态来保证原子性。x86 上未对齐的原子指令通常会直接触发异常或者变得不受支持,所以编译器在生成原子代码时默认对齐是必须的前提。
2.2 ARM 的 LL/SC 和 x86 的差别
x86 的 CAS 是一条指令,直接提供读改写原子性。ARM 这类 RISC 架构则走了另一条路:Load-Exclusive / Store-Exclusive,简称 LL/SC。
LL/SC 的思路是先执行一条独占加载指令 LDXR,把目标地址读出来,同时在内核里记录"这个地址被当前核心监视"。后续如果做条件更新,就执行 STXR,只有监视的状态仍然有效,存储才会成功;如果期间有其他核心写入过这个地址,监视状态会被清除,STXR 会返回失败码,程序必须重试整个读改写流程。
这带来一个现象:ARM 上实现 CAS 往往需要写入一个循环。compare_exchange_weak 在语义上允许因为某些硬件原因直接失败,哪怕期望值和当前值匹配;compare_exchange_strong 则在编译器层面或运行时内部做了重试包装,保证要么真正写入成功,要么因为值不匹配而失败。
从编程视角看,ARM 的原子操作更像"带条件的存储尝试",而 x86 的原子操作更像"一条完整的读改写事务"。这也是为什么在高性能原子算法里,compare_exchange_weak 通常是更推荐的选择:它允许你用一个循环容纳可能的临时失败,同时避免编译器对强语义做不必要的额外保证。
2.3 缓存一致性不等于全局顺序
这里必须澄清一个常见误解:CPU 的缓存一致性协议(MESI 这类)保证的是"每个地址的写操作最终收敛到一个顺序",它并不保证所有读写操作在全局看起来有一个统一的先后顺序。
典型例子是 x86 的 store-load 重排。线程 A 向地址 X 写入一个值,然后读取地址 Y;线程 B 同时向 Y 写入值再读取 X。在缓存一致性协议层面,两个写入最终都会被其他核心看到,但对每个读者来说,可能出现看起来都"先看到对方写入、后看到自己写入"的交叉情况。原因是 CPU 有写缓冲区和乱序执行引擎,存储操作往往不会立刻对全局可见,而加载操作可能绕过尚未提交的存储先完成。
缓存一致性解决的是"写写冲突"和"读写过期"问题,但解决不了"不同核心观察多个内存地址时的排列顺序"问题。后者正是内存序,也就是 memory ordering 要讨论的范畴。这也是无锁编程比普通加锁代码难懂的本质原因:锁通过内核机制强制建立了清晰的前后边界,而无锁代码需要你自己对每个关键读写的顺序约束负责。
3. C++11 的内存序是专门给原子操作准备的吗
3.1 这个热搜问题其实只问对了一半
网上常有人问"C++11 内存序是专门为原子操作准备的吗",我的回答是:它是围绕原子操作来定义的一套规则,但它真正想规范的对象并不仅仅是原子操作本身。
你翻 C++ 标准会发现,内存序参数出现在 std::atomic 的所有 load、store、exchange、compare_exchange 方法上,看起来确实只服务原子操作。但这套规则的目的,是为了让"非原子变量"在多线程读写时也能建立可以预测的前后关系。
举个例子,你在一个无锁环形队列里写入普通结构体对象,是用原子变量 head 的 release store 来发布"对象已经写完"这个信息;消费者则是用同一个 head 的 acquire load 来确认"在发布之前发生的写操作都已经可见"。原子变量的内存序在这里像一道闸门,它约束的是普通对象的可见顺序,而不只是原子变量自己的可见顺序。
所以更准确的说法是:内存序的载体是原子操作,但它的作用边界覆盖了与原子操作相邻的非原子内存访问。这也是 C++11 内存模型设计的核心目标:让无锁算法可以用一套跨平台规则描述,而不是每个平台各写一套内联汇编。
3.2 release/acquire 到底在约束什么
只用 std::atomic 而不用内存序,默认是 memory_order_seq_cst,最安全但也最贵。想手动优化时,先要掌握 release 和 acquire 这一对模型。
release 是"发布",对应写操作。它承诺:本线程在这个 release store 之前发生的所有内存写操作,不能被重排到这个 release store 之后。acquire 是"获取",对应读操作。它承诺:本线程在这个 acquire load 之后发生的所有内存读操作,不能被重排到这个 acquire load 之前。
把这两个组合起来,就能实现经典的写者-读者同步:
cpp复制std::atomic<bool> ready{false};
std::string message;
void producer() {
message = "hello from producer"; // 普通非原子写
ready.store(true, std::memory_order_release);
}
void consumer() {
while (!ready.load(std::memory_order_acquire)) {
// 等待
}
// 此时可以安全读取 message
std::cout << message << std::endl;
}
这里的关键点是:message 的写入发生在 ready.store(release) 之前。消费者一旦通过 acquire load 看到 ready 为 true,就能确认 producer 线程里所有发生在 release store 之前的写操作都已经对当前线程可见,因此读 message 不会读到半成品或空值。
相反,如果 ready.store 用的是 memory_order_relaxed,编译器就可能把 message 的赋值重排到 store 之后,消费者看到 ready 为 true 时,message 还没写完,这就是数据竞争。
3.3 relaxed、acquire/release、seq_cst 的选择策略
内存序的五个基本选项是 relaxed、acquire、release、acq_rel、seq_cst。它们从松到严排列,seq_cst 最接近我们直觉上的"全序一致"。
| 内存序 | 含义 | 典型用途 |
|---|---|---|
| relaxed | 只保证原子性,不做任何顺序约束 | 统计计数器、不依赖顺序的标记位 |
| acquire | 用于 load,阻止后续读写上移 | 读取发布标志、消费无锁队列头 |
| release | 用于 store,阻止先前读写下移 | 发布数据、发布无锁队列新节点 |
| acq_rel | load 和 store 都带顺序约束 | RMW 操作比如 CAS、exchange |
| seq_cst | 最强,所有线程看到同一全局顺序 | 通用默认,复杂算法兜底 |
我的实践建议很简单:非性能热点一律用默认 seq_cst,不要一开始就手动标内存序。无锁算法的正确性判断已经够难,再叠加手动内存序,等于给排查埋雷。等你用 profiler 验证确实 seq_cst 是瓶颈,再考虑把某些只做计数的操作降级为 relaxed,或者把发布-消费模式改成 release/acquire。
x86 平台上默认 seq_cst 往往不增加多少额外成本,因为 x86 的强内存模型已经天然约束了很多重排;但在 ARM、PowerPC 这类弱内存模型平台上,seq_cst 会被编译成更重的栅栏指令,性能差距这就体现出来了。真正需要跨平台高并发时,才有必要精细区分内存序。
4. 手写一套可运行的原子构建块:计数器、Treiber 栈与 SPSC 环形队列
4.1 原子计数器:relaxed 安全的场景
先从最基础的无锁结构开始:原子计数器。多个线程同时对同一个整数做自增,用互斥锁当然可以,但标准做法是直接用 fetch_add。
cpp复制std::atomic<long long> counter{0};
void on_message() {
counter.fetch_add(1, std::memory_order_relaxed);
}
这里用 relaxed 是安全的,因为我们对计数器的要求只有"最终值是谁也不丢"这一条,不关心它是按什么时间顺序递增的。fetch_add 保证自增操作本身不可分割,计数不会因为并发更新而漏掉。
需要注意一个反直觉的点:只用 fetch_add 的话,想在拿到某个时点快照后继续基于快照做逻辑会很麻烦。比如统计"处理完 1000 条后触发一次批量刷盘",标准的做法不是先 fetch_add 再判断是否等于 1000,直接用 fetch_add 的返回值判断即可,因为每个线程拿到的是唯一递增的序号。类似场景用计数器非常顺手。
4.2 Treiber 无锁栈:最直观的 CAS 竞争结构
接下来是经典的 Treiber 栈,它可能是最适合入门无锁的复杂结构。核心思想很简单:栈顶用原子指针保存,插入和弹出都通过 CAS 循环实现。
我先给一个教学性的 C++ 版本,它先不处理节点释放问题,方便把 CAS 循环的逻辑看清楚:
cpp复制struct Node {
int value;
Node* next;
};
std::atomic<Node*> head{nullptr};
void push(int value) {
Node* n = new Node{value, nullptr};
n->next = head.load(std::memory_order_relaxed);
while (!head.compare_exchange_weak(
n->next, // 期望值:读进来时的栈顶,失败时会被更新成最新栈顶
n, // 新值:新节点
std::memory_order_release,
std::memory_order_relaxed)) {
// 失败说明其他线程抢先修改了 head,循环重试即可
}
}
Node* pop() {
Node* old_head = head.load(std::memory_order_relaxed);
while (old_head &&
!head.compare_exchange_weak(
old_head,
old_head->next,
std::memory_order_acquire,
std::memory_order_relaxed)) {
// 失败时 old_head 已经被更新为最新栈顶,继续尝试
}
return old_head;
}
push 的流程是:先把当前栈顶读到新节点的 next 里,然后用 CAS 尝试把 head 从旧栈顶改成新节点。如果期间有其他线程把 head 换了,CAS 会失败,同时把期望值参数更新为最新的 head,循环再次尝试。这就是无锁编程里最常见的"读-算-CAS-重试"模式。
pop 的原理类似:先把当前 head 快照到一个局部变量,再尝试用 CAS 把 head 改成 old_head->next。如果 head 已经被别的线程推进,就重试;如果栈已空,直接返回空指针。
这段代码我在教学里不会直接拿到生产环境,原因是它没有处理节点内存释放。pop 返回的节点如果马上 delete,可能另一个线程的 CAS 还持有指向它的指针,这就会引发悬空指针。要真正回收节点,需要用到第五节介绍的内存回收技术,这里先把 CAS 本身的结构讲透。
另外要注意 compare_exchange_weak 的第一个参数是引用类型,函数内部会把它更新为当前值。这也是为什么返回值失败时,循环还能继续用同一个变量重试,因为该变量已经被刷新成最新栈顶了。这是无锁栈最容易写错的地方,很多新手会自作聪明重新读一次 head,其实不必要。
4.3 SPSC 环形队列:从单读单写开始验证无锁思路
栈是 LIFO,同一端进同一端出,竞争集中在一个指针上。队列是 FIFO,一端进一端出,天然涉及 head 和 tail 两个位置,复杂度立刻上一个台阶。我先说单生产者单消费者(SPSC)场景,这是无锁队列里最友好的一种,因为生产者和消费者各自只写自己的索引,不会出现同位置竞争。
下面是一个定长环形队列的简化实现,容量设为 2 的幂,用一个空位区分队列是否满:
cpp复制template <typename T>
class SpscRing {
public:
explicit SpscRing(size_t capacity)
: capacity_(capacity), mask_(capacity - 1),
buffer_(new T[capacity]) {
// capacity 必须是 2 的幂
}
bool push(const T& value) {
uint64_t h = head_.load(std::memory_order_relaxed);
uint64_t t = tail_.load(std::memory_order_acquire);
if (h - t >= capacity_ - 1) {
return false; // 队列已满
}
buffer_[h & mask_] = value;
head_.store(h + 1, std::memory_order_release);
return true;
}
bool pop(T& out) {
uint64_t t = tail_.load(std::memory_order_relaxed);
uint64_t h = head_.load(std::memory_order_acquire);
if (t == h) {
return false; // 队列已空
}
out = buffer_[t & mask_];
tail_.store(t + 1, std::memory_order_release);
return true;
}
private:
size_t capacity_;
size_t mask_;
std::unique_ptr<T[]> buffer_;
alignas(64) std::atomic<uint64_t> head_{0};
alignas(64) std::atomic<uint64_t> tail_{0};
};
push 端只有生产者线程会写 head_,所以 head_.load 用 relaxed 就够;但我在判断队列是否满时,需要读取消费者推进 tail_ 的最新进度,所以 tail_.load 要用 acquire。反之 pop 端只在消费者线程写 tail_,消费者读取 head_ 时必须用 acquire,才能保证看到生产者 release store 之前写入 buffer_ 的数据。
两个索引之间用 alignas(64) 隔开,是为了避免 head_ 和 tail_ 落在同一个缓存行里。如果它们在同一行,消费者每次更新 tail_ 都会让生产者的 head_ 访问变慢,性能会急剧下降,这就是下一章要展开的伪共享问题。
SPSC 队列虽然只支持一个生产者和一个消费者,但它是理解无锁队列正确性的重要分界线。多生产者多消费者(MPMC)队列通常需要把一段连续区间分成多个位置槽位,或者采用更复杂的 Michael-Scott 链表队列,每个线程都要在入队和出队时与其他所有线程竞争,代码量和心智负担都会明显增大。
4.4 为什么 MPMC 队列往往是另一个量级的问题
很多人学完 SPSC 会问:把 head_ 和 tail_ 的访问都改成 CAS,不就能变成多生产者多消费者了吗?理论上可以,但实际没这么简单。
在单链表 MPMC 队列里,尾部入队通常需要在 tail 指针上做 CAS,把新节点链到当前尾节点之后,同时可能还要修正尾指针。问题是"链到当前尾节点之后"这个动作本身需要读取旧尾节点的 next 字段;如果另一个线程刚好完成了一次入队,旧尾节点的 next 已经被更新,就会导致你操作的对象不是真正的"尾"。
经典的 Michael-Scott 队列为了解决这个问题,会引入一个 dummy 哨兵节点,并使用两个 CAS:先修正尾指针,再修正头指针。甚至在删除节点时还要考虑 ABA 问题和节点内存回收。而基于数组的 MPMC 环形队列又需要处理多个生产者竞争空位、多个消费者竞争满位的原子索引分配,还要防止消费者读到生产者还没写完整的数据。
所以我的切身体会是:SPSC 是理解无锁队列的入口,MPMC 才真正考验无锁功底。如果你只是需要一个线程池任务队列,绝大多数场景用带锁队列或者 SPSC 组合就够了。为了 MPMC 去手写无锁队列之前,先认真想清楚你到底有没有"多个写线程同时写同一个队列"的硬需求。
5. 无锁编程最隐蔽的坑:ABA、内存回收与伪共享
5.1 ABA:同一个地址重建如何让我们错误成功
CAS 有个经典陷阱叫 ABA 问题。它是这样发生的:线程 1 读取到栈顶地址 A,准备做 CAS;在它计算期间,线程 2 把 A 弹出并把节点释放,然后另一个线程又申请了一块同样地址的内存,把它作为新节点重新入栈。此时线程 1 的 CAS 看到栈顶还是地址 A,认为自己读到的快照仍然有效,于是修改成功,但实际上栈的内容已经完全是另一批节点了。
在 Treiber 栈里,ABA 会导致栈直接丢失节点或者形成环路,表现非常诡异。常见缓解方案有几种:给节点指针附加一个版本号,每次修改都递增版本号,CAS 同时比较"指针 + 版本号"的组合,也就是 double-width CAS;或者引入标签值,让每次 pop 和 push 都改变标签。x86-64 上用 16 字节的 cmpxchg16b 把指针和计数器打包成一个原子对象,是常见做法,但跨平台支持要额外确认。
ABA 的本质是"内存地址本身不足以表征对象版本"。这让我在写无锁代码时养成了一个习惯:不要只盯着 CAS 比较是否成功,更要关注你比较的对象到底会不会被释放和重建。
5.2 C++ 无锁数据结构的内存回收是真正的独立难题
在 Java、Go 这类带垃圾回收的语言里写无锁栈,节点被引用期间 GC 不会真的释放内存,ABA 风险会小很多。但在 C++ 这类手动内存管理的语言里,节点什么时候能 delete,是一个非常麻烦的问题。
前面 Treiber 栈的教学代码里 pop 直接返回裸指针,没有 delete。如果业务方 pop 之后不释放,内存会一直涨;如果立刻 delete,又可能撞上其他线程持锁的悬空指针。
业界常用的方案包括:
- Hazard Pointer(险兆指针):每个读者线程在读共享指针前,先把指针登记到自己的 hazard pointer 槽位;写者想释放节点前,检查所有 hazard pointer,如果还有线程可能访问它,就推迟释放。需要编译器提供双内存栅栏,防重排。
- Epoch-Based Reclamation(基于代际的回收):用一个全局 epoch,线程进入临界区时递增本地计数,离开时递减;回收器会延迟释放经过若干个 epoch 的节点,确保没有线程还在使用旧节点。
- RCU 风格的延迟回收:在允许短暂延迟的读场景里,可以用类似 RCU 的方式,等待所有读者退出后再统一释放。
这些方案实现起来都不轻,远比 CAS 本身复杂。如果你在 C++ 项目里要造无锁容器,我建议先去搜成熟库,比如 Folly 的 hazard pointer、libcds,或者直接基于 folly::ConcurrentQueue 这类现成实现改,而不是从零手写。
5.3 伪共享不是原子操作的错,但无锁代码会把它放大
伪共享指的是两个不同线程频繁修改两个不同的变量,但它们落在同一个缓存行里。CPU 缓存一致性协议的最小单位是缓存行,修改任意一个字节,都会让整个缓存行在其他核心的副本失效。
两个线程各自维护一个计数器,如果一个放在结构体的相邻字段,一个放另一个字段,它们被分配到同一个缓存行,那么每次自增都会触发对方缓存行失效。两个线程来回修改这个共享缓存行,实际性能可能比用互斥锁还差。
解决办法很粗暴,就是给热点变量填充到以缓存行为边界对齐。比如:
cpp复制struct alignas(64) PerThreadCounter {
long long value;
};
x86 常见缓存行是 64 字节,ARM 一些平台可能是 32 字节或 64 字节。更好的做法是用平台相关的常量做检测。在使用无锁数据结构时,head 和 tail 这类被不同线程读写的指针,尤其需要隔离缓存行。我见过不止一次,无锁栈性能跑不过带锁版本,最后定位到堆上缓存行填充不足。
5.4 无锁编码最后的一条实用原则
如果让我给
