What is the Memory Model in C++11

共 1896字,需浏览 4分钟

 ·

2020-12-27 23:46

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 
2#include 
3#include 
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,它所支持的原子操作要比atomic_flag多。
一个简单的同步操作:

1#include 
2#include 
3#include 
4#include 
5#include 
6#include 
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的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

浏览 52
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报