DYNAMIC EXPLAINER / ARTICLE FLOW

把章节变成可单步观察的过程

01 / 23
STEP 01 / CODE / LOGIC

先说结论

原子操作保证某个操作不可被并发撕裂,内存顺序约束多个读写之间的可观察关系,happens-before 才是判断另一个线程能否合法看到数据的主线。原子变量安全不等于它旁边的普通数据自动安全。

线程 A:data = 42
线程 A:ready.store(true, release)
线程 B:ready.load(acquire) 读到 true
线程 B:读取 data,得到 42

分类:计算机基础 / 并发与内存模型

先说结论

原子操作保证某个操作不可被并发撕裂,内存顺序约束多个读写之间的可观察关系,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;

不同线程分别复制、销毁自己的 ab 实例时,控制块引用计数按标准库要求安全协调。

这不意味着引用计数每个实现都必须使用某种固定 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 变量或被管理对象自动线程安全。
线程共享地址空间,进程通常拥有隔离地址空间。
上下文切换成本取决于调度、地址空间和缓存局部性,不能简化为固定开销。