CppCon 2017 学习:C++ atomics:from basic to advanced. What do they do?

发布于:2025-06-19 ⋅ 阅读:(12) ⋅ 点赞:(0)

你提到的是现代 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::atomiclock-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 数据结构

这段内容深入地解释了 为什么原子操作在多线程中是必要的,并揭示了即使是看起来简单的操作,比如 ++xx = 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 会变成 042424242,但实际上:

  • 如果 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 XADDCAS 等来实现并发安全

原子操作背后的硬件同步

你贴的图说明了:多个 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> 支持更灵活的类型

所有原子类型的操作都是真正“原子”的吗?

不一定!

  • 非锁式实现(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)) {}

解释:

  1. x0 = x.load() 读取当前值。
  2. 尝试将 xx0 改为 x0 + 1
  3. 如果失败,说明中间别的线程修改过 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::atomiclock-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_freeconstexpr),可以编译期判断。

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底层“锁” 硬件级独占访问,非真正锁但保证原子性

这部分内容进一步阐释了 strongweak 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_v
        old_v = tmp;
        return false;
    }
    Lock L;                 // 获得独占访问(锁)
    tmp = value;            // 再次读取,确认值未变(可能被其他线程修改了)
    if (tmp != old_v) {     // 如果变化了,失败并更新 old_v
        old_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 原子操作尝试将 headold_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)

  • 结合acquirerelease,禁止操作在屏障的两侧进行重排序。
  • 适合读-改-写操作,比如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_relaxedmemory_order_acquire,减少开销。
  • 成功时则需要更强的同步(如releaseacq_rel)保证内存顺序正确。

2. 默认的内存顺序

  • 如果没指定内存顺序,默认是 std::memory_order_seq_cst,这是最严格的保证。
  • 操作符重载版本(如x += 42;)也使用默认顺序,不能改变内存顺序。
  • 函数调用可以指定更弱的顺序来提升性能。

3. 为什么要改变内存顺序?

  • 性能:弱内存序减少不必要的内存屏障,提升执行效率。
  • 表达意图:帮助其他程序员理解代码同步的目的。
    两方面受众:
  • 计算机(CPU/硬件):执行效率。
  • 程序员:代码可读性和正确性。

4. 内存屏障的开销和平台差异

  • 内存屏障比原子操作本身可能更昂贵。
  • 不同平台屏障实现不同,性能影响也不同。
  • 例如在x86平台:
    • 所有加载操作本质上是acquire-load
    • 所有存储操作本质上是release-store
    • 读-改-写操作(如CAS)本质上是acq_rel
    • acq_relseq_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_acquirememory_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++原子操作非常有用但需要小心使用和理解它们的内存模型。

网站公告

今日签到

点亮在社区的每一天
去签到