C++ 并发四件套:并发编程 / 原子性 / 数据竞争 / 内存模型 (全解析) - 指南

目录

一、并发编程:多个线程一起干活的世界

1.1 并发编程的定义

1.2 并发编程里我们要解决什么

二、原子性:一个操作要么全做,要么不做

2.1 原子性的定义

1)非原子示例:

2)原子示例:

2.2 a++ “非原子”的危害

2.3 atomic 如何提供原子性

三、数据竞争:多线程抢同一块数据的灾难

3.1 数据竞争的定义

3.2 延用上一节的 counter++ 示例

3.3 如何从根上避免数据竞争

方式一:使用 std::atomic(硬件原子性)

方式二:使用 std::mutex(软件互斥锁)

四、内存模型:多线程世界里的“读写规则手册”

4.1 为什么需要“内存模型”?

4.2 C++ 内存模型提供了什么?

五、atomic vs mutex:什么时候用谁?

5.1 性能与应用场景对比

5.2 经验性总结

六、总结


在上一篇学习中,我们已经详细展开了 C++ 中 a++ 的线程安全问题,并且通过 std::atomicstd::mutex 看到了两种典型的“解决方案”——硬件级原子操作软件级互斥锁。(链接如下)

【C++基础】Day 7:a++ 的线程安全问题 与 std::atomic 全解析-CSDN博客https://blog.csdn.net/m0_58954356/article/details/155519779?spm=1001.2014.3001.5502【C++基础】Day 7:std::atomic vs mutex -CSDN博客https://blog.csdn.net/m0_58954356/article/details/155523789?spm=1001.2014.3001.5502但是,要想真正把这块知识吃透,仅靠“记住 a++ 不安全、atomic 可以、mutex 也行”还远远不够。

这篇我准备进一步把底层概念系统梳理一遍,围绕下面四个关键词展开:

  • 并发编程(Concurrency)

  • 原子性(Atomicity)

  • 数据竞争(Race Condition)

  • 内存模型(Memory Model)

并结合上一节的 a++ / std::atomic / std::mutex 示例,从现象 → 概念 → 原理 → 工程实践,把 C++ 并发中的基础理论补齐。


一、并发编程:多个线程一起干活的世界

先回顾一下上一节:

我们写了两个线程一起对同一个 countercounter++,结果居然小于预期的 200000,这就是典型的并发问题

1.1 并发编程的定义

一句话:

并发编程(Concurrency)就是让多个任务在同一时间段内协同执行。

在 C++ 里,最常见的就是多线程程序:

  • 主线程负责主业务

  • 子线程负责计算 / IO / 网络 / 控制

  • 多个线程之间共享部分数据(如计数器、日志缓冲区、任务队列等)

并发编程关注的问题:

  • 多个线程如何访问同一块数据?

  • 如何避免互相抢、互相覆盖?

  • 如何保证某些操作“不能被打断”?

  • 如何在保证正确性的前提下尽量提高性能?

典型现象就是你在上一节看到的:

// 期望:counter == 200000
// 实际:经常 < 200000,而且每次都不一样
counter++;

这就是并发编程的典型坑。


1.2 并发编程里我们要解决什么

从工程角度看,最重要的就是两个字:

“安全” + “顺序”

  • 安全:不会因为多个线程一起读写导致崩溃或错误结果

  • 顺序:某些操作之间的先后关系,是不是必须满足?谁先谁后能不能保证?

而想解决这两个问题,就需要下面三个概念:原子性、数据竞争、内存模型——这就是下面几节要讲的东西。


二、原子性:一个操作要么全做,要么不做

上一节我们说:

a++ 在多线程中不安全,因为它会被拆成 读 → 加一 → 写回 三步。

这其实就是在说:a++ 不是原子操作

2.1 原子性的定义

原子性(Atomicity) 就是:

一个操作要么全部执行,要么完全不执行,中间不会被打断,也不会被其他线程“看见到一半的状态”。

在 C++ 里:

  • 普通的 int 自增:非原子

  • std::atomic<int> 的自增:原子

1)非原子示例:

int a = 10;
a++;   // 底层:load → add → store,各自可以被打断

2)原子示例:

std::atomic a{10};
a++;  // 底层使用 CPU 原子指令,一次性完成“读-改-写”

2.2 a++ “非原子”的危害

多线程场景下,两条线程执行 a++

int a = 10;

时间线:

时间T1 操作T2 操作内存中的 a
t0load → 1010
t1load → 1010
t2add → 1110
t3add → 1110
t4store 1111
t5store 1111(覆盖)

本该是:

10 → 11 → 12

实际却变成:

10 → 11 → 11

原因:两条线程都读到了旧值 10,都算出 11,然后互相覆盖。

这就是“非原子操作在并发场景下的灾难”。


2.3 atomic 如何提供原子性

上一节我们已经写过:

std::atomic counter{0};
void add() {
    for (int i = 0; i < 100000; i++)
        counter++; // 原子操作
}

这里的 counter++底层会变成类似:

  • x86:lock xadd

  • ARM:ldrex/strex 配对

  • RISC-V:amoadd

这些都是 CPU 提供的硬件级原子操作一次性完成“读-改-写”,整个过程对其他线程来说是不可分割的

atomic = 利用硬件原子指令来实现“真正的原子性”。


三、数据竞争:多线程抢同一块数据的灾难

有了“原子性”的概念,接下来就好理解 “ 数据竞争(Race Condition)”了。

3.1 数据竞争的定义

当满足以下条件时,就发生了数据竞争:

  1. 至少有两个线程

  2. 访问同一个内存位置(比如同一个变量)

  3. 其中至少一个在写

  4. 没有使用任何同步手段(无锁、无 atomic)

一旦发生数据竞争,程序行为就是未定义(Undefined Behavior)

a++ 的多线程示例,就是教科书级的 race condition。


3.2 延用上一节的 counter++ 示例

错误版本:

int counter = 0;
void add() {
    for (int i = 0; i < 100000; i++)
        counter++; // 非原子,存在数据竞争
}

两个线程同时执行:

  • 同时读

  • 同时加

  • 同时写

结果会比 200000 小,而每次运行都可能不一样。

行为未定义 ≠ “有点不准”,而是“没人能保证结果正确”。


3.3 如何从根上避免数据竞争

方式一:使用 std::atomic(硬件原子性)

std::atomic counter{0};
void add() {
    for (int i = 0; i < 100000; i++)
        counter++; // 原子操作,无数据竞争
}

方式二:使用 std::mutex(软件互斥锁)

int counter = 0;
std::mutex m;
void add() {
    for (int i = 0; i < 100000; i++) {
        std::lock_guard lock(m);
        counter++;    // 在锁保护下访问,共享变量的读写被串行化
    }
}
  • lock_guard 构造时 m.lock()

  • 析构时 m.unlock()

  • 任意时刻只有一个线程能拿到锁

  • 自增就从“并行 + 互相覆盖”,变成了“串行 + 正确统计”

atomic 用硬件保证“每次 ++ 不互相盖”;

mutex 用软件保证“同一时间只有一个人 ++”。


四、内存模型:多线程世界里的“读写规则手册”

前面我们解决了两个问题:

  • 操作是否是原子的?

  • 多线程会不会出现数据竞争?

但还有一个更隐蔽的问题:读写顺序。

4.1 为什么需要“内存模型”?

现代 CPU 和编译器为了优化性能,会做很多“骚操作”:

  • 指令重排:把一些语句顺序调换,只要单线程结果不变就行

  • 缓存:线程 A 写的数据先在自己的 cache 里,线程 B 一段时间内看不到

  • 写缓冲:写操作可能被延后刷新到内存

单线程下,这些优化是安全且透明的。
但多线程下就会变成灾难:

ready = true;
data = 42;

在另一个线程眼里,有可能看到的顺序完全不同,甚至永远看不到更新后的值。

为了让多线程程序有“可以推理的行为”,C++11 定义了一套 内存模型(Memory Model)


4.2 C++ 内存模型提供了什么?

简化理解,它主要规定了:

  • 哪些操作可以被重排?

  • 哪些操作之间必须保持顺序?

  • 哪些写对其他线程是“可见的”?什么时候可见?

  • std::atomic 的读写有哪些顺序保证?

我们平时最常用的就是:

std::atomic x{0};
// 线程1
x.store(1, std::memory_order_release);
// 线程2
int v = x.load(std::memory_order_acquire);

release + acquire 这对组合,保证了部分“先后发生(happens-before)”关系,避免了乱序导致的怪异行为。

简单说:内存模型就是规定多线程读写时,“谁能看到谁、按什么顺序看到”的一套规则。


五、atomic vs mutex:什么时候用谁?

这一点其实是上一节的延续,借机在这里给个最终对比。

5.1 性能与应用场景对比

方案是否安全底层原理性能是否阻塞适用场景
普通 intload + add + store 可被打断⭐⭐⭐⭐⭐(快)纯单线程
std::atomicCPU 原子指令(lock xadd 等)⭐⭐⭐⭐计数器、自增、标志位、轻量共享变量
mutex + int互斥锁、可能进入内核、阻塞保护复杂数据结构、多步骤操作

5.2 经验性总结

  • 能用 atomic 就用 atomic
    简单读写 / 计数 / 标志位,自增自减,适合走硬件原子指令。

  • atomic 搞不定的,就上 mutex
    尤其是涉及多步逻辑 / 复杂数据结构的整体一致性。

例如(整个区间)

if (vec.empty()) {
    vec.push_back(x);
}

这里就必须用 mutex 来保护整个 if + push_back 区间不可能用 atomic 替代


六、总结

  1. 并发编程
    研究多个线程如何协作、如何共享数据而不出错。

  2. 原子性
    一个操作要么全部执行,要么不执行,中途不会被打断。
    a++ 非原子,atomic++ 是原子。

  3. 数据竞争
    多线程在没有同步的前提下同时读写同一内存,
    结果不可预测,行为未定义(UB)。

  4. 内存模型
    规定多线程读写的顺序、可见性和重排规则,
    确保我们写的并发程序有可推理的行为。


在并发世界里,a++ 看似简单,其实牵扯到并发编程、原子性、数据竞争和内存模型这四个核心概念。
std::atomic 用硬件原子指令解决“操作不可分割”的问题;
std::mutex 用互斥锁解决“代码区间只能一个线程进”的问题;
二者配合内存模型,才构成了 C++11 之后那套“可依赖的并发语义”。

posted @ 2026-01-13 14:26  clnchanpin  阅读(24)  评论(0)    收藏  举报