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

目录

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

[1.1 并发编程的定义](#1.1 并发编程的定义)

[1.2 并发编程里我们要解决什么](#1.2 并发编程里我们要解决什么)

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

[2.1 原子性的定义](#2.1 原子性的定义)

1)非原子示例:

2)原子示例:

[2.2 a++ "非原子"的危害](#2.2 a++ “非原子”的危害)

[2.3 atomic 如何提供原子性](#2.3 atomic 如何提供原子性)

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

[3.1 数据竞争的定义](#3.1 数据竞争的定义)

[3.2 延用上一节的 counter++ 示例](#3.2 延用上一节的 counter++ 示例)

[3.3 如何从根上避免数据竞争](#3.3 如何从根上避免数据竞争)

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

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

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

[4.1 为什么需要"内存模型"?](#4.1 为什么需要“内存模型”?)

[4.2 C++ 内存模型提供了什么?](#4.2 C++ 内存模型提供了什么?)

[五、atomic vs mutex:什么时候用谁?](#五、atomic vs mutex:什么时候用谁?)

[5.1 性能与应用场景对比](#5.1 性能与应用场景对比)

[5.2 经验性总结](#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 / 网络 / 控制

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

并发编程关注的问题:

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

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

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

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

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

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

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


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

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

"安全" + "顺序"

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

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

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


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

上一节我们说:

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

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

2.1 原子性的定义

原子性(Atomicity) 就是:

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

在 C++ 里:

  • 普通的 int 自增:非原子

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

1)非原子示例:

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

2)原子示例:

cpp 复制代码
std::atomic<int> a{10};
a++;  // 底层使用 CPU 原子指令,一次性完成"读-改-写"

2.2 a++ "非原子"的危害

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

cpp 复制代码
int a = 10;

时间线:

时间 T1 操作 T2 操作 内存中的 a
t0 load → 10 10
t1 load → 10 10
t2 add → 11 10
t3 add → 11 10
t4 store 11 11
t5 store 11 11(覆盖)

本该是:

10 → 11 → 12

实际却变成:

10 → 11 → 11

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

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


2.3 atomic 如何提供原子性

上一节我们已经写过:

cpp 复制代码
std::atomic<int> 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++ 示例

错误版本:

cpp 复制代码
int counter = 0;

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

两个线程同时执行:

  • 同时读

  • 同时加

  • 同时写

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

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


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

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

cpp 复制代码
std::atomic<int> counter{0};

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

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

cpp 复制代码
int counter = 0;
std::mutex m;

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

  • 析构时 m.unlock()

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

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

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

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


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

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

  • 操作是否是原子的?

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

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

4.1 为什么需要"内存模型"?

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

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

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

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

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

cpp 复制代码
ready = true;
data = 42;

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

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


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

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

  • 哪些操作可以被重排?

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

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

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

我们平时最常用的就是:

cpp 复制代码
std::atomic<int> 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 性能与应用场景对比

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

5.2 经验性总结

  • 能用 atomic 就用 atomic

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

  • atomic 搞不定的,就上 mutex

    尤其是涉及多步逻辑 / 复杂数据结构的整体一致性。

例如**(整个区间)**:

cpp 复制代码
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 之后那套"可依赖的并发语义"。

相关推荐
赖small强1 小时前
【Linux C/C++ 开发】 GCC 编译过程深度解析指南
linux·c语言·c++·预处理·链接·编译·编译过程
想唱rap1 小时前
C++之unordered_set和unordered_map
c++·算法·哈希算法
Rock_yzh1 小时前
LeetCode算法刷题——54. 螺旋矩阵
数据结构·c++·学习·算法·leetcode·职场和发展·矩阵
shx66661 小时前
2.1.2 ROS2 C++ 示例
c++·ros2
lightqjx1 小时前
【C++】对set和map的使用
开发语言·数据结构·c++·stl
快乐zbc1 小时前
C++ 中 typedef 指针别名与 const 的坑
开发语言·c++
azoo2 小时前
cv::Mat 取元素引起的报错
c++·opencv·计算机视觉
一个不知名程序员www2 小时前
算法学习入门---list与算法竞赛中的链表题(C++)
c++·算法
Aevget2 小时前
从业务面板到多视图协同:QtitanDocking如何驱动行业级桌面应用升级
c++·qt·ui·ui开发·qt6.3