内存模型下的顺序一致性

什么是内存模型

在多核多线程环境下,多个CPU是如何以一种统一的方式来与内存交互的,这里包括内存地址对线程的可见性,cpu对内存的访问的顺序性。

关于乱序

我们先来看代码:

#include 
#include 

using namespace std;

bool ok = true;
int val = 0;
void foo() {
    while (1) {
        if (!ok) {
            cout << val << endl;
            break;
        }
    }
}

int main() {
    thread t1(foo);
    this_thread::sleep_for(
        std::chrono::milliseconds(20));

    val = 56;
    ok = false;

    t1.join();
    return 0;
}

输出的val值可能是0,这是由于主线程ok=false先于val=56执行,为什么会这样呢?
这样看起来感觉不对,但是对于cpu或者编译器来说单独线程中互不影响的指令可能对其重排以提高性能。至于指令重排和cpu乱序执行如何提高效率,我们这里不说,大家能够明白会有这样的现象。那么我们在多线程的情况下如何避免出现这种情况呢?

std::atomic

在c++11中,我们使用原子类型总能保证顺序的一致性,我们把上边的例子改写一下:

// snip...

atomic ok {true};
atomic val {0};
void foo() {
    while (1) {
        if (!ok.load()) {
            cout << val.load() << endl;
            break;
        }
    }
}

int main() {
    thread t1(foo);
    this_thread::sleep_for(
        std::chrono::milliseconds(20));

    val.store(56);
    ok.store(false);

    t1.join();
    return 0;
}

这样就能有效的保证指令执行的一致性。其中获取ok,val值时调用load,ok和val赋值会调用store,这样既保证了原子性又能保证顺序的一致性。原子性这里我们不讲。

设置执行顺序

大家以为这里已经讲完了,还远没有,我们看下store和load的函数原型:

_Tp load(memory_order __m = 
        memory_order_seq_cst) const _NOEXCEPT;

void store(_Tp __d, 
           memory_order __m = 
           memory_order_seq_cst) _NOEXCEPT

看到有默认参数,memory_order类型默认是memory_order_seq_cst,我们来展开讲一下:
memory_order是一个枚举,有6种类型:

typedef enum memory_order {
    memory_order_relaxed, 
    memory_order_consume, 
    memory_order_acquire,
    memory_order_release, 
    memory_order_acq_rel, 
    memory_order_seq_cst
} memory_order;

简单介绍下这几个类型:

  • memory_order_relaxed:表示该原子操作是松散的,可以被任意排序
  • memory_order_acquire:本线程中,代码顺序在本条原子操作之后的读操作,执行顺序不允许重排到本条原子操作之前
  • memory_order_release:本线程中,代码顺序在本条原子操作之前的写操作,执行顺序不允许重排到本条原子操作之后
  • memory_order_acq_rel:同时包含memory_order_acquire和memory_order_release的语义
  • memory_order_seq_cst:全部对内存的存取操作按代码顺序执行
  • memory_order_consume:本线程中,代码顺序在本条原子操作之后的本内存读操作,执行顺序无法重排到本指令之前。

针对这几个对执行顺序的约束条件,通常情况下顺序一致的,松散的,release-acquire,release-consume是最为典型的四种模型,我们重点看下release-acquire。我们再次把上边的例子改写一下:

// snip...

void foo() {
    while (1) {
        if (!ok.load(memory_order_acquire)) {
            cout << val.load(memory_order_relaxed) << endl;
            break;
        }
    }
}

int main() {
    thread t1(foo);
    this_thread::sleep_for(
            std::chrono::milliseconds(20));

    val.store(56, memory_order_relaxed);
    ok.store(false, memory_order_release);

    t1.join();
    return 0;
}

这样ok赋值时设置为memory_order_release,那么val的写操作就不会重排到ok赋值的后边,ok读取时设置为memory_order_acquire,那么val读操作就不会重排到ok读取的前边,这样较默认的参数提高了执行效率,我们也只是设置我们关心的读或者写操作的顺序性。

实例

以上只能作为一个讲解的例子存在,我们看下运用到实际中,关于一个单生产者单消费者队列(生产者只有一个线程,消费者只有一个线程)的情况:

template 
bool write(_Targs&&... args)
{
    assert(capacity() > 0);
    auto tail = _M_tail.load(std::memory_order_relaxed);
    auto new_tail = increment(tail);
    if (new_tail == _M_head.load(std::memory_order_acquire)) {
        return false;
    }
    allocator_traits::construct(_M_alloc, 
        std::addressof(*tail),
        std::forward(args)...);
    _M_tail.store(new_tail, 
        std::memory_order_release);
    return true;
}

bool read(reference dest)
{
    auto head = _M_head.load(std::memory_order_relaxed);
    if (head == _M_tail.load(std::memory_order_acquire)) {
        return false;
    }
    dest = std::move(*head);
    destroy(std::addressof(*head));
    _M_head.store(increment(head), 
        std::memory_order_release);
    return true;
}

在队列中可能存在竞争关系的是在队列为空还要读取时,在队列满了还要写入时。

代码逻辑

简单解释下以上代码,该队列时环形队列,这里是队列的关键地方代码,读取和写入时的。

我们先来看写入的时候,获取尾部的下一个,判断是否和队列的头相等,即判断是否满了,如果没满的话,构造对象加入队列,重新设置队列的尾部。

再来看读取的情况,判断头是否等于尾部,即判断是否空了,如果没空的话,赋值到对象的引用返回true,重新设置队列的头。

代码乱序

如果此种存在读取和写入的乱序,写函数里可能设置了新的尾部后构造对象,这样读取的就是有问题的数据,读函数里设置新的头部后destroy,就会导致写入等问题,关于读入等等吧,所以我们采用release-acquire模型,能够达到读取和写入的是顺序性。

参考

《深入理解C++11》

https://github.com/adah1972/nvwa

你可能感兴趣的:(C++,base)