把章节变成可单步观察的过程
分类:计算机基础 / 并发与内存模型
先说结论
原子操作保证某个操作不可被并发撕裂,内存顺序约束多个读写之间的可观察关系,happens-before 才是判断另一个线程能否合法看到数据的主线。原子变量安全不等于它旁边的普通数据自动安全。
发布 42 的最小过程
线程 A:data = 42
线程 A:ready.store(true, release)
线程 B:ready.load(acquire) 读到 true
线程 B:读取 data,得到 42
release 与读到它的 acquire 建立同步,使 A 在前面的 data = 42 对 B 可见。若 B 没读到 true,就不能进入读取;若 ready 只是普通 bool,两个线程的并发访问还可能直接形成数据竞争。
生活化模型:公告栏与交接旗
想象两个人在不同房间协作。
甲先填写一张表,再升起一面旗:
填写表格内容
升旗表示“填写完成”
乙看到旗后去读表:
看到旗
读取表格内容
我们希望得到:
乙只要确认看到“完成旗”,
就能看到甲在升旗前写好的表格。
但在并发程序里,仅仅把代码按这个顺序写出来还不够。编译器和处理器可能在不破坏单线程结果的前提下调整操作,缓存与存储缓冲也会影响其他核心何时观察到写入。
C++ 内存模型用:
原子操作
sequenced-before
synchronizes-with
happens-before
建立可移植的线程间保证。
旗对应原子同步变量,表格对应被同步保护的普通数据。
三个容易混淆的问题
原子性
一个操作不会被其他线程观察成撕裂的中间状态。
例如对一个适当的 std::atomic<int>:
读取看到某次完整写入的值,
不会看到该原子写入的一半。
可见性
一个线程的写入,何时能被另一个线程按语言规则观察到。
有序性
不同操作之间,哪些顺序必须对其他线程成立。
原子性不自动意味着:
附近所有普通变量也都同步
所有操作都按源码顺序向其他线程可见
复合业务操作整体不可分割
数据竞争与未定义行为
如果两个线程并发访问同一内存位置:
至少一个是写
访问不是由合适原子操作完成
也没有 happens-before 关系
就形成数据竞争。C++ 中数据竞争导致未定义行为。
错误示例:
bool ready = false;
int payload = 0;
// 线程 A
payload = 42;
ready = true;
// 线程 B
if (ready)
{
Use(payload);
}
volatile 不能把它修复为线程同步。C++ 的 volatile 主要用于特殊访问语义,不建立普通线程间 happens-before。
happens-before 主线
线程内:
操作 A sequenced-before 操作 B
线程间若一个 release 操作与另一个 acquire 操作按规则匹配:
release synchronizes-with acquire
再结合传递关系:
甲写表格
sequenced-before
甲 release 升旗
synchronizes-with
乙 acquire 看旗
sequenced-before
乙读表格
于是:
甲写表格 happens-before 乙读表格。
这才是“看到旗后能读到表格”的语言保证。
最小发布—获取示例
#include <atomic>
#include <string>
struct Message
{
int code;
std::string text;
};
Message message;
std::atomic<bool> ready{false};
void Producer()
{
// 普通写入只由生产者执行。
message.code = 7;
message.text = "ready";
// 发布此前对 message 的写入。
ready.store(true, std::memory_order_release);
}
bool Consumer()
{
// 只有读到 release 写入的 true,才建立同步关系。
if (!ready.load(std::memory_order_acquire))
{
return false;
}
Use(message.code, message.text);
return true;
}
这里的数值和文本只是示意。
关键前提:
message 在发布前完成写入。
发布后没有线程无同步地继续修改 message。
消费者的 acquire 确实读到对应发布值或其发布序列允许的值。
如果多个生产者同时写 message,这个简单模型不再安全。
进阶:memory_order 六种常见形式
memory_order_relaxed
保证该原子对象上的操作具有原子性和修改顺序,但不为周围普通内存建立 acquire/release 同步。
适合的典型语义:
只需要无撕裂计数
不通过该计数发布其他数据
例如统计计数器是否能用 relaxed,仍取决于计数值是否参与对象生命周期或同步决策。
memory_order_release
用于发布:
阻止该线程中此前相关操作被重排到 release 之后,
并可与读取到它的 acquire 建立同步。
通常用于原子写或读改写操作。
memory_order_acquire
用于获取:
读取到匹配发布后,
后续相关操作不能越过 acquire,
并可观察发布前 happens-before 的写入。
通常用于原子读或读改写操作。
memory_order_acq_rel
用于同时承担获取与发布的原子读改写操作,例如某些 fetch_add 或成功的 compare-exchange。
memory_order_seq_cst
除 acquire/release 类保证外,还让所有顺序一致原子操作参与一个单一全序。
它更容易建立直觉,但不等于:
所有普通内存操作都成为全局事务
复合算法自动正确
没有硬件代价
memory_order_consume
它试图只沿数据依赖建立较弱顺序,但长期存在实现与使用复杂性。可移植代码不能仅凭理论上的 consume 成本优势就假设实现会提供更弱硬件指令;应以所用标准、编译器文档和验证结果为准。
原子读改写
std::atomic<unsigned> counter{0};
void Count()
{
counter.fetch_add(1, std::memory_order_relaxed);
}
每次 fetch_add 是一个原子读—改—写操作。
与下面不同:
counter = counter + 1;
即使 counter 是原子类型,后者概念上可能包含独立 load 与 store,不能替代一个不可分割的增量。
compare_exchange 的逐步状态
CAS 比较当前值与期望值:
相等 -> 写入新值并报告成功
不等 -> 不写新值,把实际值放回 expected 并报告失败
最小循环:
#include <atomic>
void RaiseToAtLeast(
std::atomic<int>& value,
int candidate)
{
int observed = value.load(std::memory_order_relaxed);
while (observed < candidate &&
!value.compare_exchange_weak(
observed,
candidate,
std::memory_order_relaxed,
std::memory_order_relaxed))
{
// 失败时 observed 已更新为当前实际值,再次检查条件。
}
}
compare_exchange_weak 允许伪失败,因此适合放在循环中。
成功与失败 memory order 的合法组合受标准限制;失败路径不能使用只适用于写入的 release 语义。
内存屏障是什么
“内存屏障”可能指不同层次:
编译器屏障:约束编译器重排。
硬件屏障:约束处理器内存操作顺序。
C++ atomic_thread_fence:参与 C++ 内存模型的栅栏操作。
它们不能随意互换。
C++ 原子操作带 memory order 时,编译器会根据目标架构生成:
可能无需额外指令
带顺序语义的读写指令
显式硬件屏障
库调用或锁
具体结果取决于架构、编译器和优化级别。
单独插入一个硬件屏障,并不会自动修复 C++ 源码中的数据竞争;语言层仍需正确的原子对象和同步关系。
可见性不等于立刻刷新所有缓存
常见误解:
release 会把所有缓存立即写回主内存,
acquire 会从主内存重新加载所有变量。
C++ 标准不要求用这个物理过程解释。
标准保证的是可观察行为和 happens-before。具体硬件可能通过:
缓存一致性协议
存储缓冲排序
屏障指令
失效消息
实现这些保证。
“主内存最新值”不是理解所有现代处理器并发语义的充分模型。
shared_ptr 的两种线程安全
这部分必须区分三样东西:
shared_ptr 对象本身
shared_ptr 指向的控制块
被管理对象
控制块引用计数
多个不同的 shared_ptr 实例共享同一控制块时:
std::shared_ptr<Data> a = source;
std::shared_ptr<Data> b = a;
不同线程分别复制、销毁自己的 a、b 实例时,控制块引用计数按标准库要求安全协调。
这不意味着引用计数每个实现都必须使用某种固定 CPU 指令。
同一个 shared_ptr 实例
如果多个线程并发访问同一个 shared_ptr 变量,且至少一个线程修改它:
需要外部同步
或使用标准提供的 atomic<shared_ptr> 等原子接口
不能因为控制块计数是线程安全的,就无锁并发写同一个 shared_ptr 对象。
被管理对象
auto shared = std::make_shared<Data>();
shared_ptr 只管理生命周期,不自动保护 Data 内部字段。
如果多个线程并发修改 *shared:
仍需要 Data 自身的互斥、原子字段、不可变设计或其他同步协议。
结论:
引用计数线程安全
≠ shared_ptr 变量的所有并发访问都安全
≠ 被管理对象的读写自动线程安全
shared_ptr 发布示例
#include <atomic>
#include <memory>
struct Snapshot
{
int value;
};
std::atomic<std::shared_ptr<const Snapshot>> current;
void Publish(int value)
{
auto next = std::make_shared<const Snapshot>(
Snapshot{value});
current.store(
std::move(next),
std::memory_order_release);
}
std::shared_ptr<const Snapshot> Acquire()
{
return current.load(std::memory_order_acquire);
}
std::atomic<std::shared_ptr<T>> 的类模板特化从 C++20 起提供;更早标准使用针对 shared_ptr 的原子自由函数。
const Snapshot 让读取方无法通过该指针修改快照,但如果对象内部仍引用可变共享资源,仍需单独分析。
原子智能指针的锁自由性不是普遍保证,可以查询对应能力或测量实现。
进程与线程
进程
进程通常拥有:
独立虚拟地址空间
资源句柄集合
安全身份和隔离边界
至少一个执行线程
不同进程不能只靠普通 C++ 指针共享对象。进程间通信需要:
管道
套接字
共享内存
消息机制
其他操作系统接口
共享内存只让物理页可被共同映射,不自动提供跨进程对象生命周期和同步协议。
线程
同一进程内线程通常共享:
地址空间
全局对象
堆分配对象
文件等进程资源
每个线程通常独有:
寄存器执行上下文
栈
线程本地存储
调度状态
C++ 规定抽象线程和内存模型;具体线程资源由实现映射到操作系统。
上下文切换
线程被切出时,系统需要保留足以恢复执行的状态,例如:
程序计数位置
寄存器
栈指针
调度信息
切换到不同进程时,通常还涉及:
地址空间上下文
页表相关状态
安全与资源上下文
实际成本可能包含:
调度器工作
缓存和 TLB 局部性损失
内核态切换
流水线与分支预测影响
但并非每次线程切换都必然清空全部缓存或 TLB。细节由硬件和操作系统优化决定。
用户态协程切换与操作系统线程切换也不是同一成本模型。
一次消息发布的逐步状态
Empty
消费者尚未看到可用消息。
Writing
生产者独占填写普通数据。
Publishing
生产者对原子标志执行 release store。
Published
发布值进入原子对象修改顺序。
Acquiring
消费者执行 acquire load。
Observed
acquire 读到匹配发布值,建立同步。
Reading
消费者读取 happens-before 保证下的数据。
失败或未就绪状态:
NotObserved
acquire 尚未读到发布值。
ProtocolViolation
发布后仍有无同步写入。
DataRace
普通访问并发冲突且无 happens-before。
正常路径
release/acquire 发布
生产者写普通数据
生产者 release store 标志
消费者 acquire load 读到该标志
消费者读取普通数据
同步关系完整。
mutex 保护
线程 A 加锁
修改共享对象
线程 A 解锁
线程 B 随后成功加同一把锁
线程 B 读取共享对象
互斥锁也建立标准规定的同步关系,通常比手写多个原子字段更容易维护复合不变量。
边界路径
acquire 没读到发布值
如果读取仍为 false:
没有通过这次读取建立发布—获取关系。
消费者不能读取 message。
返回“尚未就绪”是正常状态,不应继续读再假设稍后会一致。
CAS 竞争
多个线程同时更新原子值:
一个成功
其他线程看到 expected 被更新
重新检查条件
CAS 循环必须重新计算依赖旧值的结果,不能盲目重复最初的候选。
ABA
原子值经历:
A -> B -> A
CAS 只比较当前位模式时,可能看不出中间变化。
是否需要版本标签、危险指针、epoch 回收或其他方案,取决于数据结构与内存回收协议。
false sharing
两个线程修改不同原子变量,但变量落在同一硬件缓存行:
语言层没有数据竞争,
性能仍可能因缓存一致性交互下降。
缓存行大小和填充策略是硬件与实现问题,不能写死一个跨平台常数。
失败路径
relaxed 被误用作发布
payload = value;
ready.store(true, std::memory_order_relaxed);
消费者 relaxed 读到 true,并不因此获得读取非原子 payload 的发布—获取保证。
只把标志改成 atomic
多个普通字段之间存在复合不变量:
begin <= end
pointer 与 size 属于同一版本
状态与资源所有权同时变化
只把其中一个字段设为原子,不能让整个不变量原子化。此时可能需要锁、不可变快照或明确版本协议。
对象析构与访问竞争
最后一个 shared_ptr 释放控制块时会析构对象。如果另一个线程只持有未受保护的裸指针:
它可能与析构并发,形成悬空访问。
引用计数只有在访问线程真正持有有效共享所有权时,才能延长对象生命周期。
双重检查锁定写错顺序
先发布指针、后完成对象初始化,会让其他线程取得半初始化对象。
正确性依赖明确的发布顺序和同步关系,不能只凭“指针写入是原子的”判断。
死锁
原子操作不能消除所有锁需求。使用多个锁时可能出现:
线程 A 持有锁 1 等待锁 2
线程 B 持有锁 2 等待锁 1
需要统一锁顺序、组合加锁工具或重新设计所有权。超时返回并不自动修复已经破坏的事务不变量。
代价
原子与同步成本可能来自:
缓存一致性通信
禁止部分编译器或硬件重排
原子读改写的独占缓存行
竞争重试
进入内核等待
线程唤醒和上下文切换
较弱 memory order 可能允许更多优化,但:
正确性证明更难
平台差异更明显
维护者更容易破坏隐含协议
锁在无竞争时可能很便宜,在竞争时可让线程阻塞,避免无限自旋浪费处理器。选择锁、原子还是消息传递,应基于共享不变量和测量,而不是“无锁一定更快”。
调试与可观测性
并发问题需要记录:
线程身份
原子状态转换
对象生命代次
锁等待与持有时间
任务或消息序号
发布方与获取方
超时、CAS 失败和重试次数
上下文切换与调度信息
工具可能包括:
线程消毒器
竞态检测器
平台性能分析器
锁顺序检测
带事件序号的结构化日志
日志自身也必须线程安全,并避免改变时序后就把“问题消失”误判为已修复。
标准、操作系统与实现选择
C++ 标准可验证的部分
数据竞争与未定义行为
原子对象的修改顺序
memory_order 语义
happens-before 与 synchronizes-with
mutex 和条件变量同步保证
shared_ptr 控制块的线程安全边界
操作系统与硬件负责的部分
线程调度与阻塞唤醒
进程隔离与共享内存
上下文切换
缓存一致性与硬件内存顺序
具体屏障和原子指令
具体实现必须确定的部分
原子类型是否 lock-free
memory order 对应哪些机器指令
mutex 的自旋与休眠策略
线程数量和调度属性
对象发布与回收协议
进程间同步原语
性能填充与内存布局
最后总结
原子性、可见性和有序性是三个不同问题。
C++ 用 happens-before 描述线程间可见保证。
release 发布此前写入,匹配的 acquire 获取这些写入。
relaxed 保证原子操作本身,不自动发布周围普通数据。
内存屏障有语言、编译器和硬件层次,不能混用概念。
shared_ptr 引用计数安全不等于同一 shared_ptr 变量或被管理对象自动线程安全。
线程共享地址空间,进程通常拥有隔离地址空间。
上下文切换成本取决于调度、地址空间和缓存局部性,不能简化为固定开销。