走进并行时代之编程篇
Posted threepigs
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了走进并行时代之编程篇相关的知识,希望对你有一定的参考价值。
上回介绍了内存模型的概念,应该对多核、多处理器编程所面临的问题有所了解了,对顺序一致性模型的一种松弛就是在性能与可编程性之间做出的一种折衷。不同的体系结构所做的松弛是不一样的,都必须得提供相应的内存屏障指令来保证一致性。
在非顺序一致性的体系结构中,reorder造成的程序的异常的case我们在上回已经列举了一些了,我们必须要有一种机制来避免这种结果的发生,从层次上来划分的话,可以有指令集的跟语言级的解决方案。
指令集的解决方案比较简单,就是该体系结构会提供一些内存屏障指令或者其他类似的原子操作。内存屏障可以有读屏障RMB,写屏障WMB,读写屏障MB,读屏障的含义是该读屏障指令后的读操作开始时,屏障之前的所有读操作已经完成了,同理写屏障是指写操作的顺序性,MB是
对于所有的memory操作,也就是说只有MB之后的memory操作都执行完了才能继续执行下面的memory操作,因此MB操作的开销很大,频繁的使用会降低程序的性能,只有在可能出现数据竞争,导致异常case的时候才加上MB来阻止reorder为佳。除了内存屏障指令外,RMW(read-modify-write)也是一种使用广泛的原子操作,可以把一个memory read和一个memory write打包成inorder的原子操作,从而达到阻止reorder的目的。
指令集的解决方案过于底层了,可移植性太差。为各种高级语言定义并行内存模型的标准才是王道,把这些指令inorder实现交给编译器来做,当标准规定的需要inorder的时候由编译器来插入内存屏障或者RMW指令。java, C#, C++都有不同的标准来保证,接下来会用一个著名的double-checked-locking来开始高级语言解决方案。
Double-Checked-Locking问题来自于多处理器的singleton的线程安全问题。
class Singleton
public:
static Singleton* GetInstance();
private:
static Singleton* pInstance;
Singleton* Singleton::pInstance = 0;
Singleton* GetInstance()
if(pInstance == 0)
pInstance = new Singleton; // 1
return pInstance;
这是一个最简单的Singleton的实现,它甚至都不会单处理器线程安全的,两个线程可以同时进入语句1,所以就出现了用double-checked-locking来保证线程安全,有如下代码。
class Singleton
public:
static Singleton* GetInstance();
private:
static Singleton* pInstance;
Singleton* Singleton::pInstance = 0;
Singleton* GetInstance()
if(pInstance == 0)
Lock lock; // 伪代码
if(pInstance == 0)
pInstance = new Singleton;
return pInstance;
第一个if判断是为了不用每次都进行锁操作,如果pInstance为空,那么lock是避免不了了,这样保证了只有一个线程能进入第二个if。
咋一看double-checked-locking似乎是线程安全了,但我们看下面的case: 首先我们要看到的一点,pInstance = new Singleton这条语句包含了三个步骤,step 1. 分配Singleton内存,step 2. 执行Singleton的构造函数,step 3. 给pInstance赋值。其中step 1肯定是在step 2之前,但是step 3就不一定了,可以等step 2执行完了再赋值,也可以先赋值。如果是先赋值,那么有可能另外一个线程在构造函数没执行完成之前就读到了pInstance,得到了一个半成品,那么还是得把pInstance放在最后。不幸的是,C++标准并不保证这种顺序。那么你可能会做些修改,Singleton* temp = new Singleton; pInstance = temp; C++标准 n3902 section 1.9 paragraph 14:
Every value computation and side effect associated with a full-expression is sequenced before every value computation and side effect associated with the next full-expression to be evaluated.
也就是说对于side effects,标准确实可以保证pInstance在被temp赋值的时候,temp已经完成了step 1-3,但是编译器很有可能会把temp给优化掉,那么加不加temp没啥区别。ok,你肯定想到了volatile,volatile修饰的对象不会被编译器优化掉,而且对volatile对象的访问是side effect的,可以能保证顺序性。我们修改代码如下。
class Singleton
public:
static volatile Singleton* volatile GetInstance();
private:
static volatile Singleton* volatile pInstance;
volatile Singleton* volatile Singleton::pInstance = 0;
volatile Singleton* volatile GetInstance()
if(pInstance == 0)
Lock lock; // 伪代码
if(pInstance == 0)
volatile Singleton* volatile temp = new volatile Singleton;
pInstance = temp;
return pInstance;
根据Scott Meyers在C++ and the Perils of Double-Checked Locking中的观点,上面case中volatile同样会失效,基于两种理由,其一,volatile的语义是在单线程保序的,在多线程的情况下,它有可能会失效,取决于编译器(关于这一点,我觉得在C++0x中已经修正了,新的Ox草案已经final了,里面明确了多线程内存模型,定义了一种happen before的全局序,这是一种顺序一致性,它保证了在多线程下的表现的可控性,volatile的读写是side effect,在线程内构成了一种sequence before的序关系,这是happen before的一种,因此在新的标准下,这第一点理由已经无须担心了)。其二,volatile的语义只有在构造函数完成时才生效,这又回到了我们最初的问题,构成函数完成之前,有一次reorder发生,导致了temp在构造函数没有完成的情况下pInstance = temp被reorder了,使得其他线程读到了一个未构造完成的对象(关于这一点,可能跟编译器相关,我想大部分正常的编译器在实现上面代码的时候应该都会加个memory barrier或者RMW来阻止reorder)。第二点理由还可以用另外的办法了解决,就是在构造函数内的初始化过程中,成员对象赋值时,强转成volatile,比如:
Singleton()
static_case(volatile int&)(x) = 5;
这样的修改使得Singleton也是side effect(C++0x section 1.9 paragraph 12),那么他跟后面的pInstance = temp就构成了sequenced before的关系,在新的标准下是可以保证inorder的。
Accessing an object designated by a volatile glvalue, modifying an object, calling a library I/O function, or calling a function that does any of those operations are all side effects, which are changes in the state of the execution environment.
新标准到来之前,double-checked locking的volatile实现方式也不能保证一定线程安全的,在C++的语言层面上也没有什么能够保证它的线程安全,剩下的只能用不可移植的指令级memory barrier或者RMW指令来保证了。C++0x在规范多线程环境的表现方面,做了许多的扩充,一个重要的feature就是明确了多线程内存模型。新增了1.10这节的规范了多线程的表现标准。另外标准库里增加了原子操作,锁操作来保证线程的同步,互斥问题。
1.10节中,明确了线程的概念,任何一个程序都可能有多于一个线程在同时执行。同步操作分两种,一种是带有操作对象内存地址的,跟不带相关地址的。第一种规定了四种同步操作(consume,acquire,release,acquire&release),第二种就是我们熟悉的fence操作(acquire fence, release fence, acquire&release fence)。在对象A上的release操作使得在这之前的所有内存side effects都对其它即将对A做consume和acquire操作的线程可见。对一个原子对象的所有修改都是全序的。这一节的重点是在标准化一种线程间执行顺序。
同步序(release/acquire): 原子操作A对一个原子对象M进行了release,原子操作B对M做acquire并且读由A引导的一个release sequence的side effects值,那么A,B之间就是一种同步序关系。
这里有个release sequence的概念,就是一个原子对象上的一个最大的连续side effects序列,其中第一个是release操作,接下来的要么跟release同一线程,要么是原子read-modify-write操作。
详细的定义细节可参考n3902的section 1.10。
依赖序(release/consume): A release原子对象M,B对M做consume操作,并且读取release sequence的side effects值,A B之间就产生了一种依赖序,可见consume操作是专门为了依赖序定义的。除此如果A dependency-ordered before X,X carries dependency to B, A B也产生了依赖序。carry dependency是一种线程内的顺序,是指X是B的一个操作数,或者B读一个被X写的值,而且X sequenced before B(这也是线程内的序关系,是指X必须先于B执行)。这个依赖序主要是为了编译器保持数据相关链不要被reorder。
An evaluation A is dependency-ordered before an evaluation B if
— A performs a release operation on an atomic object M , and B performs a consume operation on M and reads a value written by any side effect in the release sequence headed by A, or
— for some evaluation X , A is dependency-ordered before X and X carries a dependency to B.
以上两种序关系再加上线程内的sequenced before关系,就构成了一种线程间的全局序关系,我们称为happens before。A happens before B就意味着A是先于B执行的,这是一种全局序,线程之间也是保证的序关系。
由于有了标准的跨线程序关系,我们可以用新的标准库的原子操作库来解决线程安全问题。标准section 29定义了memory order的结构:
namespace std
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_release,memory_order_seq_cst,memory_order_acq_rel是release语义,memory_order_consume是consume语义,memory_order_acquire,memory_order_seq_cst, memory_order_acq_rel是acquire语义。原子库里提供了标准的fence接口 void atomic_thread_fence(memory_order order),意味着可以写跨平台的fence了。
memory_order_consume是一种弱化版的acquire语义,主要还是为了提高性能,acquire语义还是比较重的。n3902的标准草案上也并没有对这几个同步语义做明确的说明,我只能从定义上来猜测了,下面的猜测代表个人看法,仅供参考。对一个原子对象M的acquire操作,保证了它之后的memory reference都不能reorder到他之前,因为如果允许的话就有可能会违反之前的同步序(release/acquire),同步序保证的了对M的release操作以及其之前的所有内存side effect都happen before接下来的acquire操作,要是acquire之后的操作被提到acquire之前,那么它很有可能就不满足这个happen before的关系(不过好像acquire并没有阻止对一个内存的写操作reorder到acquire之前,只是这似乎无关紧要,这个side effect是不属于release sequence的,因为它既不是跟release同线程,也不是RMW操作,acquire操作是不会读到这个写入的值的)。同理release操作的之前的内存写操作也都不能reorder到release之后。consume主要是为了保证数据链不被优化,是比较轻量级的同步操作了,从下面 inter-thread happens before(happen before的一种)的定义可以看出来:
An evaluation A inter-thread happens before an evaluation B if
— A synchronizes with B, or
— A is dependency-ordered before B, or
— for some evaluation X
— A synchronizes with X and X is sequenced before B, or
— A is sequenced before X and X inter-thread happens before B, or
— A inter-thread happens before X and X inter-thread happens before B.
可以看到synchronize with + sequenced before也是happen before的一种,但并没有dependency-ordered before + sequence,这意味着consume并不想acquire那样阻止acquire之后的memory reference被reorder到前面,consume只是为了保证哪些有数据相关性的操作链要保持他们的顺序(carry dependency),不要被优化。
这里只是简单的介绍了新标准的多线程内存模型,如果要更深入的了解的话,还是得仔细的研读新标准的草案,总之,在新标准下,你可以比较放心的利用这个原子操作,定义不同的memory order来写出线程安全的代码了。
相比于C++这样复杂形式化的定义,C#对同步操作的定义就简单多了。
下面是C#中定义的volatile语义:
A volatile read has “acquire semantics” meaning that the read is guaranteed to occur prior to any references to memory that occur after the read instruction in the CIL instruction sequence. A volatile write has “release semantics” meaning that the write is guaranteed to happen after any memory references prior to the write instruction in the CIL instruction sequence.
An optimizing compiler that converts CIL to native code shall not remove any volatile operation, nor shall it coalesce multiple volatile operations into a single operation.
以上是关于走进并行时代之编程篇的主要内容,如果未能解决你的问题,请参考以下文章