这是一篇我在学习操作系统中多核架构与并发执行相关内容时整理的学习笔记。 本文会持续更新,目前已完成前两个基础部分。
学习提纲总览
在开始之前,我先整理了一份学习提纲,用来约束学习范围和深度,避免发散:
- 起点:多核相关的常见错误直觉
- 真实硬件视角:程序在多核系统中的执行环境
- 线程是如何被调度到不同 CPU Core 上的
- Cache hierarchy:L1 / L2 / L3 的基本作用
- 缓存一致性:多份副本如何保持一致
- CPU 指令乱序执行
- C++ 原子变量与内存屏障
- 互斥锁与条件变量的底层机制(Linux / glibc)
- 进程与线程在操作系统内核中的实现
- 回到实践:多核系统下并发程序的基本设计原则
本文目前展开的是 1、2 两部分。
① 起点:拆掉多核的错误直觉(认知纠偏)
这一部分只做一件事:
把我一开始对多核系统的直觉模型拆掉,换成后面能继续学习的正确起点。
一、❌ 多核 = 多个 CPU + 共享内存
为什么会这么想(而且很合理)
如果学习路径来自:
- 单核时代的 C 程序
- OS 课里的“共享内存模型”
- pthread / std::thread 的接口设计
- C/C++ 的抽象语义
那么很容易形成这个心智模型:
有一块内存
多个 CPU / 线程
都在读写同一份数据
代码层面看起来确实如此:
int x = 0;
thread t1([&]{ x++; });
thread t2([&]{ x++; });
从语言层看,x 只有一个。
这个想法在什么时候“看起来没问题”
- 单核 CPU
- 多核但线程交替运行
- 数据访问不频繁
- 或者你加了大锁(mutex)
此时 cache 行为被隐藏,一致性成本被摊薄,错误不容易暴露,于是会误以为“共享内存是天然成立的”。
多核现实:所谓“共享内存”只是硬件努力维持的假象
撕掉语言抽象,看硬件:
- CPU 核心不直接操作主内存
- 核心主要和 cache 打交道
- 每个核心都有自己的 cache(至少 L1)
于是出现一个关键事实:
同一个内存地址,在不同核心上可能同时存在多个副本(下面讲一下副本是什么)
例如:
主内存:x = 1
Core0 cache:x = 0
Core1 cache:x = 0
这些值在某一时刻同时为真。
副本是什么?内存中是如何存储 x = 1 这种数据的?
结论先行:
x = 1在内存里并不是“一个变量对象”,
而是“某个地址上的若干字节被写成表示 1 的二进制”。
例如(假设 int 4 字节,小端序,地址 0x1000):
地址 内容(hex)
0x1000 01
0x1001 00
0x1002 00
0x1003 00
内存只认识:地址 + 字节。
所谓“副本”,指的是:
某个 Core 的 cache 中,保存着「包含该地址的那条 cache line」的数据拷贝
cache line 同时带有它来自哪个地址范围的 tag。
如果 cache line 是 64B,那么复制的是 [0x1000 ~ 0x103F] 这一整段字节,而不是“变量”。
什么叫“某一时刻同时为真”
定义:
在同一个真实的物理时间点上,
不同 CPU 核心对同一内存地址的观测结果可能不同,
但它们在各自视角里都是“正确的”。
例如:
主内存:x = 1
Core A cache:x = 2
Core B cache:x = 1
这不是 bug,而是多核 + cache 副本在一致性传播完成前的正常中间状态。
正确替换的认知
❌ 多核 = 多个 CPU + 共享内存
✅ 多核 = 多个核心 + 各自的 cache 副本 + 一致性协议
共享不是自然状态,而是高成本协作出来的结果。
二、❌ 内存是统一、实时、透明的
你为什么会这么想
写 C++ 时很容易默认:
x = 1;
y = x;
- 写入立刻可见
- 读到的一定是最新值
这个假设为什么能长期存活
因为三层东西在兜底:
- 编译器:尽量不破坏单线程直觉
- CPU:缓存一致性机制
- OS:调度与同步原语
但它们只是“尽量维护”,而不是保证实时透明。
多核下真实发生的事情
- 写操作 ≠ 马上写到内存
- 读操作 ≠ 马上从内存读
- 可见性是延迟的、条件性的
经典例子:
// Core A
x = 1;
// Core B
while (x == 0) {}
这段代码在多核下不保证一定能结束。
既然有延时,这段代码是如何被执行的?
结论:
没有同步时,这段代码从一开始就不具备正确的并发语义。
原因是:
- Core B 可能一直从自己的 cache 副本读
x - Core A 的写可能暂时只存在于自己的 cache
- 一致性传播是延迟、按需的
“有时能跑通”只是碰巧触发了刷新,不是被保证的行为。
正确替换的认知
❌ 内存统一、实时、透明
✅ 内存是分布式的、有延迟的,需要显式同步来建立可见性
三、❌ 多线程 = 性能线性提升
现实中:
线程数 ↑
一致性 / 协调成本 ↑↑
多线程性能 ≈ 算力收益 − 协调成本
四、❌ atomic ≈ 轻量锁
atomic 的核心承诺不是“快”,而是:
全系统范围内的可见性与顺序保证
为了做到这一点,硬件必须:
-
独占 cache line
-
阻止其他核心并发访问
-
发出一致性信号
在高争用场景下,atomic 可能比 mutex 还慢,因为代价在于全系统协调。
五、模型总结
多核系统不是多个执行单元共享内存,
而是多个拥有私有 cache 的执行体,
通过昂贵的一致性协议,维持一个“看起来共享”的内存世界。
② 真实硬件视角:执行环境的真实模型
一、什么叫“执行环境的真实模型”
一句话定义:
执行环境的真实模型 = 程序运行时真正参与执行和存储的硬件实体,以及它们之间真实的数据流动方式。
二、Core:真正跑代码的地方
Core 是一个可以独立:
- 取指
- 执行
- 写结果
的物理执行体。
每个 Core 至少包含:
- 寄存器
- 执行流水线
- 私有 cache 接口
- 自己的执行时序
不同 Core 不共享寄存器,也不共享执行状态。
寄存器是什么
寄存器是 CPU 内部的高速存储单元,用来存放当前最关键的数据和中间结果。 你可以把它理解为CPU的“随身小笔记本”
执行流水线是什么
执行流水线是把一条指令拆成多个阶段,并让多条指令在不同阶段并行执行的机制,用来提高吞吐量。
一条指令在 Core 里,不是“一下子执行完”,而是会经过一串阶段,比如:
-
取指:从内存 / cache 里把指令取出来
-
译码:看这条指令要干嘛
-
执行:做计算、地址计算等
-
访存:需要的话读/写数据
-
写回:把结果写回寄存器
流水线的关键点是:
当第 1 条指令在“执行”时,
第 2 条指令已经在“译码”,
第 3 条指令已经在“取指”。
什么是执行时序
执行时序指的是 CPU 内部真实发生的操作顺序,它不一定等同于代码顺序。
执行时序是 CPU 为了性能,在不影响单线程结果的前提下,对操作顺序做的真实安排。
三、Cache hierarchy:真正的工作内存系统
Core
↓
L1 cache(私有,极快)
↓
L2 cache(私有或半私有)
↓
L3 cache(可能共享)
↓
主内存(慢、远)
越靠近 Core → 越快 → 越私有
越远离 Core → 越慢 → 越共享
四、主内存:统一,但不是实时工作区
主内存更像是:
最终一致的、慢速的、后台存储层。
它的特点:
-
地址是统一的
-
但访问频率低
-
延迟高
-
很少被 Core 直接操作
所以
-
大多数读写发生在 cache
-
内存更多是“同步与回退的参考点”
五、Cache line:最小操作单位
Cache 不认识变量,只认识 cache line(通常 64B)。
你可以认为: cache line 是 CPU 世界里的“基本货币单位”
- 加载:按 cache line
- 失效:按 cache line
- 一致性:按 cache line
所以:
程序语义操作的是“变量”,
硬件实际操作的是“cache line”。
Core 是如何处理 cache line 的
大致流程:
- Core 发出地址
- 查本地 cache
- 未命中则加载整条 cache line
- 在 cache line 上读写
- 必要时触发一致性
- 未来某个时刻写回内存
Core 的世界里没有“变量读写”,只有 cache line 的加载、使用和协调。
六、真实执行图景总结
多个 Core
↕
私有 L1 / L2 cache
↕
共享或半共享 L3
↕
统一但遥远的主内存
数据主要在 cache 中流动,cache line 是一切同步与冲突的基本单位。
③ 线程是如何“跑”到不同 CPU Core 上的(调度视角)
一、先校准一个关键认知:线程不“属于”任何 core
在真正理解调度之前,必须先拆掉一个常见但隐蔽的假设:
❌ 线程一旦开始运行,就“绑定”在某个 CPU core 上
✅ 线程只是在某个时间段内,被调度到某个 core 上执行
也就是说:
- 线程 ≠ 执行单元
- 线程 ≠ CPU 资源
- 线程 = 一种可被移动的执行上下文
Core 是“干活的人”,
线程是“可以被换来换去的工作内容”。
二、从硬件视角往上一步:Core 在等谁给它“活干”
结合已经理解的执行模型:
-
Core 不会主动找线程
-
Core 只会做一件事:
“给我一段指令流,我就执行”
于是问题自然变成:
谁来决定:
现在这个 core,应该执行哪个线程的指令?
答案就是:操作系统调度器(scheduler)。
三、调度器到底在“调度”什么?
调度器调度的不是 CPU,
而是“哪个线程,下一刻可以占用哪个 core”。
在多核系统中,这意味着:
- 任意时刻:
- 每个 core 只能执行一个线程
- 调度器的职责是:
- 从“可运行线程集合”中
- 给每个 core 选一个当前执行者
四、线程在调度器眼里是什么状态?
对调度器来说,线程只关心三种核心状态:
-
Running
- 当前正在某个 core 上执行
-
Runnable(Ready)
- 逻辑上可以运行
- 但暂时没拿到 core(在等)
-
Blocked
- 当前不能运行(等锁、等 IO、等条件)
这里有一个很重要但容易忽略的事实:
大多数线程,大多数时间,并不在 Running 状态。
这也是为什么“线程数 > core 数”在 OS 中是常态。
五、一个 core 的真实运行循环(调度视角)
从 core 的角度看,真实世界更像这样:
我正在执行线程 A
↓
A 用完时间片 / 阻塞 / 被抢占
↓
保存 A 的执行上下文
↓
调度器为我选一个新的 runnable 线程 B
↓
恢复 B 的上下文
↓
我开始执行 B
这整个过程,就是:
- 调度(schedule)
- 上下文切换(context switch)
六、线程为什么会“跑到另一个 core”?(迁移的根本原因)
结论:
线程迁移不是异常,而是调度器的正常行为。
根本原因只有一个:
负载均衡(load balancing)
1️⃣ 负载均衡在解决什么问题?
在多核系统里,调度器要避免一种情况:
- Core 0:run queue 很长,很忙
- Core 3:run queue 很短,几乎空闲
如果线程永远不迁移,就会出现:
- 有的 core 忙死
- 有的 core 闲着
这在系统层面是不可接受的。
2️⃣ 于是,调度器会做什么?
它会:
- 从“忙的 core”的 runnable 队列中
- 拿走一部分线程
- 放到“闲的 core”的 runnable 队列中
这一步,就是线程迁移(migration)。
七、cache 局部性被破坏
当线程从 Core A 迁移到 Core B 时:
- Core A 上:
- 该线程相关的 cache line 可能还很“热”
- Core B 上:
- L1 / L2 cache 基本是“冷的”
于是:
线程迁移 ≠ 免费
迁移的真实成本是:
- cache 重新填充
- cache line 重新建立副关系
- 更不稳定的访问延迟
八、调度器面临的核心矛盾(非常重要)
现在可以清楚地看到调度器在权衡什么:
一边是:
- 负载均衡
- 提高系统整体吞吐
另一边是:
- cache 局部性
- 提高单线程执行效率、降低延迟
这两件事天然冲突。
所以现在可以接受一个非常重要的事实:
调度从来不是“最优解”,
而是“在多个成本之间的折中”。
九、CPU 亲和性(CPU Affinity)在真实系统中的含义
现在已经站在正确的位置上,可以理解 affinity 了。
一句话定义:
CPU 亲和性 = 对调度器施加约束:
限制某个线程“可以被调度到哪些 core 上”。
默认情况下发生了什么?
默认情况下:
- 调度器:
- 会“尽量”让线程不要乱跑
- 但在负载不均时,仍然允许迁移
也就是说:
默认是“软亲和性”,不是硬绑定。
当你显式设置 affinity 时,你在做什么?
你是在告诉调度器:
“这个线程,只能在指定的一组 core 上被调度。”
这会直接影响:
- 是否允许迁移
- 迁移的范围
- cache 局部性是否能长期保持
但请注意一个非常重要的边界
CPU 亲和性不是性能优化开关。
它的本质是:
- 行为约束工具
- 用来换取:
- 更稳定的 cache 行为
- 更可预测的延迟
代价是:
- 系统整体调度自由度下降
- 可能牺牲吞吐
④ Cache hierarchy L1 L2 L3 在多核系统中的结构性作用
一、Cache存在的意义
cache hierarchy 的存在意义,是 Core 与主内存有极大的速度差。
Core 执行速度极快,但是主内存访问延迟极高 如果没有 cache在中间调节,Core 会被内存彻底拖死
所以cache并不是一个性能优化的选择,而是现代CPU能够存在的必要结构
二、单一Cache无法满足多核系统需要
在多核系统中:
-
单一 cache 无法同时满足
-
低延迟
-
大容量
-
多 Core 并发访问
-
-
低延迟->需要离core近
-
大容量->需要足够的空间
-
多core并发访问->要有能共享的空间(共享的空间会导致延迟升高)
于是只能采用分层结构。
三、分层缓存的三重物理约束
1️⃣ 距离约束:cache 越大,访问延迟越高
离 Core 最近的 cache,必须足够小,才能足够快。
-
cache 是电路,不是抽象对象
-
尺寸越大 → 距离越远 → 延迟越高
这直接决定了:
-
L1 必须极小
-
且必须贴近 Core
2️⃣ 争用约束:共享 cache 会放大并发冲突
越靠近 Core 的 cache,越不能共享。
原因:多 Core 同时访问同一 cache,必然引入仲裁、排队和冲突。
所以:L1 / L2 通常是私有的, 共享只能发生在更远的层级
3️⃣ 成本约束:又快又大又多在硬件上不可承受
cache hierarchy 本质上是一种成本分级。
-
L1:极快、极贵、极小
-
L2:较快、较贵、小
-
L3:更慢、相对便宜、大
-
内存:最慢、最便宜、最大
四、Cache hierarchy 的标准结构形态
在以上约束下,现代多核系统只能演化出层级缓存结构。
Core
↓
L1 cache(极快,极小,私有)
↓
L2 cache(快,小,通常私有)
↓
L3 cache(更慢,大,可能共享)
↓
主内存(最慢,最大)
五、各级 cache 的功能分工
L1 cache:维持流水线连续执行
L1 的目标只有一个:不让 Core 停下来。
作用:满足单个 Core 的即时执行需求
L2 cache:线程工作集的私有缓冲层
L2 是 L1 的缓冲与延伸。
作用:
-
承接 L1 miss(miss:CPU请求的数据不在当前缓存中,需要从更下一级存储加载)
-
保存线程的近期访问数据
-
降低对 L3 / 内存的访问频率
可以把 L2 理解为:
线程在该 Core 上的“短期记忆区”
L3 cache:多 Core 之间的缓冲与协调层
L3 是系统级 cache,而不是线程级 cache。
它的存在是为了:
-
降低主内存带宽压力
-
缓解多 Core 同时访问内存的冲突
-
作为一致性和数据回退的重要中间层
但要强调:
L3 是共享 cache,不是共享内存。
六、层级缓存必然导致可见性延迟
只要存在 cache hierarchy,可见性就不可能是实时的。
原因是:
-
数据主要在 L1 / L2 中流动
-
L3 和内存只是更远的参考点
-
副本同步存在天然延迟
因此:
延迟可见性不是 bug,而是结构结果。
七、Cache hierarchy 与缓存一致性问题的直接因果关系
缓存一致性不是“额外复杂度”,而是层级缓存的必然副作用。
八、本部分的结构性结论
Cache hierarchy 的作用不是“让程序更快”,
而是让多核系统在物理约束下还能工作。
但代价是:
-
数据不再天然共享
-
可见性不再即时
-
顺序不再直观
这正是后续并发、原子性、内存序问题的根源。
当同一份数据,被多个 Core 的 cache 同时持有副本时,
系统如何避免“各写各的,世界分裂”?
⑤ 缓存一致性
一、“缓存一致性”必须存在
在前两部分已经建立了几个关键事实:
-
每个 Core 都有自己的私有 cache
-
Core 的读写几乎都发生在 cache 中
-
cache 的最小单位是 cache line
-
同一地址对应的 cache line,可以同时存在于多个 Core 的 cache 中
于是,只要出现下面这种场景,问题就不可避免:
Core A Core B
| |
| load x | load x
|-------------|-------------
各自 cache 中
都有 x 的副本
这时如果:
Core A: x = 1
Core B: x = 2
问题不是“谁先谁后”,
而是一个更根本的问题:
系统如何保证:
最终大家不会长期生活在彼此不一致的世界里?
这就是 缓存一致性(Cache Coherence) 要解决的事情。
二、缓存一致性保证了什么
缓存一致性 保证的是:
对同一内存地址的多个 cache 副本,
在系统范围内不会长期处于矛盾状态。
它 不保证的是:
-
实时可见
-
立刻同步
-
所有 Core 同时看到完全相同的值
换句话说:
一致性保证“最终不会分叉”,
而不是“每一刻都完全一致”。
三、cache coherence 的基本约束对象:cache line
一致性协议从来不是以“变量”为单位工作的。
它只认识一件事:
cache line
也就是说:
-
协调的是:
- “这 64 字节现在谁能读?谁能写?”
-
而不是:
- “这个 int / bool / 指针怎么样了?”
这直接带来两个重要后果:
-
伪共享(false sharing)是必然现象
-
原子性、锁、同步的成本,往往被低估
(这两点后面都会再展开)
四、一致性协议在做什么
可以把一致性协议理解成一个分布式规则系统,它强制每个 Core 遵守下面几条行为约束。
1️⃣ 写之前,必须获得“独占权”
当某个 Core 想要写一条 cache line时,它不能直接写。
它必须先做到一件事:
确保:
当前系统中,没有其他 Core
还在“合法地使用这条 cache line 的旧副本”。
这通常意味着:
-
其他 Core 中的对应 cache line:
-
要么被标记为无效
-
要么被回收
-
要么被禁止继续读写
-
写操作的真正成本,往往就在这里。
2️⃣ 读操作也不是完全“自由的”
读通常比写便宜,但它也受约束:
-
如果 cache line 是有效的 → 直接读
-
如果 cache line 被标记为无效 →
必须重新从更低层 cache / 内存获取
这就是为什么:
别的 Core 的一次写,
可能会让本地的 cache line 突然失效。
3️⃣ 状态是按 cache line 维护的
每条 cache line 都有“状态”
这些状态大致表达的是:
-
这条 cache line 是否是最新的
-
是否允许被多个 Core 共享
-
是否允许写
-
是否需要回写到内存
一致性协议的本质,就是在维护这些状态转换。
五、一致性是“昂贵的”
为什么多线程写共享变量会这么慢?
原因不是“代码写得不好”,而是:
-
写操作会触发:
-
cache line 独占
-
跨 Core 通信
-
失效广播
-
-
所有这些:
-
都发生在 Core 之间
-
都绕不开总线 / 互联结构
-
都会阻塞其他潜在访问
-
所以可以给出一个之后一定要记住的结论:
一致性不是背景机制,
而是并发程序中最核心的成本来源之一。
六、一致性 ≠ 并发正确性(非常重要的分界线)
这是很多人会混淆的一点。
缓存一致性 并不能保证你的并发程序是正确的。
它只保证:
- “不会永久性地看到彼此矛盾的数据”
但它不保证:
-
操作的原子性
-
读写的顺序
-
跨多个变量的一致视图
例如:
Core A: x = 1; y = 1;
Core B: if (y == 1) assert(x == 1);
即使 cache 是一致的,这段代码:
在没有同步的情况下,依然是错误的。
这正是为什么:
-
你需要 memory ordering
-
你需要 atomic
-
你需要锁
而不是“相信 cache 会帮你处理好”。
七、这一部分的核心认知总结
可以把第五部分压缩成三句话:
1️⃣ 多核系统中,同一数据可以在多个 Core 的 cache 中同时存在副本。
2️⃣ 缓存一致性协议的职责,是在 cache line 层面,避免这些副本长期冲突。
3️⃣ 一致性是昂贵的,而且它只解决“副本不分裂”,并不解决并发语义问题。
这一部分要解决的核心问题是:
为什么“我写在前面的代码”,
在真实硬件上,并不一定“先发生”?
⑥ CPU 指令乱序执行:程序顺序与真实发生顺序的分离
一、必须先拆掉的直觉:CPU 是按代码顺序一条条执行的
大多数人对 CPU 的默认想象是:
读第一行代码
→ 执行
→ 写结果
→ 再读下一行
这个模型在教学、单线程语义、编译器抽象层上是成立的,
但在真实硬件内部,它几乎从未真正发生过。
你写的是:
x = 1;
y = 2;
你以为发生的是:
-
x 被写成 1
-
y 被写成 2
但 CPU 真正关心的不是“语句顺序”,而是:
如何在不破坏单线程语义的前提下,
尽可能快地把所有操作做完。
二、乱序执行的根本动机:隐藏延迟、提高吞吐
现实是:
-
寄存器操作:极快
-
算术运算:快
-
cache 命中:尚可
-
cache miss / 内存访问:极慢
如果 CPU 严格按顺序执行:
a = b + c; // 需要从内存读 b、c
d = e + f; // e、f 已在寄存器
那在等 b、c 的这段时间里:
整个 Core 会被迫空转。
这在硬件设计上是不可接受的。
于是 CPU 做了一件事:
只要不影响“最终结果”,
就允许后面的、无依赖的指令先执行。
三、什么叫“不影响单线程语义”
这是理解乱序执行的锚点。
乱序执行 有一个铁律:
在单线程视角下,
程序的“可观察结果”必须和顺序执行一致。
这意味着:
-
寄存器最终值一致
-
内存最终写入一致
-
控制流结果一致
但它不要求:
-
每一步中间状态一致
-
每一次内存访问顺序一致
所以:
乱序的是“发生顺序”,
不是“程序语义”。
四、CPU 内部真实发生的事情(抽象模型)
可以把现代 Core 理解成这样一个系统:
-
指令被提前取进来
-
被拆解成更小的 micro-ops
-
放进一个执行队列
-
谁准备好了,谁先执行
只要满足:
-
数据依赖不被破坏
-
控制依赖最终可修正
CPU 就会:
主动改变执行顺序。
五、乱序执行影响的不是“算术”,而是“内存可见性”
这是并发里真正危险的地方。
来看一个经典例子:
// Core A
x = 1;
flag = 1;
// Core B
while (flag == 0) {}
assert(x == 1);
在代码层默认的顺序是:
-
A:先写 x,再写 flag
-
B:看到 flag 为 1 后,再读 x
但在真实 CPU 上,A 的执行顺序可能是:
-
先把
flag = 1提交到 cache -
x = 1仍停留在 store buffer / cache 中 -
尚未对其他 Core 可见
于是 Core B 可能看到:
flag == 1
x == 0
这不是 cache 不一致,也不是 bug,
而是 乱序 + 可见性延迟的正常结果。
六、store buffer:乱序的关键中介
为了进一步提高性能,CPU 并不会在每次写时:
-
等 cache 协议完成
-
等数据对外可见
而是引入了一个结构:
store buffer(写缓冲区)
行为是:
-
写指令先进入 store buffer
-
稍后再异步刷入 cache / 内存
-
对本 Core 来说,“写已经完成”
-
对其他 Core 来说,“可能还没发生”
这直接导致一个结论:
“写完成”
≠
“对其他 Core 可见”
这条结论是整个并发世界的地基。
七、为什么在单线程里“感觉不到乱序”
因为:
-
单线程:
- 不存在其他 Core 观察你的中间状态
-
编译器 + CPU:
- 会共同保证单线程语义
所以:
乱序在单线程里是“不可观测的”。
但一旦进入多线程:
-
其他 Core 成为了“观察者”
-
中间状态突然变得“可见”
-
你写的假设开始崩塌
八、乱序执行和缓存一致性的关系
一个非常容易混淆的点是:
“不是有缓存一致性吗?
为什么还会乱?”
答案是:
-
缓存一致性解决的是:
- “副本最终不要分裂”
-
乱序执行影响的是:
- “什么时候对别人可见”
一致性协议 不会排序写操作,
它只负责在必要时传播和失效。
所以:
一致性 ≠ 顺序保证
九、这一部分必须带走的核心结论
可以把第六部分压缩成下面这组认知:
1️⃣ CPU 会主动打乱指令的真实执行顺序来提高性能。
2️⃣ 只要不破坏单线程语义,这种乱序就是“合法的”。
3️⃣ 写操作对本 Core 完成,并不等价于对其他 Core 可见。
4️⃣ 多线程错误,往往正是从“错误假设顺序存在”开始的。
这一部分要回答的核心问题只有一个:
在“CPU 会乱序 + cache 有副本”的现实下,
C++ 如何给程序员一套“可用、可推理”的并发语义?
⑦ C++ 原子变量与内存屏障:软件如何约束乱序与可见性
一、atomic 不是“更安全的 int”
很多人第一次看到 std::atomic<T>,脑子里想的是:
“哦,这是一个不会被打断的变量。”
这是极其危险的简化。
在多核系统里,atomic 的存在不是为了“不被打断”,
而是为了跨 Core 建立规则。
一句话先定调:
atomic 的本质作用不是“算得准”,
而是“让别的 Core 知道你什么时候干了什么”。
二、区分三件事
1️⃣ 原子性(Atomicity)
原子性解决的是:
“这个操作会不会被拆开、被看见一半?”
比如:
x.store(1);
原子性保证的是:
-
不存在:
- 其他线程看到
x的“半写状态”
- 其他线程看到
-
要么看到旧值
-
要么看到新值
⚠️ 注意:
原子性只解决“单个操作”的完整性,不解决顺序问题。
2️⃣ 可见性(Visibility)
可见性解决的是:
“一个线程做的写,
什么时候、在什么条件下,对另一个线程可见?”
这是 cache + store buffer + 多核的核心问题。
没有可见性保证,就会出现你前面看到的情况:
// Core A
x = 1;
// Core B
while (x == 0) {} // 可能永远不结束
3️⃣ 顺序性(Ordering)
顺序性解决的是:
“多个操作,在不同线程眼里,
能不能被认为‘按某种顺序发生’?”
这是乱序执行真正影响并发语义的地方。
一个非常重要的认知校准
atomic ≠ 同时解决这三件事
memory order 决定你到底解决到哪一步
三、C++ 内存模型在干什么
C++ 标准并不试图:
-
描述 cache
-
描述 MESI
-
描述 store buffer
它只做了一件事:
定义:在多线程程序中,
哪些读写行为是“合法可观察的”,
哪些是未定义的。
四、atomic 的最低保证:relaxed
memory_order_relaxed 在干什么
relaxed 只提供一件事:
原子性 + 单变量的可见性
它明确不提供:
-
跨变量顺序
-
happens-before 关系
-
同步语义
可以把它理解为:
“这是一个不会被撕裂的共享变量,但别指望它帮你排队。”
为什么 relaxed 是有用的
因为在很多场景里,你只需要:
-
一个计数器
-
一个统计值
-
一个“近似正确”的状态
而不需要:
-
严格的执行顺序
-
同步其他数据
这也是为什么:
atomic 的默认不是 relaxed,
但必须理解 relaxed,
否则永远写不出高性能并发代码。
五、真正进入“同步语义”:acquire / release
这是并发认知真正升级的地方。
release:对外宣布“我这边准备好了”
data = 42;
flag.store(true, std::memory_order_release);
release 的语义不是:
- “我把 flag 写出去了”
而是:
“在这条 store 之前的所有写操作,
在另一个线程看到这个 flag 时,
都必须已经对它可见。”
acquire:我承认你之前做的事
if (flag.load(std::memory_order_acquire)) {
assert(data == 42);
}
acquire 的语义是:
一旦我读到了某个 release 写入的值,
我之后的读,
必须看到对方在 release 前完成的写。
acquire / release 在本质上做了什么
它们没有强行“全局排序”,
只做了一件非常克制的事:
在两个线程之间,
建立一条 happens-before 边。
这条边的含义是:
“你在那之前做的事,
我在这之后都能看到。”
六、为什么 acquire / release 是并发的“主力模型”
因为它满足了三个现实需求:
-
只在需要同步的地方建立顺序
-
不破坏 CPU 的大部分乱序优化
-
能精确表达“发布-订阅”关系
这正是:
-
生产者 / 消费者
-
初始化 + 使用
-
once flag
-
lock-free 队列
的理论基础。
七、seq_cst:最强、最贵、最直觉
memory_order_seq_cst 做了什么
一句话:
它要求所有使用 seq_cst 的原子操作,
在全局上看起来,
像是按某一个统一顺序发生的。
这会带来:
-
最接近“直觉”的行为
-
最强的可推理性
-
最高的约束成本
为什么 seq_cst 默认存在
因为它:
-
对新手最安全
-
对推理最简单
-
对面试最友好
但必须知道:
seq_cst 不是“更高级”,
而是“更保守”。
八、memory fence 在逻辑上做了什么
现在已经可以去理解 fence 了
atomic_thread_fence 的作用不是:
- 修改某个变量
而是:
在当前线程中,
人为插入一个“顺序点”,
约束前后的内存操作不能被乱序穿越。
它的意义是:
-
当无法用某个具体 atomic 表达同步关系时
-
显式告诉编译器 + CPU:
“这里是边界,别乱来。”
九、把这一整节压缩成“可复述模型”
C++ atomic 并不是为了“不被打断”,
而是为了在乱序的多核系统中,
用最小的代价建立可见性和顺序关系。
memory order 决定了你到底约束了多少乱序,
relaxed 只保证原子性,
acquire / release 建立线程间因果关系,
seq_cst 则牺牲性能换取全局顺序。