Cpp_原子类型_无锁队列_内存顺序
谈谈对 C++ 内存模型和 std::atomic 原子操作的理解
我理解 C++ 内存模型,核心是在多线程程序里两个问题:
- 一个线程对内存的修改,另一个线程什么时候能看到
- 编译器和 CPU 能不能对指令重排,重排后程序是否仍然正确
C++11 之后,语言层面正式定义了内存模型,也定义了多线程下哪些行为是合法的,哪些会导致未定义行为。
1. 对 C++ 内存模型的理解
(1)单线程和多线程的区别
单线程里,代码执行顺序通常按语义理解就行。
但在多线程下,为了优化性能:
- 编译器 可能会重排指令
- CPU 也可能乱序执行
- 每个核心还有自己的 缓存
所以“代码写在前面”不等于“其他线程一定先看到”。
(2)data race 是核心概念
C++ 内存模型里一个非常重要的点是:
如果多个线程 同时访问同一块内存,并且:
- 至少有一个是写
- 又没有同步手段保护
那就会发生 data race(数据竞争),结果是 undefined behavior(未定义行为)。
比如两个线程同时改一个普通 int 计数器,没有加锁,也没有用原子变量,这就是典型 data race。
所以我理解里,C++ 内存模型首先是在告诉我们:
普通变量在多线程下不能乱共享,跨线程共享写入一定要靠同步原语,比如 mutex 或 atomic。
(3)happens-before
我觉得内存模型里最关键的抽象是 happens-before。
如果 A happens-before B,那么就可以认为:
- A 的结果对 B 可见
- A 的执行顺序在语义上先于 B
而这个关系通常通过下面几种方式建立:
- 同一个线程内的顺序
- 加锁 / 解锁
- 原子操作的 acquire / release
- 线程创建和 join
所以多线程正确性的本质,不只是“代码能跑”,而是要建立正确的 同步和可见性关系。
2. 对 std::atomic 的理解
std::atomic 提供的是 原子读写 / 原子读改写操作,用来避免 data race。
它的核心价值有两个:
(1)原子性
一个原子操作不会只做一半就被别的线程看到。
比如:
1 | std::atomic<int> cnt{0}; |
这个加一是原子的,不会像普通 cnt++ 那样在多线程下出现读、改、写被打断的问题。
(2)内存可见性和有序性
atomic 不只是“这个操作不会被打断”,还可以通过 memory order 约束:
- 这个线程之前的写,什么时候对别的线程可见
- 这个线程之后的读写,能不能越过当前操作重排
所以 atomic 不只是“线程安全变量”,更准确地说,它是:
同时控制原子性 + 可见性 + 有序性 的工具。
3. 常见原子操作
常见的 std::atomic 操作我理解有这些:
load():原子读store():原子写exchange():原子交换fetch_add()/fetch_sub():原子加减compare_exchange_weak/strong():CAS,比较并交换
其中最重要的是 CAS,很多无锁结构、状态机、一次性初始化逻辑都依赖它。
比如它的语义可以理解成:
如果当前值等于预期值,就更新成新值;否则更新失败。
这是一种非常常见的无锁同步原语。
4. memory order 的理解
C++ 里常见内存序有:
memory_order_relaxedmemory_order_acquirememory_order_releasememory_order_acq_relmemory_order_seq_cst
(1)relaxed
只保证 原子性,不保证额外同步顺序。
适合纯计数这类“只要求数值正确,不要求和别的数据建立时序关系”的场景。
比如统计请求数、命中数。
(2)release / acquire
这是我觉得工程里最常用、也最重要的一组。
- release:保证这个操作前的写,不会被重排到它后面
- acquire:保证这个操作后的读,不会被重排到它前面
如果一个线程用 release 写,一个线程用 acquire 读,并且读到了对方写入的值,那么就建立了同步关系。
一个经典例子:
1 | std::atomic<bool> ready = false; |
这里 ready 就像一个发布信号。
A 先写 data,再 release 发布;
B acquire 读到 true 后,就能看到 A 之前对 data 的写。
(3)seq_cst
这是最强的内存序,默认很多原子操作就是它。
它可以理解成:
所有
seq_cst原子操作在所有线程看来,好像存在一个全局一致的顺序。
它最容易理解,也最不容易出错,但通常约束最强,性能可能略差。
所以实际工程里:
- 不敏感场景可以直接用默认的
seq_cst - 性能敏感且逻辑清楚时,再考虑
acquire/release relaxed要非常谨慎,只有在明确不需要同步语义时才用
5. atomic 和锁的区别
我理解 atomic 不是锁的替代品,而是适合 更小粒度的同步。
atomic 适合:
- 单个变量状态同步
- 计数器
- 标志位
- 简单无锁队列/栈中的局部操作
mutex 更适合:
- 多个变量需要保持一致性
- 临界区逻辑复杂
- 需要更强的互斥语义
因为 atomic 只能保证单个原子操作本身安全,
但如果逻辑涉及“先看 A,再改 B,再更新 C”,往往还是加锁更清晰、更不容易错。
所以我的理解是:
能用锁清楚解决的问题,优先用锁;只有在确实有性能需求,且共享状态足够简单时,再用 atomic。
6. 对 lock-free 的理解
很多人会把 atomic 和“无锁高性能”直接画等号,但我觉得这要分开看。
std::atomic支持原子操作- 但 是否 lock-free 不一定
is_lock_free()只能说明在当前平台/类型上是否可能无锁实现
所以用了 atomic,不代表一定完全没有锁,也不代表一定性能最好。
无锁代码通常更难写、更难验证正确性,还会有 ABA、活锁、自旋浪费 CPU 等问题。
7. 一句话总结我的理解
我对 C++ 内存模型的理解是:
它规定了多线程下内存可见性、指令重排和同步关系,核心是避免 data race,并通过 happens-before 建立正确的线程间顺序。
我对 std::atomic 的理解是:
它不仅保证操作的原子性,还能通过 memory order 控制可见性和有序性;适合做轻量同步,但不能替代所有锁,复杂共享状态仍然要优先考虑 mutex。
压缩版
C++11 引入了正式的内存模型,规定了多线程下共享内存的可见性、有序性和数据竞争规则。多个线程若同时访问同一变量且至少一方写入、又无同步保护,就会产生 data race,属于未定义行为。std::atomic 用于提供原子读写和读改写操作,避免数据竞争,并通过 memory_order 控制重排与可见性。常见内存序包括 relaxed、acquire/release 和 seq_cst。我的理解是,atomic 适合计数器、状态位等轻量同步,而复杂临界区仍应优先使用 mutex。
线程安全问题
1 | int count = 0; |
后置++有多个动作:
- 取出count的值为tmp
- 计算
count + 1 count + 1的结果赋给count- 返回tmp
对应于多条指令。
传统地解决,可以用加锁的方式
1 | { |
但是,互斥锁是比较耗费资源的,如果临界区的代码比较轻量级,那么传统mutex锁相对而言就比较小题大做了。
现在有新机制解决:指令级并发。
如果想让这么多动作在一条指令内做完的话,需要让处理器支持这样的操作,而不用锁机制,因此这种叫做无锁结构。
原子类型
定义于<atomic>
原子类型,封装了一个值,保证其的访问不会导致数据竞争,并且可用于同步不同线程之间的内存访问。
atomic_flag
初始化可以赋值为ATOMIC_FLAG_INIT,意为无状态,对应false。
test_and_set():置位为有状态,对应true,并返回调用之前的状态。这是一个原子操作,不会被其他线程干扰。clear()重置flag为无状态
atomic_flag做自旋锁
以下就是一个用atomic_flag做忙等待的例子:
- 初始flag为无状态
- 调用一次
test_and_set()置位flag为有状态 - while中一直调用
test_and_set(),测试它的状态,直到为无状态时,退出循环,结束程序。
1 |
|
给定一个atomic_flag,初始为无状态,同时启动10个线程,操作之前,调用test_and_set,如果测试为无状态,说明没有其他线程在操作,就可以进行操作。
操作完毕后,clear重置flag为无状态。下一个线程就可以探测到无状态,开始它的操作。
此时,atomic_flag就相当于自旋锁的作用。
1 |
|
输出:
1 | thread #1 |
atomic
1 | template <class T> struct atomic; |
原子对象的主要特征是,从不同线程访问值不会导致数据竞争(即,这样做是明确定义的行为,访问顺序正确)。
通常,对于所有其他对象,如果同时访问同一对象而导致数据争用,则该操作将被视为未定义行为。
原子对象能够通过指定不同的内存顺序来同步对其线程中其他非原子对象的访问。
Relaxed Ordering的问题
1 | // Thread 1: |
标记为 memory_order_relaxed 的原子操作不是同步操作。它们不会在并发内存访问中强加顺序,它们只保证原子性和修改顺序的一致性。
例如,x 和 y 初始为零。
以上程序就会允许产生 r1 == r2 == 42,在线程 1 内,A 在 B 之前被排序,并且在线程 2 内,C 在 D 之前被排序。
但是没有什么可以阻止 D 在 A 之前 修改了 y,并且 B 在 C 之前 修改 x 。
D 对 y 的副作用对于线程 1 中的 load A 是可见的,B 对 x 的副作用对于线程 2 中的 load C 是可见的。
特别是,如果在线程 2 中 D 在 C 之前完成,这可能会发生,这可能是在运行时发生的,或者由于编译器重新排序导致的。
实际上,A、B是不能调换的,因为编译器会看到,同在线程1中,B中的
r1的值依赖于上一句的A对r1的操作。
而C、D之间就没有依赖了,因为 D 操作的是42,是个常量。
所以,经过线程并发、内存重排,可能的执行顺序有:
A B C D 此时 x、y、r1、r2的值:0、0、0、42
A B D C 此时 x、y、r1、r2的值:0、42、0、0
A C D B 此时 x、y、r1、r2的值:0、42、0、0
A D C B 此时 x、y、r1、r2的值:0、42、0、0
C A D B 此时 x、y、r1、r2的值:0、42、0、0
D A C B 此时 x、y、r1、r2的值:42、42、42、42 => 这时便出现了r1 == r2 == 42
C D A B 此时 x、y、r1、r2的值:42、42、42、0
D C A B 此时 x、y、r1、r2的值:0、42、42、0
怎么解决呢?
见下文
内存顺序
原子操作只是提供了不同线程读写的同步。
但是没有提供操作顺序的同步。
比如:线程1修改原子值a,线程2读取原子值a。
两个线程各自的读写操作,确实是保证了不会有脏值。
但是线程1、线程2的顺序没有做控制,
如果线程2想要读出旧值,但是线程1在线程2读值前进行写操作,还是会导致线程2读出脏值。
因此需要提供内存顺序机制。
下面举一个类似的例子,怎么通过原子变量+内存顺序控制,让consumer确保producer执行完毕后,再 load 出 b 的值。
1 | void producer(void) |
| 类型 | 含义 |
|---|---|
| relaxed | CPU和编译器可以重新排序变量顺序。 这是一种松散的内存顺序,不保证不同线程中的内存访问对原子操作进行排序。 |
| consume(废弃) | 针对某个原子变量的访问指令(store)重排到此指令(load)前。即自己load排到针对某个变量的release操作后。(在C++26中被废弃了,推荐用acquire) |
| acquire | 所有访问指令(store)排到此指令(load)前。即自己load排到所有release操作后。 |
| release | 所有访问指令(load)排到此指令(store)之后。即自己store排到所有consume、acquire操作之前。 扮演了一个同步点(synchronization point)的角色。 |
| acq_rel | The operation loads acquiring and stores releasing。 该操作可能扮演两种角色。 比如 std::atomic::exchange操作,两个变量交换:需要load值,可能需要等待release; 需要store值,即产生release,需要给其他acquire、consume通知。 |
| seq_cst | sequentially consistent,意思是该操作以顺序一致的方式排序。一旦所有可能对其他线程产生可见副作用的内存访问已经发生,则所有操作使用此内存顺序。 这是最严格的内存顺序,保证了在非原子内存访问中线程交互之间的意外副作用最小。 对于consume和acquire的load,顺序一致的store操作被认为是release操作。 |
lock_free测试
测试以确定原子模板包含的类型是否支持无锁。
1 | int main() |
经过测试后发现,不超过8字节(64位)的数据结构,并且没有虚函数表的数据结构,是支持无锁的。
1 | struct A {int x, y; A() {}} |
无锁编程的原理
CAS(Compare-And-Swap)
CAS在执行事务时把整个数据都拷贝到应用中,在数据更新提交的时候比较数据库中的数据与新数据,如果两个数据一模一样则表示没有冲突可以直接提交,如果有冲突就要交给业务逻辑去解决。(具有ABA问题:用版本号或时间戳解决)
现代CPU提供CAS(Compare-And-Swap) 等原子指令,可在单个指令周期内完成类似以下的判断函数:
1 | bool CAS(T* ptr, T old_val, T new_val) |
以上类似的操作,实际指令执行期间不会被中断,避免了数据竞争。
比如,用这个特性,实现一个自旋锁:
1 | int TestAndSet(int *old_ptr, int new) |
1 | typedef struct lock_t |
内存顺序
1 | std::atomic<int> flag(0); |
无锁数据结构的实现模式
”读-修改-写“循环(Read-Modify-Write Loop)
这是实现无锁的关键思维之一:乐观并发控制:先执行操作,提交前,验证数据(tail)未被修改,如果未被修改,再提交
1 | bool CAS(Node* old_ptr, Node* new_ptr) |
帮助机制(Helping Mechanism)
当线程A发现线程B的操作未完成时,主动协助推进(如无锁队列中帮其他线程移动tail指针)。
分离并发关注点(如头尾指针分离)
将数据结构拆分为多个可独立更新的部分,减少竞争点。
这是实现无锁的关键思维之二:局部性原理(Locality Principle):通过数据分片(如分槽队列)减少缓存行冲突:
对齐64位。
1 | struct alignas(64) PaddedAtomic |
ABA问题(乐观锁问题)
乐观锁会出现这种问题。
乐观锁是对于数据冲突保持一种乐观态度,操作数据时不会对操作的数据进行加锁(这使得多个任务可以并行的对数据进行操作),只有到数据提交的时候才通过一种机制来验证数据是否存在冲突(一般实现方式是通过加版本号然后进行版本号的对比方式实现)
特点:乐观锁是一种并发类型的锁,其本身不对数据进行加锁。而是通过业务实现锁的功能。
不对数据进行加锁就意味着允许多个请求同时访问数据,同时也省掉了对数据加锁和解锁的过程。
这种方式节省了悲观锁加锁的操作,所以可以一定程度的的提高操作的性能。
不过在并发非常高的情况下,会导致大量的请求冲突,冲突导致大部分操作无功而返而浪费资源。
所以在高并发的场景下,乐观锁的性能却反而不如悲观锁。
问题场景
比如说线程一从数据库中取出库存数 3,这时候线程二也从数据库中取出库存数 3。
线程二进行了一些操作变成了 2。但是然后线程二在线程一拿到操作权之前,又将库存数变成了 3。
这时候:线程一进行 CAS 操作发现数据库中仍然是 3,然后线程一操作成功。
尽管线程一的 CAS 操作成功,但是不代表这个过程就是没有问题的。
解决方案
使用版本戳来对数据进行标记,数据每发生一次修改,版本号就增加1。某条数据在提交的时候,如果数据库中的版本号与自己的一致,就说明数据没有发生修改,否则就认为是脏数据,需要处理。
使用带版本号的指针:
1 | struct VersionedPtr |
内存回收挑战
| 方法 | 原理 | 适用场景 |
|---|---|---|
| Hazard Pointers | 线程本地记录"危险指针"(有的也叫风险指针),延迟删除其他线程可能访问的内存 | Linux内核常用 |
| Epoch-Based Reclaim | 将内存标记为待回收,当所有线程都不持有旧epoch时才删除 | NVIDIA库常用 |
| RCU(Read-Copy-Update) | 读操作无同步,写操作创建副本延迟回收 | Linux内核链表 |
MPMC无锁队列
Node的设计(伪共享预防、ABA问题解决)
- 伪共享预防:
alignas(64)和填充确保 头尾 在不同缓存行 - ABA问题防护:通过
version版本号区分指针复用
1 | struct Node |
1 |
|
队列初始化(哨兵dummy头节点)
作用:保证非空队列始终存在头节点,避免头尾指针竞态
1 | { |
入队操作
1 | // ... |
compare_exchange_weak和strong
1 | bool compare_exchange_weak(T& expected, T desired, |
两个函数都是:
Atomically compares the value representation (since C++20) of *this with that of expected. If those are bitwise-equal, replaces the former with desired (performs read-modify-write operation). Otherwise, loads the actual value stored in *this into expected (performs load operation).
比较*this和expected的值,若相等,则把*this替换为desired(执行read-modify-write operation,即:读-修改-写),返回真。否则,把*this实际的值,存到expected中(执行load operation),返回假。
参数:
compare_exchange_weak和compare_exchange_strong都在后面有两个内存顺序的参数,第一个内存顺序指的是,成功时,做read‑modify‑write operation的内存顺序;第二个内存顺序指的是,失败时,做load operation的内存顺序。
与 compare_exchange_strong 不同的是,这个弱版本允许通过返回 false来虚假地失败,即使*expected 确实与 obj 中包含的值相等。
对于某些循环算法来说,这可能是可接受的行为,并且可能在某些平台上导致显著更好的性能。
对于这些虚假的失败,函数返回false,但不修改预期。
出队操作
1 | // ... |
析构
1 | // ... |
完整代码
1 |
|
内存回收(危险指针)
1 | // Hazard Pointer简单实现(线程本地注册) |
无锁是忙等待吗?
无锁编程并不等同于忙等待(Busy-Waiting),其核心在于「非阻塞」,而非具体等待方式。
- 「非阻塞」的定义
- Lock-Free:至少一个线程能前进
- Wait-Free:所有线程都能在有限步完成
- 延迟与吞吐的权衡
- 忙等待:牺牲CPU,以降低延迟(高频交易系统)
- 阻塞等待:加大延迟,以加大吞吐(Web服务器连接池)
无锁编程可根据竞争强度动态选择等待策略:
- 低竞争时短暂自旋(利用CPU流水线)
- 中竞争时退避+主动让出CPU
- 高竞争时进入操作系统阻塞队列
核心目标是以最小开销维持「至少一个线程前进」的非阻塞特性,而非强制忙等待。
实际无锁设计中可通过多种策略避免忙等:
操作系统级阻塞等待
1 | // C++20 前(使用 futex) |
定时退避策略
1 | int retries = 0; |
队列化竞争机制(排队锁)
1 | struct Waiter { std::atomic<Waiter*> next; }; |
对比
| 策略 | 实现方式 | 适用场景 | CPU占用 | 延迟 |
|---|---|---|---|---|
| 忙等待 | while(!CAS) |
极低延迟场景(<100ns) | 100%核心 | 极低 |
| 主动退让 | std::this_thread::yield() |
用户态竞争适中 | < 30% | 微秒级 |
| 操作系统阻塞 | futex / atomic::wait |
高竞争/长等待 | ~0% | 毫秒级 |
| 队列化调度 | MCS锁等排队机制 | 严格公平性要求 | 随队列转移 | 亚毫秒级 |
工业级案例
Linux内核Futex
- 首次CAS竞争失败后,通过
FUTEX_WAIT系统调用挂起线程 - 解锁时通过
FUTEX_WAKE唤醒等待线程
Java的AQS(Abstract Queued Synchronizer)
1 | final boolean acquireQueued(...) |
C++20原子等待
1 | std::atomic<int> flag(0); |
总线锁和缓存锁
总线锁(Bus Lock)
当CPU执行带LOCK前缀的指令(如CMPXCHG)时,会通过芯片组发出硬件信号,独占整个内存总线(Bus) 。此时其他CPU的所有内存访问请求将被阻塞,直到当前操作完成。
1 | sequenceDiagram |
特点
- 全局性锁定:锁定期间所有内存操作均被阻塞(包括非竞争数据)
- 性能开销大:原子操作串行化,多核性能急剧下降
- 兼容性强:早期x86处理器的唯一选择(如80486)
缓存锁(Cache Locking)—— MESI优化
核心:缓存一致性协议(Cache Coherence)
现代CPU通过MESI协议(Modified/Exclusive/Shared/Invalid)维护多核缓存一致性:
1 | graph LR |
缓存锁的触发条件(以Intel CPU为例)
当原子操作访问的数据满足:
- 对齐在缓存行内(通常64字节对齐)
- 目标地址未跨缓存行(Non-split Access)
- CPU支持缓存锁定技术(几乎所有现代处理器)
工作流程
1 | // 示例:两个线程在核 0 和核 1 上执行原子操作 |
避免缓存冲突
内存对齐避免总线锁
1 | // 错误:变量可能跨缓存行 |
检测方法:Linux下
perf c2c可检测缓存行冲突(False Sharing)
写竞争下的性能差异
当两个CPU核心频繁写同一缓存行时:
- 缓存锁场景:缓存行在
Modified↔Invalid状态间震荡,产生大量RFO消息 - 解决方案:伪共享隔离(False Sharing Elimination)
1 | // 多核计数器优化(每个核独占缓存行) |
特殊场景总线锁不可避免
1 | LOCK XCHG [mem], reg ; 显式LOCK前缀 |
对比
| 特征 | 总线锁 | 缓存锁 |
|---|---|---|
| 锁定范围 | 整个内存总线 | 单个缓存行 |
| 性能影响 | 全局停顿,性能损失严重 | 仅影响涉及特定缓存行的操作 |
| 触发条件 | LOCK前缀指令 |
内存对齐且未跨缓存行的原子操作 |
| 实现技术 | 硬件信号硬阻塞 | MESI缓存一致性协议 |
| 现代应用 | 仅作兜底(如跨缓存行操作) | 99%原子操作的默认实现 |
| 能耗 | 高(总线开关) | 低(仅缓存状态切换) |