CppCon 2017 学习:C++ atomics:from basic to advanced. What do they do?
你提到的是现代 C++ 并发编程中的一个关键主题:无锁(lock-free)与原子操作(atomics)。下面我将详细解释这一段的含义和背景,帮助你全面理解。
Atomics
是什么?
C++ 的 std::atomic<T>
是一种并发安全类型,它允许在多个线程之间无锁地共享数据,操作是原子性的(atomic),也就是说,不会中途被打断。
示例:
std::atomic<int> counter{0};
void increment() {counter.fetch_add(1, std::memory_order_relaxed);
}
这个 fetch_add
操作在线程之间是原子的,不需要加锁。
什么是 Lock-free?
Lock-free 是指多个线程访问共享数据时,至少有一个线程在有限时间内完成操作,而不会因为锁竞争被阻塞。
对比:
类型 | 说明 |
---|---|
Blocking (mutex) | 使用 std::mutex 等锁保护,线程可能被阻塞 |
Lock-free | 没有锁,线程不会阻塞,性能更高 |
Wait-free | 所有线程都能在有限步数内完成操作,最强保证 |
这是在说明:两种实现方式(锁与无锁)都正确、都能完成相同的功能,但性能不同。 |
- 一个程序使用了传统的
std::mutex
- 另一个使用了
std::atomic
实现的无锁甚至 wait-free 结构 - 没有使用像“忙等”(spinlock、while loop without yield)这种“伪并发”技巧
结论:无锁(特别是 wait-free)更快。
举个实际例子:计数器
使用 std::mutex
:
std::mutex mtx;
int counter = 0;
void increment() {std::lock_guard<std::mutex> lock(mtx);++counter;
}
- 优点:简单、安全
- 缺点:每次加锁/解锁有开销,多个线程可能争用同一个锁
使用 std::atomic
(lock-free):
std::atomic<int> counter{0};
void increment() {counter.fetch_add(1, std::memory_order_relaxed);
}
- 优点:更快,不需要上下文切换
- 缺点:逻辑更复杂,处理竞争条件更小心
wait-free 比 lock-free 更好吗?
是的。wait-free 是一种更强的保证:
类型 | 举例 | 特点 |
---|---|---|
blocking | std::mutex , std::lock_guard | 可被阻塞 |
lock-free | std::atomic + compare_exchange_* | 至少一个线程能进展 |
wait-free | 特殊的无锁算法(如环形队列) | 所有线程都能进展 |
但 wait-free 很难实现,一般只在非常高要求的系统中使用(例如实时系统、内核中断上下文等)。 |
总结
核心观点 | 说明 |
---|---|
Atomics 是 lock-free 编程的工具 | 原子操作可以避免锁,提高性能 |
lock-free 意味着“快” | 没有上下文切换,没有内核参与,线程能快速进展 |
wait-free 更进一步 | 所有线程都有进展保证(极高要求) |
C++ 提供 std::atomic | 帮你构建无锁结构(栈、队列、计数器等) |
这段内容是一个对比实验,目的是说明使用 std::atomic
(lock-free 编程)比使用 std::mutex
(加锁编程)通常更快的原因。
代码解读与对比
你给出了两个程序(A 和 B),它们的功能相同:多个线程各自遍历一个数组片段,把结果加总到全局变量 sum
。
程序 A(使用原子变量 std::atomic
)
std::atomic<unsigned long> sum;
void do_work(size_t N, unsigned long* a) {for (size_t i = 0; i < N; ++i)sum += a[i]; // 原子地加总
}
特点:
sum
是原子变量,所有线程可以并发地执行sum += a[i]
。- 编译器会将
sum += x
转换为原子的fetch_add
操作,底层是无锁的(lock-free)。 - 不需要锁,不涉及线程阻塞。
程序 B(使用互斥锁 std::mutex
)
unsigned long sum = 0;
std::mutex M;
void do_work(size_t N, unsigned long* a) {unsigned long s = 0;for (size_t i = 0; i < N; ++i)s += a[i]; // 局部累加std::lock_guard<std::mutex> L(M);sum += s; // 临界区,只执行一次加法
}
特点:
- 使用 局部变量
s
进行中间结果加总,只在最后加到全局sum
时加锁。 - 优化点在于:只加锁一次,不是每次
a[i]
都加锁。 - 加锁仍然会引发 线程阻塞和调度开销,尤其是多个线程同时尝试
sum += s
时。
性能比较
项目 | 程序 A(原子) | 程序 B(锁) |
---|---|---|
线程安全性 | 是 | 是 |
锁的使用 | 无锁 | 使用 mutex |
开销 | 极低 | 可能存在上下文切换 |
并发性能(多线程) | 高 | 中(临界区等待) |
可扩展性(核心数) | 更好 | 容易成为瓶颈 |
小结:为什么 A 更快?
- 程序 A 使用原子操作,每次写入都不会阻塞其他线程。
- 程序 B 虽然优化了加锁粒度(只锁一次),但仍然涉及 锁竞争。
- 原子操作比互斥锁的代价低很多:不涉及系统调用,不触发线程调度。
延伸建议
- 如果你只想避免争用、又希望有更高性能,可以考虑 分段累加 或 每线程一个 sum,最后合并(更快且不锁):
thread_local unsigned long local_sum = 0; for (...) local_sum += ...;
这段话深入解释了一个常见的误区:“lock-free 一定更快”。它的核心思想是:
“Lock-free/Wait-free” 不等于更快,真正决定性能的是算法本身。
逐句理解
“Is lock-free faster?”
“无锁就更快吗?”
这是一个经常被问但容易误解的问题。
Algorithm rules supreme
最终决定性能的是算法本身,而不是是否使用锁。
即使你使用了 lock-free 技术,如果算法本身效率低,性能依然差。
“Wait-free” has nothing to do with time
– Wait-free 指的是计算步骤的数量有限
– 并不代表这些步骤所需的时间短或更快
解释:
- “Wait-free” 的定义是:每个线程都能在有限的步骤内完成操作,不会被其他线程阻塞。
- 但“步骤”并不是时间单位,步骤可以长也可以短。
所以 wait-free ≠ 快
它是关于进度保障,而不是速度。
Atomic operations do not guarantee good performance
原子操作不会自动带来好性能。
举例:
- 在高并发下,即使是
std::atomic::fetch_add
,也可能造成总线争用(cache line bouncing)。 - 一个频繁写入的原子变量,性能可能比适当加锁还差。
There is no substitute for understanding what you’re doing
你必须真正理解你在做什么,否则再先进的工具也没用。
“This class is the next best thing”
本课的目标就是帮助你理解这些底层概念,让你正确使用原子、无锁等技术。
小结:几点理解重点
概念 | 说明 |
---|---|
Lock-free | 至少有一个线程在有限步骤内完成操作,不会被其他线程完全阻塞 |
Wait-free | 所有线程都能在有限步骤内完成操作 |
Atomic ≠ 快 | 原子操作防止数据竞争,但不能保证更快 |
算法优先 | 一个糟糕的算法,即使是无锁的也不会高效 |
如果你希望,我可以继续深入讲解: |
std::atomic
的原理与使用memory_order
的控制- 如何分析 cache line 竞争(false sharing)
- 或者给你一个例子来展示 “lock-free” 反而更慢的场景
“什么是原子操作(Atomic Operation)”。我们来一句句分解理解:
原子操作的定义
“Atomic operation is an operation that is guaranteed to execute as a single transaction”
原子操作 = 一个不可分割的操作
- 要么整个操作完成了
- 要么操作完全没发生
- 中间状态永远不会被其他线程看到
举个简单例子:
std::atomic<int> x = 0;
x.fetch_add(1);
这个 fetch_add(1)
是 原子的,意味着:
- 别的线程永远不会看到
x
是 “正在从 0 变到 1 的中间状态” - 只可能看到:
- 还没变之前:x == 0
- 或者已经变完了:x == 1
其他线程看到的是操作“前”或“后”的状态
这保证了线程间的数据一致性和安全性,避免“竞争条件”(race condition)。
底层如何实现原子操作?
底层通常用以下几种指令来实现原子性:
LOCK CMPXCHG
(x86)LL/SC
(Load Linked / Store Conditional,ARM)CAS
(Compare and Swap)
这些指令由 CPU 保证原子性(一次性完成,不可中断)。
原子 ≠ 只在硬件层
原子操作的概念也存在于 高层应用中,例如:
- 数据库事务:
BEGIN TRANSACTION ... COMMIT
- 整个事务要么完全执行成功,要么什么都没改(回滚)
即使不是用硬件实现,只要系统能保证操作不可分割,就算是“原子”的。
- 整个事务要么完全执行成功,要么什么都没改(回滚)
总结归纳
关键词 | 含义 |
---|---|
原子性 | 操作要么全部完成,要么完全不发生 |
线程可见性 | 线程看不到操作的中间状态 |
底层实现 | 通常由 CPU 的原子指令(如 CAS)支持 |
广义原子操作 | 如数据库事务等高层概念也算原子操作 |
如果你希望更深入,我可以解释: |
std::atomic
背后的原子指令种类(例如:加法、CAS)- 原子操作 vs 互斥锁的适用场景
- 如何用 C++ 实现 lock-free 数据结构
这段内容深入地解释了 为什么原子操作在多线程中是必要的,并揭示了即使是看起来简单的操作,比如 ++x
或 x = 42424242
,在多线程环境中也可能导致错误或未定义行为。
下面我们逐句解析,帮助你理解这些例子和背后的原理。
1. 自增操作 ++x
是非原子的
示例:
int x = 0;
Thread 1: ++x;
Thread 2: ++x;
看起来两个线程各自执行一次 ++x
,你也许希望 x == 2
。
但是,++x
实际上是下面这个“读取-修改-写入(read-modify-write)”的过程:
int tmp = x; // 读取
++tmp; // 修改
x = tmp; // 写入
问题:多个线程并发执行时
如果两个线程都在几乎同时读取 x == 0
,分别加一后写回,你最终会得到:
x = 1 // 而不是你期望的 2
这就是一个 data race(数据竞争),其结果是 未定义行为(undefined behavior)。
2. 更危险的例子:非原子读写
int x = 0;
Thread 1: x = 42424242;
Thread 2: int tmp = x;
你可能以为 tmp
会变成 0
或 42424242
,但实际上:
- 如果
x
是 32 位,但机器只支持 16 位对齐访问 - 或者
x
正好跨越了两个 cache line 或 page
那么 读线程可能读到一半新值,一半旧值!
例如: - 写线程在更新
x = 42424242
的时候被打断 - 读线程读取一半更新后的值,一半更新前的值
可能得到一个完全莫名其妙的数值,比如0x42420000
,甚至触发崩溃
补充说明
- 在 x86 架构 上,基本整型(int、long)通常是原子读写的(自然对齐并小于等于 CPU 字长)
- 但在 ARM、RISC-V 或对齐不当时,这种假设就不成立了
- 并且 读-改-写操作一定不是原子的,即使在 x86 上!
3. CPU 缓存层次结构引发的问题
这部分用图解释了缓存一致性可能导致的问题:
层级 | 描述 |
---|---|
L1 Cache | 每个核心私有,最快但最小 |
L2 Cache | 通常也是私有,但稍慢更大 |
L3 Cache | 多核心共享,主板级别 |
Main Memory | 全局共享,最慢 |
在多核系统中: |
- 每个线程可能运行在不同核心
- 各自拥有一份缓存副本
- 你以为改的是共享变量,实际上线程看到的是自己缓存中的旧值
- 导致写没同步过去,读没更新到
这种叫 cache incoherency(缓存不一致),更进一步是所谓的 false sharing(伪共享) 问题。
总结重点
概念 | 含义 |
---|---|
++x 是非原子的 | 实际是 read → modify → write,多个线程同时执行会竞争 |
数据竞争(data race) | 多个线程无同步地访问同一个变量,其中至少有写操作 |
非原子读写风险 | 在某些平台或对齐不当时,读写可出现部分更新 |
CPU 缓存一致性问题 | 不同核心线程可能读到旧值或写入互相覆盖,产生难以发现的 bug |
如何解决? | 使用 std::atomic<T> ,或者 std::mutex 保护访问 |
所以:如何正确访问共享数据?
用 C++ 提供的原子工具:
std::atomic<int> x = 0;
std::atomic<std::string*> p = nullptr;
或者使用锁:
std::mutex m;
std::lock_guard<std::mutex> guard(m);
// 安全访问共享数据
如果你想,我可以继续帮你:
- 讲解
std::atomic<T>
如何使用 - 分析
memory_order
的含义和应用 - 给你一个完整的线程安全计数器或队列例子
你贴的内容继续深入解释了 C++ 中 std::atomic
的关键特性,尤其是在多线程并发中的作用:
C++ 中原子操作的发展
C++03
: “什么是线程?”
- C++03 标准 没有内建的线程或并发支持,只能依赖平台 API(如 pthreads、Win32 threads)或第三方库(如 Boost.Thread)。
- 没有线程,就没有数据竞争、原子操作的语言层面支持。
C++11
: 引入 std::atomic
#include <atomic>
std::atomic<int> x(0); // 注意:不能写成 std::atomic<int> x = 0;
std::atomic<T>
是一种 模板类,提供对类型T
的 原子操作支持- 比如
++x;
、--x;
、x = new_val;
、x.load();
、x.store();
等操作变成线程安全的
++x
是原子的!
std::atomic<int> x(0);
当两个线程并发执行 ++x
:
Thread 1: ++x;
Thread 2: ++x;
现在的结果是:
x == 2 // 一定是!
因为 ++x
是一个 原子操作(atomic read-modify-write):
- 它不能被中断
- 不允许其他线程“半途读或写”
- 背后会使用硬件指令,如
LOCK XADD
、CAS
等来实现并发安全
原子操作背后的硬件同步
你贴的图说明了:多个 CPU 核心中缓存的数据也要通过原子同步机制来协调。
- 当一个线程对
x
做原子写时 - 它会强制刷新缓存,通知其它核心的缓存失效(cache coherence)
- 所以最终所有线程都能看到更新后的
x == 2
这通常是通过 CPU 的 MESI 协议(Modified, Exclusive, Shared, Invalid) 来实现的。
std::atomic
的关键问题解析
哪些 C++ 类型可以是原子的?
- 标准保证以下类型的原子性(前提是对齐合理):
int
,long
,bool
,pointer
,enum
,等等
- 对于自定义结构体或类(非 trivially copyable 类型),
std::atomic<T>
不一定合法- C++20 引入
std::atomic_ref<T>
支持更灵活的类型
- C++20 引入
所有原子类型的操作都是真正“原子”的吗?
不一定!
- 非锁式实现(lock-free):直接由 CPU 指令支持
- 锁式模拟(fallback to mutex):用互斥锁实现(较慢)
可以使用std::atomic<T>::is_lock_free()
判断:
std::atomic<int> x;
std::cout << x.is_lock_free(); // 通常为 true,但不保证
原子操作是否一定比锁快?
不一定,取决于:
情况 | 谁更快 |
---|---|
简单类型、轻量操作 | atomic 更快 |
多个变量一起更新 | mutex 更灵活(可组合) |
高并发且数据访问密集 | atomic 易受 cache 抖动影响 |
大结构或复杂初始化/析构操作 | mutex 更安全 |
“atomic” 就等同于 “lock-free” 吗?
不是!
- atomic 描述的是 语义保证(原子性)
- lock-free 是 实现策略:不依赖互斥量(如
LOCK
指令、CAS) - 有些
std::atomic
可能不是 lock-free,会退化为内部加锁实现
原子操作避免了锁,那么就一定不会等待?
也不是!
- 即使不加锁,多个线程争用同一个原子变量,也可能导致 忙等(busy-wait) 或 高并发冲突
- 在高竞争场景中,
++x
可能反复失败(如 CAS 失败重试),从而拖慢性能
总结:你现在应该理解了
点 | 说明 |
---|---|
std::atomic<T> 提供线程安全 | 适用于简单类型 |
++x 会自动变为原子操作 | 无需加锁 |
原子操作可能比 mutex 快 | 但不是绝对 |
原子 ≠ lock-free | 是语义,不是实现细节 |
lock-free ≠ wait-free | wait-free 是更强保证(每个线程在有限步骤内完成) |
如果你愿意,我可以继续带你深入以下内容: |
memory_order
的五种模式及其实际用途compare_exchange_*
的原理与用法(构建无锁算法的基础)- 如何编写一个 lock-free 队列或栈(比如 Michael-Scott queue)
什么类型可以被定义为原子类型?
在 C++ 中,std::atomic<T>
允许你对某个类型 T
的变量执行原子操作。但不是所有类型都可以使用 std::atomic<T>
。
▶ 哪些类型可以?
所有“可平凡复制”(trivially copyable)的类型都可以被原子化。
什么是“可平凡复制(trivially copyable)”?
一个类型如果满足以下条件,就是“可平凡复制”的:
- 占用连续的一段内存(没有隐藏的指针或虚函数表等)。
- 可以通过
memcpy
(内存拷贝)进行复制,也就是说复制就是直接拷贝它的所有字节。 - 没有虚函数或虚继承。
- 拥有平凡的构造函数、拷贝构造函数、析构函数(即编译器自动生成,且不抛出异常)。
- 没有用户自定义的拷贝构造、移动构造、析构函数。
示例(合法):
std::atomic<int> i; // OK,int 是 trivially copyable
std::atomic<double> x; // OK,double 也是
struct S { long x; long y; };
std::atomic<S> s; // OK,如果 S 没有特殊构造函数或虚函数
非法示例:
struct A {virtual void foo(); // 有虚函数,不可被原子化
};
std::atomic<A> a; // 错误
std::atomic<T>
可以进行哪些操作?
总是可以做的操作:
- 读/写
.load()
:原子读取.store()
:原子写入- 使用赋值运算符也是原子的
特别的原子操作:
- 比较并交换(CAS)
.compare_exchange_strong()
.compare_exchange_weak()
- 取值并操作(fetch-and-modify)
.fetch_add()
,.fetch_sub()
,.fetch_or()
,.fetch_and()
等
其他操作 —— 取决于 T 的类型:
操作 | 支持的类型 | |
---|---|---|
加法、减法 (+ , - ) | 整型、指针类型 | |
位操作 (& , ` | , ^`) | 整型 |
比较交换(CAS) | 所有支持的类型 | |
自定义类型(如 struct S) | 只支持 load/store,不支持加法、位运算等 |
小结:
类型 | 可以使用 std::atomic<T> 吗? | 说明 |
---|---|---|
int , double | 可以 | 原生类型,trivially copyable |
自定义 struct,无虚函数 | 可以 | 只要是平凡复制类型 |
有虚函数的类 | 不行 | 不是平凡复制类型 |
包含指针的 struct | 可以(取决于指针用法) | 只要整体是平凡复制的即可 |
这段内容出自 C++ 原子操作专题演讲,讲的是 std::atomic<T>
可以支持哪些操作,哪些操作看似能用却并非原子操作。我们来系统整理一下:
哪些操作在 std::atomic<int>
上是原子的?
std::atomic<int> x{0};
以下操作是 原子的,即线程安全的、不需要加锁的:
++x;
// 原子前置自增x++;
// 原子后置自增x += 1;
// 原子加法x |= 2;
// 原子按位或x &= 2;
// 原子按位与x ^= 2;
// 原子按位异或x -= 1;
// 原子减法x.load();
// 原子读取x.store(val);
// 原子写入x.exchange(val);
// 原子交换x.compare_exchange_strong(expected, desired);
// 原子条件交换
这些操作内部都有适当的内存顺序语义(默认为std::memory_order_seq_cst
),可以安全用于多线程。
哪些操作虽然语法合法,但不是原子的?
int y = x * 2; // 原子读取 x,但乘法是普通操作
x = y + 1; // 普通加法后写入 x —— 不是原子的
x = x + 1; // 读取 x,加 1,再写回 —— 两步,不是原子的
x = x * 2; // 类似,读取 x,乘 2,写回 —— 非原子
以上这些操作都 不是原子的,虽然它们能编译通过。问题是它们将原子变量拆成多个操作(读、算、写),会有竞态条件(race condition)风险。
哪些操作压根编译都过不了?
x *= 2; // std::atomic<int> 没有定义 operator *=
这类操作是直接非法的,编译器会报错,因为 C++ 只为某些操作定义了原子版本的重载。
总结:哪些运算符是为 std::atomic<int>
定义的?
已定义的原子操作符(重载)有:
operator=
operator++
(前/后缀)operator+=
operator–=
(减法)operator|=
,operator&=
,operator^=
(按位逻辑)
未定义的:operator*=
,/=
,%=
等算术复合运算- 所有乘除模运算:
x = x * 2
等
实用建议:
- 永远用
x.fetch_add(1)
或++x
代替x = x + 1
- 如果做复杂表达式,比如
x = x * 2
,考虑用compare_exchange
模式:
int expected = x.load();
int desired;
do {desired = expected * 2;
} while (!x.compare_exchange_weak(expected, desired));
这样写才能做到原子性。
中文理解总结:
std::atomic<T>
并不是所有看似“简单”的操作都是原子的,只有那些 C++ 标准库显式支持的操作符重载才是原子操作。你能写出来、不代表它是线程安全的!
C++ 原子操作 和 CAS(Compare-And-Swap)机制的核心原理和意义。我们来一步步理解这些内容,尤其是“为什么 CAS 很特别”。
CAS(Compare-And-Swap)有什么特别?
定义:
CAS 是一种原子操作,其作用是:
bool compare_exchange_strong(expected, desired)
如果
atomic_value == expected
,就将它设置为desired
,并返回true
;否则,把当前值写回expected
,返回false
。
CAS 的典型用途:自定义原子操作
C++ 中只有整数支持原子 ++x
,但对于 浮点数、自定义类型、复杂操作(如乘法),就需要用 CAS 来实现线程安全的逻辑。
举个例子:
std::atomic<int> x{0};
int x0 = x;
while (!x.compare_exchange_strong(x0, x0 + 1)) {}
解释:
x0 = x.load()
读取当前值。- 尝试将
x
从x0
改为x0 + 1
。 - 如果失败,说明中间别的线程修改过
x
,此时x0
会被 CAS 重写为当前值,再重试。
这就可以在没有++
操作符的类型上,实现等价于原子的 ++ 操作!
为什么 CAS 是 lock-free 算法的核心?
- 避免锁带来的性能问题(如阻塞、死锁)
- 可以构造复杂的、线程安全的更新逻辑
- 通用性强 —— 可应用于
int
,double
,struct
,只要可以比较和替换
扩展:CAS 还能实现哪些自定义操作?
// 原子乘以2(std::atomic<int> 不支持 *=)
int x0 = x.load();
while (!x.compare_exchange_strong(x0, x0 * 2)) {}
或者用于浮点数加法(std::atomic 不支持 ++):
std::atomic<double> d{1.0};
double old = d.load();
while (!d.compare_exchange_strong(old, old + 0.5)) {}
与 fetch_* 系列函数的区别?
x.fetch_add(1); // 原子加法,简单、有效
x.fetch_sub(1);
x.fetch_or(0x10);
这些函数:
- 只适用于整数类型
- 代码更短更清晰
- 但如果你需要更复杂的表达式(如乘法、浮点数加法、条件更新),就必须用 CAS
最后总结:理解常见误区
误区:
x = x + 1
在 std::atomic 上看似能写,其实不是原子的,包含多个步骤(读、算、写)
正确方式:用
fetch_add
、++x
,或对不支持的类型用 CAS 写逻辑!
一句话总结
CAS 是构建一切“非原生原子操作”的基石。它允许你在没有 lock 的前提下,实现任意自定义的线程安全修改,是现代并发程序设计的关键工具。
1. 原子操作到底快不快?
- 需要实际测量,不能一概而论。
- 性能高度依赖于 硬件架构、编译器实现 和 使用场景。
- 不同 CPU(如 Intel Broadwell-EX vs Haswell)、不同核数、不同缓存体系结构,对原子操作的支持和效率差别明显。
2. 原子操作 vs 非原子操作
- 当然,原子操作会比普通的非原子读写和计算慢,因为必须保证内存的可见性和一致性,需要 CPU 额外的缓存同步和总线锁定操作。
- 但对性能影响的大小,要看是否有多线程竞争。
- 单线程或无竞争情况下,原子操作性能接近普通操作。
3. 原子操作 vs 互斥锁(mutex)和自旋锁(spinlock)
- 原子操作往往 远快于锁(mutex)和自旋锁。
- 原子操作本质上是 CPU 提供的轻量级指令,如
lock cmpxchg
,而锁涉及上下文切换、内核态进入等重开销。 - 这种差异在多线程和高并发场景下尤为明显。
4. 多线程规模和性能变化
- 随着线程数增加,原子操作的吞吐量会下降,因为竞争导致缓存同步压力加大。
- 但是,锁的性能衰减更为严重,尤其是线程数远大于核心数时。
- 例子:
- 在 120 核心 Broadwell-EX 处理器上,原子操作保持较高的操作速率。
- 在 4 核 Haswell 上,虽然性能也下降,但依旧明显快于锁。
5. 对开发者的建议
- 不要仅凭直觉判断性能,要依赖测量。
- 比较原子操作与其他线程安全机制的性能,而不是与普通非线程安全操作比较。
- 在设计时,如果能用原子操作替代锁,通常能获得更好的性能和更低延迟。
6. 最后提醒:
- CAS(Compare-And-Swap)作为实现复杂原子操作(乘法、浮点数累加、自定义更新)的核心机制,是许多 lock-free 算法的基础。
- 尽管 CAS 会有失败重试开销,但整体还是比锁更轻量。
总结一条清晰的线:
原子操作是 CPU 提供的高效同步原语,通常比锁更快,但性能表现依赖于硬件、线程竞争程度和使用方式。只有实际测量,才能得出合理结论。
std::atomic 和 lock-free 的关系,以及实际使用中可能遇到的“隐藏秘密”和注意点的讲解,帮你梳理理解:
1. std::atomic
不等于 lock-free
std::atomic<T>
是一个模板类,它保证了对变量的原子操作,但不保证它本身就是 lock-free 的。- lock-free 意味着操作不需要锁,也不阻塞线程,底层由硬件原子指令支持。
- 某些类型的
std::atomic<T>
,因为大小、对齐或复杂性,编译器/硬件可能用锁实现它的原子操作,性能会差很多。
2. 判断是否 lock-free
- 可以用
std::atomic<T>::is_lock_free()
方法,返回 bool 值,表示当前平台/环境下此类型的原子操作是否 lock-free。 - 这是运行时检测,因为同一程序在不同硬件/编译设置下可能不同。
- C++17 增加了
std::atomic<T>::is_always_lock_free
(constexpr
),可以编译期判断。
3. 实例说明:
不同类型的大小和对齐会影响是否 lock-free。
long x; // 一般 lock-free,因为长整型通常有对应硬件指令
struct A { long x; }; // 通常 lock-free,和上面类似
struct B { long x; long y; }; // 16 字节,部分 CPU 支持 16 字节原子操作
struct C { long x; int y; }; // 12 字节,通常不是 lock-free
struct D { int x; int y; int z; }; // 12 字节,也通常非 lock-free
struct E { long x; long y; long z; }; // 24 字节,绝大多数平台非 lock-free
- 16 字节通常是特殊情况,比如 x86-64 在部分 CPU 支持 16 字节原子操作(如
cmpxchg16b
指令)。 - 超过 CPU 原生支持大小的类型,往往用内部锁实现原子。
4. 对齐和填充很关键
- 内存对齐直接影响是否能 lock-free。
- 非对齐的数据结构会导致原子操作不能用硬件指令实现。
5. 线程是否等待(竞争)?
- 多线程对同一个原子变量操作时(如
++x
),线程间会有内存总线争用和同步,可能导致性能瓶颈。 - 不同线程访问不同变量(例如
x[0]
和x[1]
)时,不会互相等待,因为操作独立,减少了竞争。
总结
特性 | 说明 |
---|---|
std::atomic<T> | 保证原子性,但不保证 lock-free |
is_lock_free() | 运行时判断当前实现是否 lock-free |
is_always_lock_free | 编译期判断,C++17 新增 |
类型大小和对齐 | 影响是否能用硬件指令实现 lock-free |
多线程同变量竞争 | 会导致等待和性能下降 |
多线程不同变量访问 | 不会等待,性能更好 |
“原子操作是否会相互等待(阻塞)”,其实涉及对底层硬件缓存一致性协议和并发执行的深入理解:
1. 原子操作之间会“等待”吗?
- 答案是:会的,但具体情况看操作类型和数据访问冲突程度。
2. 为什么会等待?
- 原子操作必须保证对同一内存地址的操作是**线性一致(linearizable)**的。
- 多个 CPU 核心操作共享的变量时,必须协调缓存一致性,只允许一个核心持有该缓存行的“独占访问权”,其他核心要等待。
- 这种缓存行“抢占”导致了硬件级的同步与等待。
3. 共享变量 vs 非共享变量
假设有数组 x[]
,两个线程分别执行:
- 线程1:
++x[0];
- 线程2:
++x[1];
- 如果
x[0]
和x[1]
在不同缓存行,线程操作不会互相等待,因为各自缓存行的独占权不会冲突。 - 如果
x[0]
和x[1]
在同一缓存行(通常 64 字节),虽然是不同元素,但硬件缓存一致性会导致竞争,表现为伪共享(false sharing),线程间会有等待。
4. 缓存层级示意
- CPU 核心有独立的寄存器和 L1 缓存。
- 共享变量加载到每个核的 L1/L2 缓存,写操作需要获取缓存行独占权,其他核心需要等待,写操作之间有同步延迟。
- L3 缓存和主内存负责更大范围的缓存一致性,但速度较慢。
5. 读操作和写操作的区别
- 读操作可以并发进行,扩展性好,基本不等待。
- 写操作(尤其是对同一个缓存行)会导致核心之间的等待,因为必须保持缓存一致性。
6. wait-free 的含义
- “wait-free”强调的是算法步骤数有限且有界,而不是操作的实际耗时。
- 实际上,硬件和缓存协议会导致原子写操作等待,但算法设计保证了不会无限阻塞、活锁或死锁。
7. 实际性能表现(来自图表)
- 原子操作性能随线程数增长会下降,特别是访问同一个变量时。
- 与锁相比,原子操作通常仍然更快,且更具扩展性。
- 多线程访问不同变量时性能几乎线性扩展,等待很少。
总结表
情况 | 是否等待 | 说明 |
---|---|---|
多线程写同一个原子变量 | 会等待 | 缓存行独占权竞争导致等待 |
多线程读同一个原子变量 | 不等待 | 读操作扩展性好 |
多线程写不同缓存行变量 | 几乎不等待 | 独立缓存行,减少冲突 |
多线程写同缓存行不同变量 | 可能等待 | 伪共享导致缓存行竞争 |
关于 atomic 操作之间的等待机制 和 CAS(compare-and-swap)强弱版本的区别,我帮你总结和补充理解:
1. Atomic 操作是否等待?
- 原子操作必须等待缓存行的独占访问权,这是多线程共享数据而不产生竞态条件的代价。
- 即使是访问同一个缓存行不同变量,也会产生伪共享,导致性能下降,因为缓存行只能被一个核心独占写访问。
- 避免伪共享的办法是将各线程频繁写入的数据分开,确保它们位于不同缓存行甚至不同内存页(尤其是 NUMA 架构上)。
- 这说明原子操作不是无等待的,而是受限于硬件缓存一致性协议。
2. Strong 和 Weak CAS
compare_exchange_strong
:- 只有当变量当前值等于预期值时,才成功交换新值,否则失败并更新预期值。
- 失败时不会“虚假失败”,返回 false 说明值不匹配。
compare_exchange_weak
:- 语义上和强版本相同,但允许“虚假失败”(spuriously fail)。
- 即使变量值等于预期,也可能返回 false。
- 这种失败是允许的,是为了提高效率(减少处理器上的忙等待)。
- 虚假失败时,
old_x
保持原值不变。
- 典型用法中,weak 版本通常在自旋循环里使用,因为它可能失败需要重试。
3. CAS 背后的“锁”概念
- 虽然叫“锁”,但底层实现并非真正的互斥锁。
- 通常是 CPU 提供的硬件级独占访问机制,例如通过缓存行锁定、总线锁等。
- 伪代码示例:
bool compare_exchange_strong(T& old_v, T new_v) {// 硬件层面的独占访问T tmp = value;if (tmp != old_v) {old_v = tmp;return false;}value = new_v;return true;
}
- 这里的“Lock L” 是抽象,表示硬件原子操作所做的独占访问。
总结
特点 | 说明 |
---|---|
atomic 操作等待机制 | 等待缓存行独占访问权,避免竞态但可能产生等待 |
伪共享影响性能 | 不同线程写同缓存行不同变量时,仍会相互等待 |
strong CAS | 成功时交换,失败时更新预期值,无虚假失败 |
weak CAS | 允许虚假失败,适合自旋重试 |
CAS底层“锁” | 硬件级独占访问,非真正锁但保证原子性 |
这部分内容进一步阐释了 strong 和 weak compare-and-swap (CAS) 的工作机制和设计哲学,以及原子变量在实际并发结构中的应用。让我帮你梳理一下:
Strong 和 Weak CAS 的区别(结合伪代码)
Strong CAS
bool compare_exchange_strong(T& old_v, T new_v) {T tmp = value; // 先读取当前值(读取快)if (tmp != old_v) { // 值不匹配,直接失败,更新 old_vold_v = tmp;return false;}Lock L; // 获得独占访问(锁)tmp = value; // 再次读取,确认值未变(可能被其他线程修改了)if (tmp != old_v) { // 如果变化了,失败并更新 old_vold_v = tmp;return false;}value = new_v; // 设置新值return true;
}
- **双重检查(Double-checked locking)**模式回归了!第一次读是快速路径,只有匹配才会进入真正的“锁”阶段。
- 写操作(独占访问)比较重,需要“获得锁”。
- 这是强 CAS,保证无虚假失败。
Weak CAS
bool compare_exchange_weak(T& old_v, T new_v) {T tmp = value; // 快速读取if (tmp != old_v) {old_v = tmp;return false;}TimedLock L; // 尝试获得锁,可能失败if (!L.locked()) // 如果没获得锁,直接失败(虚假失败)return false;tmp = value; // 再次读取if (tmp != old_v) {old_v = tmp;return false;}value = new_v; // 设置新值return true;
}
- 尝试加锁但允许失败,如果独占访问“太难”获得,直接让出机会,避免等待。
- 所以 weak CAS 会有更多虚假失败,适合自旋重试使用。
Atomic 变量通常配合非原子数据一起用
- 比如 atomic queue 里,atomic 变量往往只是索引或计数器:
int q[N];
std::atomic<size_t> front;
void push(int x) {size_t my_slot = front.fetch_add(1); // 原子递增,获得唯一插入槽位q[my_slot] = x; // 非原子数组写入对应位置
}
- atomic 变量保证索引的唯一性和正确顺序,而具体数据本身可以是非原子的。
- 这种设计避免对所有数据加锁,提高性能。
关键点总结
项目 | 说明 |
---|---|
Strong CAS | 无虚假失败,写操作带锁,读操作快,典型双重检查锁模式 |
Weak CAS | 允许虚假失败,尝试非阻塞锁,适合循环重试 |
原子变量实际作用 | 多作为索引/标志,结合非原子数据形成高效线程安全数据结构 |
双重检查锁模式 | 读取两次,先快后慢,减少独占访问次数,提高性能 |
这部分内容继续讲述了 原子变量(std::atomic
)作为访问和同步共享内存的“网关”,以及如何利用它们构建线程安全的数据结构,同时介绍了与原子操作紧密相关的 内存屏障(Memory Barriers) 的作用。以下是详细理解和总结:
Atomic List 示例:无锁链表的头插入操作
struct node {int value;node* next;
};
std::atomic<node*> head;
void push_front(int x) {node* new_n = new node;new_n->value = x;node* old_h = head.load();do {new_n->next = old_h;} while (!head.compare_exchange_strong(old_h, new_n));
}
- 这里
head
是一个 原子指针,指向非原子链表节点。 - 插入新节点时,先读当前头节点指针
old_h
。 - 新节点的
next
指向旧头old_h
。 - 用 CAS 原子操作尝试将
head
从old_h
改为new_n
。 - 如果
head
在此期间被其他线程改了(即old_h
不再有效),CAS 失败,更新old_h
,重试。 - 这样保证了链表头部的插入操作在多线程中是安全且无锁的。
Atomic 变量是访问共享内存的“网关”
- 原子变量本身是指向普通内存的指针,它们确保对指针变量的操作是原子的,防止竞争条件。
- 通过原子操作可以实现:
- 独占访问(exclusive access):线程“锁定”某块内存,准备数据。
- 释放访问(release access):线程把准备好的数据“发布”给其他线程可见。
- 但实际数据存储在非原子内存中,原子变量仅控制访问的同步和顺序。
为什么需要内存屏障(Memory Barriers)
- CPU 和编译器为了优化,会对读写指令做重新排序。
- 这可能导致一个线程修改的内存对其他线程不可见或者乱序,引发竞态。
- 内存屏障确保内存操作的顺序性和可见性,即:
- 在屏障之前的写操作必须先完成。
- 在屏障之后的读操作不能提前执行。
- 屏障是跨多个 CPU 核心的全局同步机制,硬件层面实现,CPU 指令集提供支持。
内存屏障作用示意
操作顺序 | 发生内存顺序 | 可能结果(无屏障) | 有屏障保证顺序和可见性 |
---|---|---|---|
写数据1 | 写缓存在 CPU Cache | 其他 CPU 可能看不到或乱序 | 其他 CPU 按顺序看到写操作 |
写数据2 | 可能提前到数据1前 | 数据2先被看到导致异常 | 保证数据1先被看到,保证同步正确 |
总结
内容 | 说明 |
---|---|
原子变量是指针 | 指向普通内存,操作本身是原子操作,保证指针更新不会出现竞争 |
通过 CAS 实现无锁结构 | 例如链表头插入,CAS 反复尝试更新指针,保证线程安全 |
内存屏障是关键 | 确保不同 CPU 核心对内存的访问顺序一致,防止乱序和不可见,保障同步正确性 |
内存屏障由硬件实现 | 程序员通过原子操作和屏障指令(或编译器内建)使用,隐藏硬件细节 |
这段内容介绍了C++中**内存屏障(memory barriers)**的演变,C++11中标准化的内存顺序模型,以及几种常见的内存顺序语义的含义。以下是要点和理解:
1. C++03与C++11的内存屏障
- C++03 没有标准的、可移植的内存屏障接口。
- C++11 引入了标准内存模型,定义了内存顺序(memory order)和屏障的概念,统一了不同平台上的行为。
2. 内存屏障与内存顺序
- C++11的内存屏障是原子操作的参数,用来指定操作的内存顺序。
- 内存屏障保证了内存操作的执行顺序,防止编译器和CPU重排序带来的不可预期结果。
- 内存顺序是原子操作的修饰符,决定了操作与其他内存操作之间的相对顺序。
3. 主要的内存顺序语义示例
std::atomic<int> x;
x.store(1, std::memory_order_release);
memory_order_release
:释放语义,保证之前对内存的写操作在此操作之前完成,向其他线程“发布”更新。
4. memory_order_relaxed
(无屏障)
- 例子:
x.fetch_add(1, std::memory_order_relaxed);
- 含义:操作是原子的,但不保证任何顺序或同步,也就是说:
- 程序中写操作的顺序仍是代码顺序(程序顺序)
- 但对其他线程来说,内存操作可能乱序出现,不保证可见顺序
- 适合不关心操作顺序、只关心原子性的场景。
5. Acquire屏障(memory_order_acquire
)
- Acquire屏障确保:
- 屏障之后的所有读写操作都不会被重新排序到屏障之前。
- 只影响调用该屏障的线程的操作顺序。
- 保证该线程在屏障之后看到前一个线程通过release发布的所有修改。
换句话说,acquire屏障“获得”了之前release屏障“发布”的更新,使得后续操作能正确看到同步状态。
总结对比
内存顺序 | 作用描述 | 适用场景 |
---|---|---|
memory_order_relaxed | 原子操作,但无顺序保证 | 只需要原子性,不关心顺序或同步的操作 |
memory_order_acquire | 读屏障,保证屏障后操作不会提前执行 | 读操作同步,从release同步点开始访问数据 |
memory_order_release | 写屏障,保证屏障前操作都完成后才写入 | 写操作同步,发布数据供其他线程读取 |
这段内容讲了C++中释放屏障(release barrier)、获取释放协议(acquire-release protocol)、以及它们在锁(locks)中的应用,最后还提到了**双向屏障(acquire-release)和顺序一致性(seq_cst)**的区别。帮你总结一下:
1. Release屏障 (memory_order_release
)
- 作用:保证程序中在释放屏障之前的所有内存操作(读写),对其他线程来说必须先于释放操作可见。
- 禁止重排序:不能将屏障之前的读写操作,重排序到屏障之后。
- 只针对调用线程的顺序保证。
比如:
x.store(1, std::memory_order_release);
表示在这条存储操作之前的所有操作完成后,再执行这条存储。
2. Acquire-Release协议(常用于线程同步)
- 线程1:对
x
执行release
存储操作,并且在此之前完成对共享数据的写入。 - 线程2:对同一个
x
执行acquire
加载操作,获得同步点。 - 结果:线程2保证看到线程1在release之前所做的所有写入。
这是经典的发布-获取同步模型,比如生产者发布数据,消费者读取数据时保证数据是最新的。
3. 锁实现中的内存屏障示例
伪代码:
std::atomic<int> l(0);
// 线程1
l.store(1, std::memory_order_acquire);
++x;
l.store(0, std::memory_order_release);
// 或者自旋锁版本
while (l.exchange(1, std::memory_order_acquire));
++x;
l.store(0, std::memory_order_release);
acquire
保证进入临界区时看到之前线程释放的内存状态。release
保证退出临界区时所有写入对其他线程可见。
4. 双向屏障 (memory_order_acq_rel
)
- 结合
acquire
和release
,禁止操作在屏障的两侧进行重排序。 - 适合读-改-写操作,比如
compare_exchange
。 - 但需要所有线程针对同一个原子变量使用,否则不保证正确顺序。
5. 顺序一致性 (memory_order_seq_cst
)
- 是最严格的内存顺序保证。
- 所有原子操作全局有单一的顺序,跨线程保证所有原子操作的顺序一致。
- 不需要所有线程用同一个变量才能保证顺序。
- 性能可能较低,但编程模型最简单。
简单图示(Acquire-Release)
线程1 (Release) 线程2 (Acquire)a b c x = load_acquire()x = store_release() 读取后访问 a, b 修改的共享数据
线程2读到线程1发布的x
后,保证a,b,c
这些操作都对线程2可见。
为什么 CAS (Compare-And-Swap) 有两个不同的内存顺序参数(成功和失败),并探讨了 内存顺序选择背后的性能和意图,帮你总结重点:
1. 为什么CAS有两个内存顺序参数?
bool compare_exchange_strong(T& old_v, T new_v, memory_order on_success, memory_order on_failure);
- on_success:当CAS成功(成功写入新值)时使用的内存顺序。
- on_failure:当CAS失败(值不匹配,未写入)时使用的内存顺序。
原因: - 读取(失败路径)通常比写入(成功路径)快,失败时只需做一次读取,不需要完全的同步开销。
- 失败时可以使用较弱的内存序,例如
memory_order_relaxed
或memory_order_acquire
,减少开销。 - 成功时则需要更强的同步(如
release
或acq_rel
)保证内存顺序正确。
2. 默认的内存顺序
- 如果没指定内存顺序,默认是
std::memory_order_seq_cst
,这是最严格的保证。 - 操作符重载版本(如
x += 42;
)也使用默认顺序,不能改变内存顺序。 - 函数调用可以指定更弱的顺序来提升性能。
3. 为什么要改变内存顺序?
- 性能:弱内存序减少不必要的内存屏障,提升执行效率。
- 表达意图:帮助其他程序员理解代码同步的目的。
两方面受众: - 计算机(CPU/硬件):执行效率。
- 程序员:代码可读性和正确性。
4. 内存屏障的开销和平台差异
- 内存屏障比原子操作本身可能更昂贵。
- 不同平台屏障实现不同,性能影响也不同。
- 例如在x86平台:
- 所有加载操作本质上是
acquire-load
。 - 所有存储操作本质上是
release-store
。 - 读-改-写操作(如CAS)本质上是
acq_rel
。 acq_rel
和seq_cst
在x86上表现类似。
- 所有加载操作本质上是
5. 内存顺序传达程序员意图的示例
memory_order_relaxed
:仅计数器,操作之间无依赖,比如计数累加。memory_order_release
:写操作发布数据给其他线程,确保前面的写完成后才发布。- 不显式说明:代码难读难懂,容易产生隐晦的错误。
总结
- CAS操作需要两个内存顺序参数,是为了优化失败路径的性能,成功路径保证正确同步。
- 使用合适的内存顺序,既能提升性能,也能表达代码的同步意图。
- 理解内存顺序对写出高效且正确的无锁代码非常重要。
顺序一致性(Sequential Consistency,seq_cst) 在 C++ 原子操作中的作用、性能影响,以及何时选择 std::atomic
。
1. 顺序一致性(seq_cst)让程序更慢
- 顺序一致性是最强的内存顺序保证,它确保所有线程都看到原子操作的修改顺序是一致的。
- 这个保证通常会带来较高的性能开销,因为硬件和编译器不能轻易对操作重排序。
- 但顺序一致性让程序更容易理解,调试也更简单。
2. 并非所有操作都需要 seq_cst
- 不是每个原子操作都必须用
memory_order_seq_cst
。 - 例如,锁的实现只需用
memory_order_acquire
和memory_order_release
来保证正确的同步即可。 - 使用更弱的内存顺序表达程序意图,既提升性能又避免过度同步。
3. C++ 标准的一个缺陷
- 你写了:
class C { std::atomic<size_t> N; T* p; … }; C::~C() { cleanup(p, N.load(std::memory_order_relaxed)); }
- 实际上,
N
在对象析构时可能被其他线程访问,存在潜在危险。 - 你希望标准允许一种“非原子加载”(
load_nonatomic()
)来避免恐慌,或者更明确表达你不关心同步的意图。
4. 何时使用 std::atomic
- 高性能无锁并发数据结构,且通过性能测试证明。
- 一些锁难以实现或开销大的数据结构,比如无锁链表、无锁树。
- 避免锁的缺点(死锁、优先级反转、延迟等)。
- 当同步仅依赖简单的原子加载和存储时。
总结
- 顺序一致性好理解但有性能代价。
- 更细粒度的内存顺序选择是实现高性能并发程序的关键。
- C++原子操作非常有用但需要小心使用和理解它们的内存模型。