所在的位置: C++ >> C++发展 >> C多线程内存模型stdmemor

C多线程内存模型stdmemor

北京青春痘医院电话 https://m-mip.39.net/czk/mipso_8598820.html
概念

在C++11标准原子库中(std::atomic),大多数函数都接受一个参数:std::memory_order:

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

std::memory_order被称为内存排序约束。它们中的每一个都有其预期目的。std::memory_order指定内存访问,包括常规的非原子内存访问,如何围绕原子操作排序。在没有任何制约的多处理器系统上,多个线程同时读或写数个变量时,一个线程能观测到变量值更改的顺序不同于另一个线程写它们的顺序。其实,更改的顺序甚至能在多个读取线程间相异。一些类似的效果还能在单处理器系统上出现,因为「内存模型」允许编译器变换。

C++原子库中所有原子操作的默认行为是序列一致的顺序(memory_order_seq_cst,见后述讨论)。该默认行为可能有损性能,不过也可以传递给线程对原子操作额外的std::memory_order参数,以指定附加制约,在原子性外,编译器和处理器还必须强制该操作。

看到这里,不了解内存模型,原子操作等概念的可能不是特别明白上面这段解释,下面我们先来详细介绍这些基础概念。

内存模型基础我们都知道为了避免racecondition,线程就要规定代码语句(语句块)的执行顺序。通常我们都是使用mutex加锁,后一线程必须等待前一线程解锁才能继续执行。第二种方式是使用原子操作来避免竞争访问同一内存位置。所谓原子操作是指不会被线程调度机制打断的操作;这种操作一旦开始,就一直执行到结束,在执行完毕之前不会被任何其它任务或事件中断。原子操作是不可分割的操作,要么做了要么没做,不存在做一半的状态。如果读取对象值的加载操作是原子的,那么对象上的所有修改操作也是原子的,读取的要么是初始值,要么是某个修改完成后的存储值。因此,原子操作不存在修改过程中值被其他线程看到的情况,也就避免了竞争风险。每个对象从初始化开始都有一个修改顺序,这个顺序由来自所有线程对该对象的写操作组成。通常这个顺序在运行时会变动,但在任何给定的程序执行中,系统中所有线程都必须遵循此顺序如果对象不是原子类型,就要通过同步来保证线程遵循每个变量的修改顺序。「如果一个变量对于不同线程表现出不同的值序列,就会导致数据竞争和未定义行为」。使用原子操作就可以把同步的责任抛给编译器内存模型,准确来说应该是多线程内存模型,也叫动态内存模型,多个线程对同一个对象同时读写时所做的约束,该模型理解起来要复杂一些,涉及了内存、Cache、CPU各个层次的交互,尤其是在多核系统中为了保证多线程环境下执行的正确性,需要对读写事件加以严格限制。std::memory_order就是用来做这事的,它实际上是程序员、编译器以及CPU之间的契约,遵守契约后大家各自优化,从而可能提高程序性能。std::memory_order,它表示机器指令是以什么样的顺序被处理器执行的,一般现代的处理器不是逐条处理机器指令的。下面来简单举个例子说明一下(初始:x=y=0):线程1线程2①x=1;③y=2;②r1=y;④r2=x;

假设编译器、CPU不对指令进行重排,也没有使用std::memory_order,且两个线程交织执行(假设以上四条语句都是原子操作)时共有4!/(2!*2!)=6种情况:

①②③④-r1=0,r2=1③④①②-r1=2,r2=0①③④②-r1=2,r2=1①③②④-r1=2,r2=1③①②④-r1=2,r2=1③①④②-r1=2,r2=1

从上面看,最终r1和r2的最终结果共有3种,r1=r2=0的情况不可能出现。但是当四条语句不是原子操作时。有一种可能是CPU的指令预处理单元看到‘线程1’的两条语句没有依赖性(不管哪条语句先执行,在两条指令语句完成后都会得到一样的结果),会先执行r1=y再执行x=1,或者两条指令同时执行,这就是CPU的多发射和乱序执行。对于线程2也一样。这样一来就有可能出来r1=r2=0的结果。执行顺序可能就是:②④①③

另外一种r1=r2=0的情况是:线程1和线程线程2分别在不同的CPU核上执行,大家都知道CPU中是有Cache和RAM的,简单的理解一下程序的执行都是从RAM-Cache-CPU,执行完后CPU-Cache-RAM,有一种可能是当Core1和Core2都将x,y更新到L1Cache中,而还未来得及更新到RAM时,两个线程都已经执行完了第二条语句,此时也会出现r1=r2=0。

image-

另外一个就是,从编译器层面也一样,为了获取更高的性能,它也可能会对语句进行执行顺序上的优化(类似CPU乱序)。

因此,在编译器优化+CPU乱序+缓存不一致的多重组合下,情况不止以上三种。但不管哪一种,都不是我们希望看到的。那么如何避免呢?最简单的,也是首选的,方案当然是std::mutex。因为使用mutex对比atomic更容易分析程序出现的各种错误,对于mutex的错误,大多数都是漏了加锁,或者加锁次序错乱等问题,但是如果使用atomic就比较难排查问题。那std::atomic是不是就没啥用呢,当然不是,当程序对代码执行效率要求很高,std::mutex不满足时,就需要std::atomic上场了,因为std::atomic主要是为了提高性能而生的。

现在的编译器基本都支持指令重排,上述的现象也只是理想情况下的执行情况,因为顺序一致性代价太大不利于程序的优化。但是有时候你需要对编译器的优化行为做出一定的约束,才能保证你的程序行为和你预期的执行结果保持一致,那么这个约束就是「内存模型」。如果想要写出高性能的多线程程序必须理解内存模型,编译器会给你的程序做静态优化,CPU为了提升性能也有动态乱序执行的行为。总之,实际编程中程序不会完全按照你原始代码的顺序来执行,因此内存模型就是程序员、编译器、CPU之间的契约。编程、编译、执行都会在遵守这个契约的情况下进行,在这样的规则之上各自做自己的优化,从而提升程序的性能。

原子操作间的关系

多线程中要保证race-condition情况下的正确运行,mutex或者atomic限制是必要的,mutex我们前面的文章已经介绍过可以理解为就是synchronizes-with的关系,而atomic的限制有两种关系:synchronizes-with和happens-before的限制,确保在线程之间运行的顺序保证。

Synchronized-with?

Thesynchronizes-withrelationshipissomethingthatyoucangetonlybetweenoperationsonatomictypes.Operationsonadatastructure(suchaslockingamutex)mightprovidethisrelationshipifthedatastructurecontainsatomictypesandtheoperationsonthatdatastructureperformtheappropriateatomicoperationsinternally,butfundamentallyit


转载请注明:http://www.aierlanlan.com/tzrz/64.html

  • 上一篇文章:
  •   
  • 下一篇文章: 没有了