What is the Memory Model in C++11

C语言与CPP编程 2020-12-24 00:00

C++11其实主要就四方面内容,第一个是可变参数模板,第二个是右值引用,第三个是智能指针,第四个是内存模型(Memory Model)。

相对来说,这也是较难理解的几个特性,分别针对于泛型编程,内存优化,内存管理和并发编程。

并发编程是个非常大的模块,而在诸多内容底下有一个基本的概念,就是并发内存模型(Memory Model)。

那么,什么是内存模型?

1
Memory Model

早在之前介绍并发编程的文章中,我们就知道同步共享数据很重要。而同步可分为两种方式:原子操作和顺序约束。

原子操作是数据操作的最小单元,天生不可再分;顺序约束可以协调各个线程之间数据访问的先后顺序,避免数据竞争。

通常的同步方式会有两个问题,一是效率不够,二是死锁问题。导致效率不够是因为这些方式都是lock-based的。

当然,若非非常在意效率,完全可以使用这些同步方式,因其简单方便且不易出错。

若要追求更高的效率,需要学习lock-free(无锁)的同步方式。

内存模型,简单地说,是一种介于开发者和系统之间的并发约定,可以无锁地保证程序的执行逻辑与预期一致。

这里的系统包括编译器、处理器和缓存,各部分都想在自己的领域对程序进行优化,以提高性能,而这些优化会打乱源码中的执行顺序。尤其是在多线程上,这些优化会对共享数据造成巨大影响,导致程序的执行结果往往不遂人意。

内存模型,就是来解决这些优化所带来的问题。主要包含三个方面:

  • Atomic operations(原子操作)

  • Partial ordering of operations(局部执行顺序)

  • Visible effects of operations(操作可见性)

原子操作和局部执行顺序如前所述,「操作可见性」指的是不同线程之间操作共享变量是可见的。

原子数据的同步是由编译器来保证的,而非原子数据需要我们自己来规划顺序。

2

关系定义

这里有三种关系术语,

  • sequenced-before

  • happens-before

  • synchronizes-with

同一线程语句之间,若A操作在B操作之前执行,则表示为A sequenced-before B,A的执行结果对B可见。
而在不同线程的语句之间,若A操作在B操作之前就已发生,则表示为A happens-before B。该关系具有可传递性,也就是说,若A happens-before B,B happens-before C,则一定能得出A happens-before C。
若A操作的状态改变引发了B操作的执行,则表示为A synchronizes-with B。比如我们学过的事件、条件变量、信号量等等都会因一个条件(状态)满足,而执行相应的操作,这种状态关系就叫做synchronizes-with。
由于synchronizes-with的特性,可以借其实现happens-before关系。
内存模型就是提供一个操作的约束语义,借其可以满足上述关系,实现了顺序约束。

3

Atomics(原子操作)

原子操作的知识之前也介绍过,限于篇幅,便不再捉细节。
先来整体看一下原子操作支持的操作类型,后面再来讲应用。
这里挑两个来介绍一下相关操作,算是回顾。
第一个来讲atomic_flag,这是最简单的原子类型,代表一个布尔标志,可用它实现一个自旋锁:

1#include <atomic>
2#include <thread>
3#include <iostream>
4
5class spin_lock
6{

7    std::atomic_flag flag = ATOMIC_FLAG_INIT;
8public:
9    void lock() while(flag.test_and_set()); }
10
11    void unlock() { flag.clear(); }
12};
13
14spin_lock spin;
15int g_num = 0;
16void work()
17
{
18    spin.lock();
19
20    g_num++;
21
22    spin.unlock();
23}
24
25int main()
26
{
27    std::thread t1(work);
28    std::thread t2(work);
29    t1.join();
30    t2.join();
31
32    std::cout << g_num;
33
34    return 0;
35}

atomic_flag必须使用ATOMIC_FLAG_INIT初始化,该值就是0,也就是false。
只能通过两个接口来操作atomic_flag:
  • clear:清除操作,将值设为false。

  • test_and_set:将值设为true并返回之前的值。


第9行的lock()函数实现了自旋锁,当第一个线程进来的时候,由于atomic_flag为false,所以会通过test_and_set设置为true并返回false,第一个线程于是可以接着执行下面的逻辑。

当第二个线程进来时,flag为true,因此会一直循环,只有第一个线程中unlock了才会接着执行。由此保证了共享变量g_num。
第二个来讲atomic<bool>,它所支持的原子操作要比atomic_flag多。
一个简单的同步操作:

1#include <atomic>
2#include <thread>
3#include <iostream>
4#include <vector>
5#include <algorithm>
6#include <iterator>
7
8std::atomic<bool> flag{false};
9std::vector<int> shared_values;
10void work()
11
{
12    std::cout << "waiting" << std::endl;
13    while(!flag.load())
14    {
15        std::this_thread::sleep_for(std::chrono::milliseconds(5));
16    }
17
18    shared_values[1] = 2;
19    std::cout << "end of the work" << std::endl;
20}
21
22void set_value()
23
{
24    shared_values = { 789 };
25    flag = true;
26    std::cout << "data prepared" << std::endl;
27}
28
29int main()
30
{
31    std::thread t1(work);
32    std::thread t2(set_value);
33    t1.join();
34    t2.join();
35
36    std::copy(shared_values.begin(), shared_values.end(), std::ostream_iterator<int>(std::cout" "));
37
38    return 0;
39}

这里有两个线程,它们之间拥有执行顺序,只有先在set_value函数中设置好共享值,才能在work函数中修改。
通过flag的load函数可以获取原子值,在值未设置完成时其为false,所以会一直等待数据到来。当flag变为true时,表示数据已经设置完成,于是会继续工作。

4

Memory ordering(内存顺序)

是什么保证了上述原子操作能够在多线程环境下同步执行呢?

其实在所有的原子操作函数中都有一个可选参数memory_order。比如atomic<bool>的load()和store()原型如下:

bool std::_Atomic_bool::load(std::memory_order _Order = std::memory_order_seq_cst) const noexcept
void std::_Atomic_bool::store(bool _Value, std::memory_order _Order = std::memory_order_seq_cst) noexcept

这里的可选参数默认为memory_order_seq_cst,所有的memory_order可选值为:

enum memory_order {

    memory_order_relaxed,
    memory_order_consume,
    memory_order_acquire,
    memory_order_release,
    memory_order_acq_rel,
    memory_order_seq_cst
};

这就是C++提供的如何实现顺序约束的方式,通过指定特定的memory_order,可以实现前面提及的sequence-before、happens-before、synchronizes-with关系。

顺序约束是我们和系统之间的一个约定,约定强度由强到弱可以分为三个层次:

  • Sequential consistency(顺序一致性): memory_order_seq_cst
  • Acquire-release(获取与释放): memory_order_consume,memory_order_acquire,memory_order_release,memory_order_acq_rel
  • Relaxed(松散模型): memory_order_relaxed

Sequential consistency保证所有操作在线程之间都有一个全局的顺序,Acquire-release保证在不同线程间对于相同的原子变量的写和读的操作顺序,Relaxed仅保证原子的修改顺序。

为何要分层次呢?

其实顺序约束和系统优化之间是一种零和博弈,约束越强,系统所能够做的优化便越少。

因此每个层次拥有效率差异,层次越低,优化越多,效率也越高,不过掌握难度也越大。

所有的Memory order按照操作类型,又可分为三类:

  • Read(读):memory_order_acquire,memory_order_consume

  • Write(写):memory_order_release

  • Read-modify-Write(读-改-写):memory_order_acq_rel,memory_order_seq_cst

Relaxed未定义同步和顺序约束,所以要单独而论。

例如load()就是Read操作,store()就是Write()操作,compare_exchange_strong就是Read-modify-Write操作。

这意味着你不能将一个Read操作的顺序约束,写到store()上。例如,若将memory_order_acquire写到store()上,不会产生任何效果。

我们先来从默认的Sequential consistency开始,往往无需设置,便默认是memory_order_seq_cst,可以写一个简单的生产者-消费者函数:

1std::string sc_value;
2std::atomic<bool> ready{false};
3
4void consumer()
5
{
6    while(!ready.load()) {}
7
8    std::cout << sc_value << std::endl;
9}
10
11void producer()
12
{
13    sc_value = "produce values";
14    ready = true;
15}
16
17int main()
18
{
19    std::thread t1(consumer);
20    std::thread t2(producer);
21    t1.join();
22    t2.join();
23
24    return 0;
25}

此时,执行顺序具有强保证性,一定是先执行了producer再执行的consumer。
用标准的关系术语来说就是,第13行的操作和第14行的操作是sequenced-before关系,第14行和第6行的操作是synchronizes-with关系,进而保证了14行的赋值操作一定在第6行的load()操作之前执行,也就是保证了happens-before关系。
Acquire-release就开始变得有些复杂,我们先以一个最简单的例子来看。

1class spin_lock
2{

3    std::atomic_flag flag = ATOMIC_FLAG_INIT;
4public:
5    spin_lock() {}
6
7    void lock() while(flag.test_and_set(std::memory_order_acquire)); }
8
9    void unlock() { flag.clear(std::memory_order_release); }
10};
11
12spin_lock spin;
13void work()
14
{
15    spin.lock();
16    // do something
17    spin.unlock();
18}
19
20int main()
21
{
22    std::thread t1(work);
23    std::thread t2(work);
24    t1.join();
25    t2.join();
26
27    return 0;
28}

clear()中使用了release,test_and_set()中使用了acquire,acquire和release操作之间是synchronizes-with的关系。

它的行为和之前使用sequential consistency默认参数的自旋锁一样,不过要更加轻便高效。

test_and_set()操作其实是个Read-modify-Write操作,不过依旧可以使用acquire操作。release禁止了所有在它之前或之后的写操作乱序,acquire禁止了所有在它之前或之后的读操作乱序。

在两个不同的线程之间,共同访问同一个原子是flag,所添加的顺序约束就是为了保证flag的修改顺序。

我们再来看一个更清晰的例子:

1std::atomic<bool> x{false}, y{false};
2std::atomic<int> z{0};
3void write()
4
{
5    // relaxed只保证修改顺序
6    x.store(truestd::memory_order_relaxed);
7
8    // release保证在它之前的所有写操作顺序一致
9    y.store(truestd::memory_order_release);
10}
11
12void read()
13
{
14    // acquire保证在它之前和之后的读操作顺序一致
15    while(!y.load(std::memory_order_acquire));
16
17    // relaxed只保证修改顺序
18    if(x.load(std::memory_order_relaxed))
19        ++z;
20}
21
22int main()
23
{
24    std::thread t1(write);
25    std::thread t2(read);
26    t1.join();
27    t2.join();
28
29    assert(z.load() != 0);
30
31    return 0;
32}

注意这是使用了relaxed、release和acquire三种约束。
relaxed只保证修改顺序,所以对于write()函数来说,一定是先执行x后执行y操作。不过若是将y也使用relaxed,虽然在write()中是先x后y的顺序,而在read()的眼中,可能是先y后x的顺序,这是优化导致的。
而因为y的读和写使用了acquire和release约束,所以可以保证在不同线程间对于相同的原子变量读和写的操作顺序一致。
同时,Acquire-release操作还拥有传递性,是典型的happens-before关系。
还是提供一个例子:

1std::vector<int> shared_value;
2std::atomic<bool> produced{false};
3std::atomic<bool> consumed{false};
4
5void producer()
6
{
7    shared_value = { 789 };
8
9    // A. realse happens-before B
10    produced.store(truestd::memory_order_release);
11}
12
13void delivery()
14
{
15    // B. acquire,A synchronizes with B
16    while(!produced.load(std::memory_order_acquire));
17
18    // B. release happens-beforeC
19    consumed.store(truestd::memory_order_release);
20}
21
22void consumer()
23
{
24    // C. acquire, B synchronizes with C
25    // therefore, A happens before C
26    while(!consumed.load(std::memory_order_acquire));
27
28    shared_value[1] = 2;
29}
30
31int main()
32
{
33    std::thread t1(consumer);
34    std::thread t2(producer);
35    std::thread t3(delivery);
36    t1.join();
37    t2.join();
38    t3.join();
39
40    std::copy(shared_value.begin(), shared_value.end(), std::ostream_iterator<int>(std::cout" "));
41
42    return 0;
43}

注释已经足够说明其中所以,便不细述。

5

Fences(栅栏)

看回先前的一个例子:

1std::atomic<bool> x{false}, y{false};
2std::atomic<int> z{0};
3void write()
4
{
5    // relaxed只保证修改顺序
6    x.store(truestd::memory_order_relaxed);
7    y.store(truestd::memory_order_relaxed);
8}
9
10void read()
11
{
12    // relaxed只保证修改顺序
13    while(!y.load(std::memory_order_relaxed));
14    if(x.load(std::memory_order_relaxed))
15        ++z;
16}


relaxed是最弱的内存模型,此处全使用relaxed,顺序将不再有保证。

也许在read()中看到的write()操作是先y后x,那么此时read()里面的if操作便无法满足,也就是说,++z不会被执行。

解决方法是结合fences来使用,只需添加两行代码:

1std::atomic<bool> x{false}, y{false};
2std::atomic<int> z{0};
3void write()
4
{
5    // relaxed只保证修改顺序
6    x.store(truestd::memory_order_relaxed);
7
8    std::atomic_thread_fence(std::memory_order_release);
9
10    y.store(truestd::memory_order_relaxed);
11}
12
13void read()
14
{
15    // relaxed只保证修改顺序
16    while(!y.load(std::memory_order_relaxed));
17
18    std::atomic_thread_fence(std::memory_order_acquire);
19
20    if(x.load(std::memory_order_relaxed))
21        ++z;
22}

fences位于relaxed操作之间,它像一个栅栏一样,可以保证前后的操作不会乱序。
具体细节,接着来看。
C++提供了两个类型的fences,
  • std::atomic_thread_fence:同步线程之间的内存访问。

  • std::atomic_signal_fence:同步同一线程上的signal handler和code running。

我们主要学习第一个线程的fence,它会阻止特定的操作穿过栅栏,约束执行顺序。

有三种类型的fences,

  • Full fence:阻止两个任意操作乱序。memory_order_seq_cst或memory_order_acq_rel。

  • Acquire fence:阻止读操作乱序,memory_order_acquire。

  • Release fence:阻止写操作乱序,memory_order_release。

用图来表示为:

图中间灰色的一杠就表示fence,红色表示禁止乱序,可以看到,除了Store-Load,其它操作都可以保障执行顺序。
同样也有效率差异,可以针对具体的操作来选择合适的fence。

6

总结

本篇内容挺复杂的,其实就包含三个方面:Atomic operations(原子操作)、Partial ordering of operations(局部执行顺序)和Visible effects of operations(操作可见性)。

面对一个复杂的概念,往往需要变换尺度来进行理解,若一开始便陷入诸多细节中去,难免迷失其中,看不到整体的结构。
所以这里其实也就是以我自己的理解来写,细节涉及不多,但整体结构已算完整,想了解更多具体细节可以参考C++ Concurrency in Action

C语言与CPP编程 C语言/C++开发,C语言/C++基础知识,C语言/C++学习路线,C语言/C++进阶,数据结构;算法;python;计算机基础等
评论
  • 彼得·德鲁克被誉为“现代管理学之父”,他的管理思想影响了无数企业和管理者。然而,关于他的书籍分类,一种流行的说法令人感到困惑:德鲁克一生写了39本书,其中15本是关于管理的,而其中“专门写工商企业或为企业管理者写的”只有两本——《为成果而管理》和《创新与企业家精神》。这样的表述广为流传,但深入探讨后却发现并不完全准确。让我们一起重新审视这一说法,解析其中的矛盾与根源,进而重新认识德鲁克的管理思想及其著作的真正价值。从《创新与企业家精神》看德鲁克的视角《创新与企业家精神》通常被认为是一本专为企业管
    优思学院 2025-01-06 12:03 119浏览
  • 每日可见的315MHz和433MHz遥控模块,你能分清楚吗?众所周知,一套遥控设备主要由发射部分和接收部分组成,发射器可以将控制者的控制按键经过编码,调制到射频信号上面,然后经天线发射出无线信号。而接收器是将天线接收到的无线信号进行解码,从而得到与控制按键相对应的信号,然后再去控制相应的设备工作。当前,常见的遥控设备主要分为红外遥控与无线电遥控两大类,其主要区别为所采用的载波频率及其应用场景不一致。红外遥控设备所采用的射频信号频率一般为38kHz,通常应用在电视、投影仪等设备中;而无线电遥控设备
    华普微HOPERF 2025-01-06 15:29 127浏览
  • 根据Global Info Research项目团队最新调研,预计2030年全球封闭式电机产值达到1425百万美元,2024-2030年期间年复合增长率CAGR为3.4%。 封闭式电机是一种电动机,其外壳设计为密闭结构,通常用于要求较高的防护等级的应用场合。封闭式电机可以有效防止外部灰尘、水分和其他污染物进入内部,从而保护电机的内部组件,延长其使用寿命。 环洋市场咨询机构出版的调研分析报告【全球封闭式电机行业总体规模、主要厂商及IPO上市调研报告,2025-2031】研究全球封闭式电机总体规
    GIRtina 2025-01-06 11:10 104浏览
  • 村田是目前全球量产硅电容的领先企业,其在2016年收购了法国IPDiA头部硅电容器公司,并于2023年6月宣布投资约100亿日元将硅电容产能提升两倍。以下内容主要来自村田官网信息整理,村田高密度硅电容器采用半导体MOS工艺开发,并使用3D结构来大幅增加电极表面,因此在给定的占位面积内增加了静电容量。村田的硅技术以嵌入非结晶基板的单片结构为基础(单层MIM和多层MIM—MIM是指金属 / 绝缘体/ 金属) 村田硅电容采用先进3D拓扑结构在100um内,使开发的有效静电容量面积相当于80个
    知白 2025-01-07 15:02 75浏览
  • PLC组态方式主要有三种,每种都有其独特的特点和适用场景。下面来简单说说: 1. 硬件组态   定义:硬件组态指的是选择适合的PLC型号、I/O模块、通信模块等硬件组件,并按照实际需求进行连接和配置。    灵活性:这种方式允许用户根据项目需求自由搭配硬件组件,具有较高的灵活性。    成本:可能需要额外的硬件购买成本,适用于对系统性能和扩展性有较高要求的场合。 2. 软件组态   定义:软件组态主要是通过PLC
    丙丁先生 2025-01-06 09:23 85浏览
  • 在智能家居领域中,Wi-Fi、蓝牙、Zigbee、Thread与Z-Wave等无线通信协议是构建短距物联局域网的关键手段,它们常在实际应用中交叉运用,以满足智能家居生态系统多样化的功能需求。然而,这些协议之间并未遵循统一的互通标准,缺乏直接的互操作性,在进行组网时需要引入额外的网关作为“翻译桥梁”,极大地增加了系统的复杂性。 同时,Apple HomeKit、SamSung SmartThings、Amazon Alexa、Google Home等主流智能家居平台为了提升市占率与消费者
    华普微HOPERF 2025-01-06 17:23 145浏览
  • By Toradex 秦海1). 简介嵌入式平台设备基于Yocto Linux 在开发后期量产前期,为了安全以及提高启动速度等考虑,希望将 ARM 处理器平台的 Debug Console 输出关闭,本文就基于 NXP i.MX8MP ARM 处理器平台来演示相关流程。 本文所示例的平台来自于 Toradex Verdin i.MX8MP 嵌入式平台。  2. 准备a). Verdin i.MX8MP ARM核心版配合Dahlia载板并
    hai.qin_651820742 2025-01-07 14:52 45浏览
  •     为控制片内设备并且查询其工作状态,MCU内部总是有一组特殊功能寄存器(SFR,Special Function Register)。    使用Eclipse环境调试MCU程序时,可以利用 Peripheral Registers Viewer来查看SFR。这个小工具是怎样知道某个型号的MCU有怎样的寄存器定义呢?它使用一种描述性的文本文件——SVD文件。这个文件存储在下面红色字体的路径下。    例:南京沁恒  &n
    电子知识打边炉 2025-01-04 20:04 100浏览
  • 根据环洋市场咨询(Global Info Research)项目团队最新调研,预计2030年全球无人机锂电池产值达到2457百万美元,2024-2030年期间年复合增长率CAGR为9.6%。 无人机锂电池是无人机动力系统中存储并释放能量的部分。无人机使用的动力电池,大多数是锂聚合物电池,相较其他电池,锂聚合物电池具有较高的能量密度,较长寿命,同时也具有良好的放电特性和安全性。 全球无人机锂电池核心厂商有宁德新能源科技、欣旺达、鹏辉能源、深圳格瑞普和EaglePicher等,前五大厂商占有全球
    GIRtina 2025-01-07 11:02 71浏览
  • 这篇内容主要讨论三个基本问题,硅电容是什么,为什么要使用硅电容,如何正确使用硅电容?1.  硅电容是什么首先我们需要了解电容是什么?物理学上电容的概念指的是给定电位差下自由电荷的储藏量,记为C,单位是F,指的是容纳电荷的能力,C=εS/d=ε0εrS/4πkd(真空)=Q/U。百度百科上电容器的概念指的是两个相互靠近的导体,中间夹一层不导电的绝缘介质。通过观察电容本身的定义公式中可以看到,在各个变量中比较能够改变的就是εr,S和d,也就是介质的介电常数,金属板有效相对面积以及距离。当前
    知白 2025-01-06 12:04 173浏览
  • 本文介绍Linux系统更换开机logo方法教程,通用RK3566、RK3568、RK3588、RK3576等开发板,触觉智能RK3562开发板演示,搭载4核A53处理器,主频高达2.0GHz;内置独立1Tops算力NPU,可应用于物联网网关、平板电脑、智能家居、教育电子、工业显示与控制等行业。制作图片开机logo图片制作注意事项(1)图片必须为bmp格式;(2)图片大小不能大于4MB;(3)BMP位深最大是32,建议设置为8;(4)图片名称为logo.bmp和logo_kernel.bmp;开机
    Industio_触觉智能 2025-01-06 10:43 87浏览
  • 大模型的赋能是指利用大型机器学习模型(如深度学习模型)来增强或改进各种应用和服务。这种技术在许多领域都显示出了巨大的潜力,包括但不限于以下几个方面: 1. 企业服务:大模型可以用于构建智能客服系统、知识库问答系统等,提升企业的服务质量和运营效率。 2. 教育服务:在教育领域,大模型被应用于个性化学习、智能辅导、作业批改等,帮助教师减轻工作负担,提高教学质量。 3. 工业智能化:大模型有助于解决工业领域的复杂性和不确定性问题,尽管在认知能力方面尚未完全具备专家级的复杂决策能力。 4. 消费
    丙丁先生 2025-01-07 09:25 80浏览
我要评论
0
点击右上角,分享到朋友圈 我知道啦
请使用浏览器分享功能 我知道啦