深入解析:深入剖析C++内存模型:超越原子性的多线程编程基石

news/2025/9/18 14:58:57/文章来源:https://www.cnblogs.com/wzzkaifa/p/19098801

在单线程时代,代码执行的世界是简单、有序的。一行代码执行完后紧接着下一行,我们无需担心指令会以意想不到的顺序执行。然而,当我们踏入多线程的领域,尤其是现代多核处理器架构下,这个世界变得复杂而诡异。编译器为了优化可能重排指令,CPU为了效率也可能乱序执行并将数据缓存在层级缓存中。这使得一个线程中的写入操作,在其他线程看来,可能并非按照我们代码编写的顺序发生。

std::atomic 的出现,不仅仅是为了解决原子操作(如原子递增)的问题,其更核心、更强大的意义在于它允许程序员通过指定内存顺序(Memory Order)来精确控制非原子内存的同步方式。理解这一点,是写出正确、高效多线程代码的关键。

1. 为何需要内存模型?重排的幽灵

考虑以下经典代码片段:

// 线程 1
x = 42;
// (1)
y = 1;
// (2)
// 线程 2
if (y == 1) {
// (3)
assert(x == 42);
// (4) 这个断言可能会失败吗?
}

在单线程视角下,x 肯定先被赋值为 42,然后 y 被赋值为 1。因此,如果线程2看到 y == 1,那么 x 必然等于 42

然而,在多核世界中,这个断言可能会失败!原因如下:

  1. 编译器重排:编译器可能发现先执行 (2) 再执行 (1) 效率更高(例如,x 不在寄存器中而 y 在),因此交换了它们的顺序。
  2. CPU重排:即使编译器没有重排,CPU也可能为了性能(如缓存命中率)而乱序执行指令。线程1的写入 xy 可能被缓存在不同的缓存行,并以不同的顺序刷新到主内存(或其他核心的缓存)。
  3. 可见性问题:线程2可能看到了线程1对 y 的更新,但由于缓存一致性协议(如MESI)的延迟,尚未看到对 x 的更新。

C++内存模型提供了一套可移植的抽象,让我们能够精确地描述一个线程中的内存操作如何与另一个线程中的操作进行“同步”和“排序”,从而驯服这些重排,让多线程程序具有可预测的行为。

2. std::atomic 与内存顺序

std::atomic<T> 确保了对 T 的操作是原子的、不可分割的。但更重要的是,每一次原子操作都可以选择一种内存顺序(Memory Order),它定义了该操作周围的非原子内存访问的可见性关系。

C++标准定义了6种内存顺序,但它们可以归纳为3大类:

| 内存顺序 | 枚举值 | 说明 |
| :— | :— | :— |
| 顺序一致性 (Sequentially Consistent) | memory_order_seq_cst | 最强约束。提供全局唯一执行顺序,性能开销最大。 |
| 获取-释放 (Acquire-Release) | memory_order_acquire
memory_order_release
memory_order_acq_rel | 成对使用,在配对线程间建立同步关系。开销中等。 |
| 松散 (Relaxed) | memory_order_relaxed | 只保证原子性,不提供任何同步和排序约束。开销最小。 |

2.1 顺序一致性 (memory_order_seq_cst)

这是默认的内存顺序,也是最强的一种。它做了两件事:

  1. 原子性:保证操作本身是原子的。
  2. 全局顺序:整个程序中的所有 seq_cst 操作形成一个单一的、全局一致的修改顺序(Total Modification Order)。每个线程都仿佛按照这个全局顺序依次执行这些操作。

开销:为了实现全局顺序,通常需要完整的内存栅栏(Memory Fence),这会阻止编译器和大部份CPU的重排,并强制刷新缓存,因此开销最大。

例子

std::atomic<
bool> x, y;
std::atomic<
int> z;
void write_x() {
x.store(true, std::memory_order_seq_cst);
// (1)
}
void write_y() {
y.store(true, std::memory_order_seq_cst);
// (2)
}
void read_x_then_y() {
while (!x.load(std::memory_order_seq_cst));
// (3)
if (y.load(std::memory_order_seq_cst)) // (4)
++z;
}
void read_y_then_x() {
while (!y.load(std::memory_order_seq_cst));
// (5)
if (x.load(std::memory_order_seq_cst)) // (6)
++z;
}
// 最终 z 不可能为 0。
// 因为 (1) 和 (2) 有一个全局顺序。假设是先 (1) 后 (2)。
// 那么如果线程C在 (3) 看到 x=true,那么对于也使用 seq_cst 的线程D来说,它也必须在这个全局顺序中看到 (1) 发生在 (2) 之前。
// 因此,如果线程D在 (5) 看到 y=true,那么它接下来在 (6) 看到的 x 也必然为 true。
// 所以 z 最终至少为 1,甚至为 2,但绝不会是 0。
2.2 获取-释放语义 (memory_order_acquire, memory_order_release, memory_order_acq_rel)

这套模型在线程间成对地建立同步关系,而不是追求全局顺序。它更轻量,也更需要程序员谨慎推理。

  • store 操作使用 release释放操作。在该操作之前的所有内存写入(包括非原子和松散的原子写入),都不能被重排到该操作之后
  • load 操作使用 acquire获取操作。在该操作之后的所有内存读取(包括非原子和松散的原子读取),都不能被重排到该操作之前
  • read-modify-write 操作(如 fetch_add)使用 acq_rel:同时具备获取和释放语义。

当一个 获取操作 读取到一个由 释放操作 写入的值时,就发生了一次 同步(Synchronizes-with)。这次同步建立后,释放操作之前的所有写操作,都对 获取操作之后的所有读操作 可见。

开销:通常只需要阻止编译器重排和特定类型的CPU重排(如StoreLoad重排可能不需要),开销比 seq_cst 小。

例子(自旋锁)

class SpinLock
{
std::atomic<
bool> flag{
false
};
public:
void lock() {
while (flag.exchange(true, std::memory_order_acquire)) {
// (1) 获取
// 自旋等待
}
}
void unlock() {
flag.store(false, std::memory_order_release);
// (2) 释放
}
};
// 用法
SpinLock mutex;
int data = 0;
void thread_func() {
mutex.lock();
// (1) 获取锁,同时也“获取”了之前持有锁的线程的所有写入
data++;
// 临界区操作,保证不会被重排到 lock() 之前
mutex.unlock();
// (2) 释放锁,同时也“释放”了对 data 的修改,确保对下一个获取锁的线程可见
}

unlock() 中的 release store 与 lock() 中的 acquire load(通过 exchange)成功配对。这保证了临界区(data++)内的操作不会“泄漏”到锁外,并且对下一个获得锁的线程是立即可见的。

2.3 松散顺序 (memory_order_relaxed)

只保证操作本身的原子性(不会读写的中间状态),不提供任何同步和排序保证。周围的操作可以被自由重排。

用途:用于简单的计数器更新,其中顺序无关紧要,只需要最终结果正确。例如,收集统计数据。

开销:最小,通常与普通指令开销无异。

危险例子

std::atomic<
bool> x, y;
std::atomic<
int> z;
void write_x_then_y() {
x.store(true, std::memory_order_relaxed);
// (1)
y.store(true, std::memory_order_relaxed);
// (2) 可能被重排到 (1) 之前!
}
void read_y_then_x() {
while (!y.load(std::memory_order_relaxed));
// (3)
if (x.load(std::memory_order_relaxed)) // (4)
++z;
}
// z 有可能为 0!
// 因为 (1) 和 (2) 之间没有顺序约束,CPU/编译器可能先执行 (2)。
// 线程B可能在 (3) 看到 y=true 后,在 (4) 却看到 x=false(因为 (1) 的更新尚未可见)。

3. 实战分析:Dekker算法与内存顺序

Dekker算法是一个经典的软件互斥算法,它要求严格的内存顺序才能正确工作。我们用它来展示不同内存顺序带来的影响。

Dekker算法核心

std::atomic<
bool> flag1{
false
}, flag2{
false
};
std::atomic<
int> turn{
1
};
void thread1(int& counter) {
flag1.store(true, memory_order);
// A1
while (flag2.load(memory_order)) {
// A2
if (turn.load(memory_order) != 1) {
// A3
flag1.store(false, memory_order);
// A4
while (turn.load(memory_order) != 1) {
} // A5
flag1.store(true, memory_order);
// A6
}
}
// 临界区开始
counter++;
// 临界区结束
turn.store(2, memory_order);
// A7
flag1.store(false, memory_order);
// A8
}
void thread2(int& counter) {
flag2.store(true, memory_order);
// B1
while (flag1.load(memory_order)) {
// B2
if (turn.load(memory_order) != 2) {
// B3
flag2.store(false, memory_order);
// B4
while (turn.load(memory_order) != 2) {
} // B5
flag2.store(true, memory_order);
// B6
}
}
// 临界区开始
counter++;
// 临界区结束
turn.store(1, memory_order);
// B7
flag2.store(false, memory_order);
// B8
}

场景分析

假设我们全部使用 memory_order_relaxed

  1. 线程1执行 A1(设置 flag1=true)。
  2. 线程2执行 B1(设置 flag2=true)。
  3. 线程1执行 A2,检查 flag2true,进入循环。
  4. 线程1执行 A3,检查 turn 不为1(初始为1,但可能线程2已经修改?这里先假设还没改),所以不进入if。
  5. 线程2执行 B2,检查 flag1true,进入循环。
  6. 线程2执行 B3,检查 turn 为1(不等于2),进入if块。
  7. 线程2执行 B4,设置 flag2=false
  8. 关键问题:线程1此时可能正在执行 A2 的循环条件检查。由于 flag2 的存储是 relaxed,线程1可能看不到这个更新,或者即使看到了,线程1的 A3 检查 turn 也可能被重排到 A2 之前!这会导致线程1错误地认为 flag2 仍然为真,并且 turn 仍然是1,从而跳过if块,直接进入临界区。
  9. 同时,线程2在 B5 等待 turn 变为2。而线程1也进入了临界区。
    结果:两个线程同时进入临界区,算法失败。

如何修复?

必须使用更强的内存顺序来建立正确的同步:

  • A1B1store 必须是 release,以确保它们之前的操作(如果有)不会重排到后面。
  • A2B2load 必须是 acquire,以确保它们能看到对方 release 的写入,并且它们之后的操作不会重排到前面。
  • A7/B7(修改 turn)的 store 应该是 release,以确保退出临界区的操作先于 turn 的修改。
  • A5/B5load 应该是 acquire,以确保在获得 turn 的所有权后,能正确看到对方线程在释放 turn 之前的所有操作(即对方临界区的修改结果)。

实际上,最安全省事的方法是在这个精细的算法中使用 memory_order_seq_cst,因为它最符合算法设计时隐含的全局顺序假设。而 acquire-release 需要更精细地在每个操作上标注,难度极大。

4. 总结与建议

  • 默认使用 memory_order_seq_cst:除非你有充分的理由和信心,否则坚持使用默认的顺序一致性模型。它是正确的,虽然可能不是最快的。
  • 理解 Acquire-Release:当你需要在线程间建立明确的“同步点”(如锁、信号量)时,这是性能与正确性的最佳平衡点。仔细区分“加载(获取)”和“存储(释放)”操作。
  • 极端谨慎使用 Relaxed:仅当你非常确定操作的顺序和可见性完全不影响程序逻辑时(如计数器、指针的发布),才使用它。这是专家工具。
  • 借助高级抽象:大多数情况下,你不需要直接使用 std::atomic 和内存顺序来编写复杂的同步原语。优先使用标准库提供的互斥锁(std::mutex)、条件变量(std::condition_variable)等高级工具,它们已经为你正确实现了底层的内存同步。std::atomic 用于在这些工具无法满足性能需求时,进行极致的优化和实现无锁数据结构。

C++内存模型是现代多线程编程的底层基石。理解它,你就能真正洞察多线程程序中数据流动的奥秘,从而写出既正确又高效并发代码。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mzph.cn/news/907187.shtml

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

Spring Security 框架 - 教程

pre { white-space: pre !important; word-wrap: normal !important; overflow-x: auto !important; display: block !important; font-family: "Consolas", "Monaco", "Courier New", …

Android 安卓 困难处理记录 腾讯IM和厂商离线推送难题 点击离线推送无法唤醒APP启动页但某些Service服务和Application被启动

Android 安卓 困难处理记录 腾讯IM和厂商离线推送难题 点击离线推送无法唤醒APP启动页但某些Service服务和Application被启动pre { white-space: pre !important; word-wrap: normal !important; overflow-x: auto !im…

百度地图如何获取瓦片图

百度地图如何获取瓦片图1.根据百度地图的经度和纬度来获取瓦片图的 x、y坐标值。 使用第三方javascript库,已经有牛人实现了。 tile-lnglat-transform-es6 如果想表现一下自己很牛,也可以自己去根据思路是实现。 使…

Codeforces Round 1051 (Div 2)

cf1051 Div2 ABCD1D2E题解Problem - A. All Lengths Subtraction 思路: 我们希望 n 和 n - 1 相邻,n - 1, n 和 n - 2 相邻 ... 不断往外扩展 所以我们可以维护 l 和 r 表示当前扩展到了哪里 通过判断下一个数是否和…

scheduleAtFixedRate

定时任务中的scheduleAtFixedRate方法 在Java的并发编程中,scheduleAtFixedRate是ScheduledExecutorService接口中的一个方法,用于在给定的初始延迟后,以固定的周期执行所提交的任务。这个方法非常适合需要多次执行…

redis-string类型常用命令

redis-string类型常用命令String类型value是Redis中最常用,最基本的类型,String类型的value可以存放任意类型数据,包括数值型,二进制的图片,音频,视频,序列化对象等等。一个String类型的value最大时521M。 1.se…

CRMEB标准版PHP核销功能深度解析,附权限配置技巧

订单核销,使用核销码或立即核销进行核销 核销订单:用户购买商品时选择到店自提的订单。到店后需要出示核销码以供核销。 1、收入核销码核销 订单—>订单管理—>立即核销在订单列表页,点击左上角订单核销按钮,…

一文详细说明大模型安全评估要怎么做

一文详细说明大模型安全评估要怎么做《网络安全技术 生成式人工智能服务安全基本要求》 《基本要求》是大模型安全总纲性文件,提纲挈领地指出模型备案上线所需具备的基础条件,是大模型备案技术性指导文件《生成式人工…

apache doris 和 clickhouse的区别

Apache Doris 和 ClickHouse 均为 MPP(大规模并行处理)架构的列式存储 OLAP 数据库,核心定位都是解决海量数据下的高性能分析查询场景,但二者在技术设计、生态适配、适用场景等维度存在显著差异。以下从 核心架构、…

Python numba jit加速计算

安装pip install numba使用示例import timefrom numba import jit# 原始函数 def python_sum(n):total = 0for i in range(n):total += ireturn total# Numba 加速版本 @jit(nopython=True) def numba_sum(n):total = …

人机协作开发新体验:花两天时间与Cursor共同打造一个微信小程序

前言 在过去的几天里,我完成了一个完整的微信小程序项目——双色球机选应用。 这个项目的独特之处在于,所有的代码编写工作都是由 Cursor 完成的,而我主要负责需求分析、功能规划和调试测试。项目概述 应用功能 我开…

OEC-Turbo刷群晖Armbian流程记录

记录OEC-Turbo的刷机流程,为以后反复折腾做参考。 设备版本:OEC L2.0,不清楚1.0和2.0的区别 系统:Windows 11 准备工具瑞芯微驱动 瑞芯微烧录工具 Loader文件 固件 镊子 Type-C数据线工具下载链接:https://pan.qu…

01_网络分层模型

一、OSI 七层网络模型 所谓七层就是基于 URL 等应用层信息的负载均衡,四层就是基于 IP + 端口的负载均衡,同样的还有基于二层 MAC 地址,三层 IP 地址的负载均衡。 而 OSI(Open System Interconnection,开放式通信互…

SaaS 是什么?一文带你看懂 SaaS 与传统软件的区别

SaaS 发音类似于「萨斯」,是 Software as a Service 的缩写,直译过来就是「软件即服务」。你可以这样理解: 在 SaaS 模式下,软件变得和水电气很相似,你只需要每月缴纳固定的费用即可享受服务。再举个比较具体的例…

FreeCAD-即时入门-全-

FreeCAD 即时入门(全)原文:zh.annas-archive.org/md5/ba46ce5f33da4fa68df84701f1baaf8a 译者:飞龙 协议:CC BY-NC-SA 4.0前言 FreeCAD 是一个面向工程世界的通用建模工具。与为动画师和艺术家设计的其他建模工具…

UOS统信服务器操作系统V20(1070)安装mysql8.0.41(建议安装glibc2.28版本)

环境:OS:UOS Server 20 统信服务器操作系统V20(1070)mysql:8.0.41 glib.2.17 操作系统下载https://www.chinauos.com/resource/download-server查看系统glibc版本[root@localhost yum.repos.d]# ldd --versionldd (GNU…

MyEMS:重新定义人与能源的关系 —— 一场藏在数据里的能源管理革命

能源,这个推动现代文明运转却始终隐形的主角,正通过数字技术与我们建立全新的对话方式。MyEMS作为开源能源管理系统,正在悄然引领这场变革——它不仅改变我们管理能源的方式,更在重新定义人与能源之间的关系。 从被…

TJOI2007--线段

题目传送门代码点击查看代码 #include<bits/stdc++.h> using namespace std; const int N=2e4+10; int n; int l[N],r[N],len[N]; int dp[N][2]; //dp[i][0]表示停留在本行左端点 //那么就要到右端点在再回到左…

KEITHLEY 数字万用表 能测试电阻吗

KEITHLEY 数字万用表 能测试电阻吗KEITHLEY 数字万用表(DMM, Digital Multimeter) 都具备 电阻测量功能。 🔹 一般 KEITHLEY 的 DMM(如 DMM6500、DMM7510、2000/2100 系列 等)都有以下功能:直流电压 DCV交流电压…