Skip to content

Stage07|Modern C++ Foundation ​

Lesson092|Atomic、竞态条件与线程安全 ​

Tags: #C++17 #Atomic #DataRace #MemoryModelDifficulty: ⭐⭐⭐⭐⭐


一、问题场景:count++ 为什么会丢 ​

多个线程同时执行:

cpp
count++;

它通常包含读取、加一和写回。两个线程可能读到同一个旧值并覆盖结果。若 count 是普通变量且无同步,这不只是“结果偶尔不准”,而是 C++ 数据竞态,程序行为未定义。

二、本课目标 ​

  1. 区分 race condition 与 data race。
  2. 理解 atomic 解决单个原子状态的边界。
  3. 掌握 load/store/fetch_add/compare_exchange。
  4. 建立 acquire/release 的基本直觉。
  5. 知道何时 mutex 比 atomic 更正确。

三、回到 TaskSystem:统计值和结果发布 ​

worker 需要统计 submitted/completed/failed,也可能把计算结果发布给主线程。计数器是单字段统计,适合 atomic;任务结果、状态和错误信息是复合不变量,仍需要 mutex、future 或明确的消息传递。先区分“统计”与“业务事实”,再选择内存序。

四、数据竞态 ​

两个线程并发访问同一内存位置,至少一个是写,且没有同步关系,便可能构成数据竞态。volatile 不提供线程同步;它主要约束特定内存访问优化,不能替代 atomic 或 mutex。

五、原子计数器 ​

cpp
std::atomic<int> completed{0};

void finishJob() {
    completed.fetch_add(1, std::memory_order_relaxed);
}

如果计数器只用于统计,不负责发布其他数据,relaxed 可能足够。它保证该原子自身操作不可撕裂并具有修改顺序,但不为其他普通数据建立可见性。

六、发布与获取 ​

cpp
std::string result;
std::atomic<bool> ready{false};

// producer
result = "done";
ready.store(true, std::memory_order_release);

// consumer
if (ready.load(std::memory_order_acquire)) {
    use(result);
}

成功读取 release 写入的 acquire load,使此前写入对消费者可见。这是教学模型;复杂无锁结构还涉及对象生命周期、ABA、回收和多原子不变量,不应只拼 memory_order。

七、Compare Exchange ​

cpp
int expected = 0;
state.compare_exchange_strong(expected, 1);

成功时把 0 改为 1;失败时 expected 会被更新为实际值。weak 版本允许伪失败,通常在循环中使用。

八、Atomic 不能维护多字段不变量 ​

假设余额和版本必须一起变化,分别做成两个 atomic 仍可能让读者看到不一致组合。此时 mutex 封装事务更直接:

cpp
struct Account {
    std::mutex mutex;
    int balance{};
    int version{};
};

先追求可证明的正确性,再判断锁竞争是否真的成为热点。

九、可编译验证:丢失更新与修复 ​

启动 4 个线程,每个循环 100000 次,对普通 int 和 atomic<int> 分别递增。普通版本本身含未定义行为,只用于配合 ThreadSanitizer 教学,不应把某次错误数字当作稳定结果。

bash
clang++ -std=c++17 -O1 -g -fsanitize=thread -pthread atomic_lab.cpp -o atomic_lab

再把计数器改为 mutex 保护,验证两种正确版本都达到 400000,并比较 Release 下多轮耗时。

十、TaskSystem 的线程安全契约 ​

文档必须说明:哪些计数可并发读取、stop() 是否可与 submit() 并发、任务回调在哪个线程执行、析构前是否必须停止提交。一个 atomic 计数器只能保护这个计数器,不能自动保护队列、任务对象或统计之间的关系。

十一、诊断方法 ​

text
先找共享可变状态
列出所有读写线程
确认同步原语是否覆盖每次访问
用 TSan 获取冲突堆栈
检查对象是否在线程完成前销毁
不要用 sleep 隐藏时序

十二、线程安全的含义 ​

“类使用了 atomic”不等于类线程安全。文档应说明哪些方法可并发、对象销毁需要什么前提、回调在哪个线程、复合操作是否原子,以及调用方是否仍需外部锁。

十三、练习与答案 ​

1. volatile 能修复 count++ 吗? ​

不能。它不使读改写原子化,也不建立线程间 happens-before。

2. relaxed 是否等于没有顺序? ​

它仍保证该 atomic 自身的原子性和修改顺序,只是不对其他内存操作提供跨线程同步顺序。

3. 何时优先 mutex? ​

需要维护多个字段不变量、执行复合事务、无锁正确性难证明,或竞争并非真实性能瓶颈时。

十四、本课总结 ​

Atomic 适合表达小而清楚的原子状态;mutex 适合保护复合不变量。线程安全必须通过同步关系和对象生命周期证明,不能靠“测试暂时没崩”。