详解c++ atomic原子编程中的Memory Order

概述

但是,基于内核对象的同步,会带来昂贵的上下文切换(用户态切换到内核态,占用1000个以上的cpu周期)。就需要使用另一种方法 —— 原子指令。

仅靠原子技术实现不了对资源的访问控制,即使简单计数操作,看上去正确的代码也可能会crash。

这里的关键在于编译器和cpu实施的重排指令导致了读写顺序的变化。只要没有依赖,代码中在后面的指令就可能跑到前面去,编译器和CPU都会这么做。

注1:单线程代码不需要关心乱序的问题。因为乱序至少要保证这一原则:不能改变单线程程序的执行行为

注2:内核对象多线程编程在设计的时候都阻止了它们调用点中的乱序(已经隐式包含memory barrier),不需要考虑乱序的问题。

注3:使用用户模式下的线程同步时,乱序的效果才会显露无疑。

程序员可以使用c++11 atomic提供了6种memory order,来在编程语言层面对编译器和cpu实施的重排指令行为进行控制

多线程编程时,通过这些标志位,来读写原子变量,可以组合出4种同步模型:

Relaxed ordering

Release-Acquire ordering

Release-Consume ordering

Sequentially-consistent ordering

默认情况下,std::atomic使用的是Sequentially-consistent ordering(最严格的同步模型)。但在某些场景下,合理使用其它3种ordering,可以让编译器优化生成的代码,从而提高性能。

Relaxed ordering

在这种模型下,std::atomic的load()和store()都要带上memory_order_relaxed参数。Relaxed ordering仅仅保证load()和store()是原子操作,除此之外,不提供任何跨线程的同步。

先看看一个简单的例子:

std::atomic<int> x = 0;     // global variable
std::atomic<int> y = 0;     // global variable

Thread-1:                                  Thread-2:
r1 = y.load(memory_order_relaxed); // A    r2 = x.load(memory_order_relaxed); // C
x.store(r1, memory_order_relaxed); // B    y.store(42, memory_order_relaxed); // D

执行完上面的程序,可能出现r1 == r2 == 42。理解这一点并不难,因为编译器允许调整 C 和 D 的执行顺序。

如果程序的执行顺序是 D -> A -> B -> C,那么就会出现r1 == r2 == 42。

如果某个操作只要求是原子操作,不需要其它同步的保障,就可以使用 Relaxed ordering。程序计数器是一种典型的应用场景。

#include <cassert>
#include <vector>
#include <iostream>
#include <thread>
#include <atomic>
std::atomic<int> cnt = {0};
void f()
{
    for (int n = 0; n < 1000; ++n) {
        cnt.fetch_add(1, std::memory_order_relaxed);
    }
}
int main()
{
    std::vector<std::thread> v;
    for (int n = 0; n < 10; ++n) {
        v.emplace_back(f);
    }
    for (auto& t : v) {
        t.join();
    }
    assert(cnt == 10000);    // never failed
    return 0;
}

Release-Acquire ordering

在这种模型下,store()使用memory_order_release,而load()使用memory_order_acquire。这种模型有两种效果,第一种是可以限制 CPU 指令的重排:

(1)在store()之前的所有读写操作,不允许被移动到这个store()的后面。 // write-release语义

(2)在load()之后的所有读写操作,不允许被移动到这个load()的前面。 // read-acquire语义

该模型可以保证:如果Thread-1的store()的那个值,成功被 Thread-2的load()到了,那么 Thread-1在store()之前对内存的所有写入操作,此时对 Thread-2 来说,都是可见的。

下面的例子阐述了这种模型的原理:

#include <thread>
#include <atomic>
#include <cassert>
#include <string>
std::atomic<bool> ready{ false };
int data = 0;
void producer()
{
    data = 100;                                       // A
    ready.store(true, std::memory_order_release);     // B
}
void consumer()
{
    while (!ready.load(std::memory_order_acquire))    // C
        ;
    assert(data == 100); // never failed              // D
}
int main()
{
    std::thread t1(producer);
    std::thread t2(consumer);
    t1.join();
    t2.join();
    return 0;
}

让我们分析一下这个过程:

首先 A 不允许被移动到 B 的后面。

同样 D 也不允许被移动到 C 的前面。

当 C 从 while 循环中退出了,说明 C 读取到了 B store()的那个值,此时,Thread-2 保证能够看见 Thread-1 执行 B 之前的所有写入操作(也即是 A)。

使用Release-Acquire ordering实现双重检查锁模式(DLCP)

下面单件为例来说明:

class Singleton
{
public:
    static Singleton* get_instance() {
        Singleton* tmp = instance_.load(std::memory_order_acquire);
        if (tmp == nullptr) {
            std::unique_lock<std::mutex> lk(mutex_);
            tmp = instance_;
            if (tmp == nullptr) {
                tmp = new Singleton();
                instance_.store(std::memory_order_release);
            }
        }
        return tmp;
    }

private:
    Singleton() = default;
    static std::atomic<Singleton*> instance_;
    static std::mutex mutex_;
};

使用Release-Acquire ordering实现自旋锁(Spinlock)

获取和释放语义,是实现锁的基础(Spinlock, Mutex, RWLock, ...),所有被[Read Acquire,Write Release]包含的区域,即构成了一个临界区,临界区里的内存操作,不会乱序到临界区之外执行。

            read-acquire(判断是否加锁,没则加锁,否则循环等待)

-------------------------------------------------------------------------

            all memory operation stay between the line(临界区)

-------------------------------------------------------------------------

                        write-release(释放锁)

实现代码如下:

#include <atomic>
class simple_spin_lock
{
public:
    simple_spin_lock() = default;
    void lock()
    {
        while (flag.test_and_set(std::memory_order_acquire))
            continue;
    }
    void unlock()
    {
        flag.clear(std::memory_order_release);
    }
private:
    simple_spin_lock(const simple_spin_lock&) = delete;
    simple_spin_lock& operator =(const simple_spin_lock&) = delete;
    std::atomic_flag flag = ATOMIC_FLAG_INIT;
};

①对std::atomic_flag的操作具有原子性,保证了同一时间,只有一个线程能够lock成功,其余线程全部在while循环

②使用了acquire内存屏障, 所以lock具有获取语义

③使用了release内存屏障, 所以unlock具有释放语义

Release-Consume ordering

在这种模型下,store()使用memory_order_release,而load()使用memory_order_consume。这种模型有两种效果,第一种是可以限制 CPU 指令的重排:

(1)在store()之前的所有读写操作,不允许被移动到这个store()的后面。

(2)在load()之后的所有依赖此原子变量的读写操作,不允许被移动到这个load()的前面。

注:不依赖此原子变量的读写操作可能会CPU指令重排

下面的例子阐述了这种模型的原理:

#include <thread>
#include <atomic>
#include <cassert>
#include <string>

std::atomic<std::string*> ptr;
int data;
// thread1
void producer()
{
    std::string* p  = new std::string("Hello"); // A
    data = 42; // B
    ptr.store(p, std::memory_order_release); // C
}
// thread2
void consumer()
{
    std::string* p2;
    while (!(p2 = ptr.load(std::memory_order_consume))) // D
        ;
    assert(*p2 == "Hello"); //E     always true: *p2 carries dependency from ptr
    assert(data == 42); // F     may be false: data does not carry dependency from ptr
}

int main()
{
    std::thread t1(producer);
    std::thread t2(consumer);
    t1.join();
    t2.join();
    return 0;
}

Sequentially-consistent ordering

所有以memory_order_seq_cst为参数的原子操作(不限于同一个原子变量),对所有线程来说有一个全局顺序(total order)

并且两个相邻memory_order_seq_cst原子操作之间的其他操作(包括非原子变量操作),不能reorder到这两个相邻操作之外

UE4下的Memory Order

enum class EMemoryOrder
{
    // Provides no guarantees that the operation will be ordered relative to any other operation.
    Relaxed,

    // Establishes a single total order of all other atomic operations marked with this.
    SequentiallyConsistent  // Load和Store函数缺省为该类型
};

详见:UnrealEngine\Engine\Source\Runtime\Core\Public\Templates\Atomic.h

Atomic相关的测试代码见:UnrealEngine\Engine\Source\Runtime\Core\Private\Tests\Misc\AtomicTest.cpp

以上就是详解c++ atomic原子编程中的Memory Order的详细内容,更多关于c++ atomic原子编程中的Memory Order的资料请关注我们其它相关文章!

(0)

相关推荐

  • c++11 atomic的使用详解

    std::atomic_flag std::atomic_flag是一个原子的布尔类型,可支持两种原子操作: test_and_set, 如果atomic_flag对象被设置,则返回true; 如果atomic_flag对象未被设置,则设置之,返回false clear. 清楚atomic_flag对象 std::atomic_flag可用于多线程之间的同步操作,类似于linux中的信号量.使用atomic_flag可实现mutex. #include <iostream> #include

  • 利用C++11原子量如何实现自旋锁详解

    一.自旋锁 自旋锁是一种基础的同步原语,用于保障对共享数据的互斥访问.与互斥锁的相比,在获取锁失败的时候不会使得线程阻塞而是一直自旋尝试获取锁.当线程等待自旋锁的时候,CPU不能做其他事情,而是一直处于轮询忙等的状态.自旋锁主要适用于被持有时间短,线程不希望在重新调度上花过多时间的情况.实际上许多其他类型的锁在底层使用了自旋锁实现,例如多数互斥锁在试图获取锁的时候会先自旋一小段时间,然后才会休眠.如果在持锁时间很长的场景下使用自旋锁,则会导致CPU在这个线程的时间片用尽之前一直消耗在无意义的忙等

  • C++11并发编程关于原子操作atomic的代码示例

    一:概述 项目中经常用遇到多线程操作共享数据问题,常用的处理方式是对共享数据进行加锁,如果多线程操作共享变量也同样采用这种方式. 为什么要对共享变量加锁或使用原子操作?如两个线程操作同一变量过程中,一个线程执行过程中可能被内核临时挂起,这就是线程切换,当内核再次切换到该线程时,之前的数据可能已被修改,不能保证原子操作. C++11提供了个原子的类和方法atomic,保证了多线程对变量原子性操作,相比加锁机制mutex.lock(),mutex.unlock(),性能有几倍的提升. 所需头文件<a

  • 详解C++中的内存同步模式(memory order)

    内存模型中的同步模式(memory model synchronization modes) 原子变量同步是内存模型中最让人感到困惑的地方.原子(atomic)变量的主要作用就是同步多线程间的共享内存访问,一般来讲,某个线程会创建一些数据,然后给原子变量设置标志数值(译注:此处的原子变量类似于一个flag);其他线程则读取这个原子变量,当发现其数值变为了标志数值之后,之前线程中的共享数据就应该已经创建完成并且可以在当前线程中进行读取了.不同的内存同步模式标识了线程间数据共享机制的"强弱"

  • 浅谈关于C++memory_order的理解

    看了c++并发编程实战的内存模型部分后,一直对memory_order不太懂,今天在知乎发现了百度的brpc,恰好有关于原子操作的文档,感觉解释的很好.为了加深理解,再次总结一遍. 在多核编程中,我们使用锁来避免多个线程修改同一个数据时产生的竞争条件.但是,锁会消耗系统资源,当锁成为性能瓶颈的时候,就需要使用另一种方法--原子指令.c++11中引入了原子类型atomic. 原子指令 (x均为std::atomic) 作用x.load()返回x的值.x.store(n)把x设为n,什么都不返回.x

  • C++11中的原子量和内存序详解

    一.多线程下共享变量的问题 在多线程编程中经常需要在不同线程之间共享一些变量,然而对于共享变量操作却经常造成一些莫名奇妙的错误,除非老老实实加锁对访问保护,否则经常出现一些(看起来)匪夷所思的情况.比如下面便是两种比较"喜闻乐见"的情况. (a) i++问题 在多线程编程中,最常拿来举例的问题便是著名的i++ 问题,即:多个线程对同一个共享变量i执行i++ 操作.这样做之所以会出现问题的原因在于i++这个操作可以分为三个步骤: step operation 1 i->reg(读取

  • 详解c++ atomic原子编程中的Memory Order

    概述 但是,基于内核对象的同步,会带来昂贵的上下文切换(用户态切换到内核态,占用1000个以上的cpu周期).就需要使用另一种方法 -- 原子指令. 仅靠原子技术实现不了对资源的访问控制,即使简单计数操作,看上去正确的代码也可能会crash. 这里的关键在于编译器和cpu实施的重排指令导致了读写顺序的变化.只要没有依赖,代码中在后面的指令就可能跑到前面去,编译器和CPU都会这么做. 注1:单线程代码不需要关心乱序的问题.因为乱序至少要保证这一原则:不能改变单线程程序的执行行为 注2:内核对象多线

  • 详解C#面相对象编程中的继承特性

    继承(加上封装和多态性)是面向对象的编程的三个主要特性(也称为"支柱")之一. 继承用于创建可重用.扩展和修改在其他类中定义的行为的新类.其成员被继承的类称为"基类",继承这些成员的类称为"派生类".派生类只能有一个直接基类.但是,继承是可传递的.如果 ClassB 派生出 ClassC,ClassA 派生出 ClassB,则 ClassC 会继承 ClassB 和 ClassA 中声明的成员. 注意 结构不支持继承,但可以实现接口. 从概念上来

  • 详解C语言面向对象编程中的封装

    目录 前言 一.面向对象基本概念 什么是对象? 对象与类 面向对象的编程方式 二.C语言实现面向对象 面向对象的三大特征 面向对象之封装 简介 代码实现–基础版 代码实现-进阶版 总结 前言 面向对象是一种思维方式,基本上用什么语言都是可以实现的.C语言的编程方式一般是面向过程的,但是也是可以实现面向对象的.对象是什么?什么又是面向对象?面向对象的三大特性又怎么实现,且听我细细道来. 一.面向对象基本概念 什么是对象? 此对象非彼对象,虽然有时候此对象又可以是你脑袋中的对象,那让我们从我们误解的

  • 详解Java图形化编程中的鼠标事件设计

    鼠标事件的事件源往往与容器相关,当鼠标进入容器.离开容器,或者在容器中单击鼠标.拖动鼠标时都会发生鼠标事件.java语言为处理鼠标事件提供两个接口:MouseListener,MouseMotionListener接口. MouseListener接口 MouseListener接口能处理5种鼠标事件:按下鼠标,释放鼠标,点击鼠标.鼠标进入.鼠标退出.相应的方法有: (1) getX():鼠标的X坐标 (2) getY():鼠标的Y坐标 (3) getModifiers():获取鼠标的左键或右键

  • 详解Java的设计模式编程中的原型模式

    定义:用原型实例指定创建对象的种类,并通过拷贝这些原型创建新的对象. 类型:创建类模式 类图: 原型模式主要用于对象的复制,它的核心是就是类图中的原型类Prototype.Prototype类需要具备以下两个条件: 实现Cloneable接口.在java语言有一个Cloneable接口,它的作用只有一个,就是在运行时通知虚拟机可以安全地在实现了此接口的类上使用clone方法.在java虚拟机中,只有实现了这个接口的类才可以被拷贝,否则在运行时会抛出CloneNotSupportedExcepti

  • 详解C++11原子类型与原子操作

    1.认识原子操作 原子操作就是在多线程程序中"最小的且不可并行化的"操作,意味着多个线程访问同一个资源时,有且仅有一个线程能对资源进行操作.通常情况下原子操作可以通过互斥的访问方式来保证,例如Linux下的互斥锁(mutex),Windows下的临界区(Critical Section)等.下面看一个Linux环境使用POSIX标准的pthread库实现多线程下的原子操作: #include <pthread.h> #include <iostream> usi

  • 详解K-means算法在Python中的实现

    K-means算法简介 K-means是机器学习中一个比较常用的算法,属于无监督学习算法,其常被用于数据的聚类,只需为它指定簇的数量即可自动将数据聚合到多类中,相同簇中的数据相似度较高,不同簇中数据相似度较低. K-MEANS算法是输入聚类个数k,以及包含 n个数据对象的数据库,输出满足方差最小标准k个聚类的一种算法.k-means 算法接受输入量 k :然后将n个数据对象划分为 k个聚类以便使得所获得的聚类满足:同一聚类中的对象相似度较高:而不同聚类中的对象相似度较小. 核心思想 通过迭代寻找

  • 详解MySQL8.0原子DDL语法

    01 原子DDL介绍 原子DDL语句将数据字典更新.存储引擎操作和与DDL操作相关联的二进制日志写入合并到单个原子操作中.该操作要么提交,对数据字典.存储引擎和二进制日志保留适用的更改,要么回滚. 在MySQL8.0中,原子DDL操作这一特性,支持表相关操作,例如create table.drop table等,也支持非表相关操作,例如create routine.drop trigger等. 其中: 支持的表操作包含: drop.create.alter(操作对象是databases, tab

  • 详解Java字节码编程之非常好用的javassist

    一.Javassist入门 (一)Javassist是什么 Javassist是可以动态编辑Java字节码的类库.它可以在Java程序运行时定义一个新的类,并加载到JVM中:还可以在JVM加载时修改一个类文件.Javassist使用户不必关心字节码相关的规范也是可以编辑类文件的. (二)Javassist核心API 在Javassist中每个需要编辑的class都对应一个CtCLass实例,CtClass的含义是编译时的类(compile time class),这些类会存储在Class Poo

  • 详解c++良好的编程习惯与编程要点

    1.以良好的方式编写C++ class 假设现在我们要实现一个复数类complex,在类的实现过程中探索良好的编程习惯. Header(头文件)中的防卫式声明 complex.h: # ifndef __COMPLEX__ # define __COMPLEX__ class complex { } # endif 防止头文件的内容被多次包含. 把数据放在private声明下,提供接口访问数据 # ifndef __COMPLEX__ # define __COMPLEX__ class com

随机推荐