详解C++中的内存同步模式(memory order)
内存模型中的同步模式(memorymodelsynchronizationmodes)
原子变量同步是内存模型中最让人感到困惑的地方.原子(atomic)变量的主要作用就是同步多线程间的共享内存访问,一般来讲,某个线程会创建一些数据,然后给原子变量设置标志数值(译注:此处的原子变量类似于一个flag);其他线程则读取这个原子变量,当发现其数值变为了标志数值之后,之前线程中的共享数据就应该已经创建完成并且可以在当前线程中进行读取了.不同的内存同步模式标识了线程间数据共享机制的"强弱"程度,富有经验的程序员可以使用"较弱"的同步模式来提高程序的执行效率.
每一个原子类型都有一个load()方法(用于加载操作)和一个store()方法(用于存储操作).使用这些方法(而不是普通的读取操作)可以更清晰的标示出代码中的原子操作.
atomic_var1.store(atomic_var2.load());//atomicvariables vs var1=var2;//regularvariables
这些方法还支持一个可选参数,这个参数可以用于指定内存模型的同步模式.
目前这些用于线程间同步的内存模式共有3种,我们依此来看下~
顺序一致模式(sequentiallyconsistent)
第一种模式是顺序一致模式(sequentiallyconsistent),这也是原子操作的默认模式,同时也是限制最严格的一种模式.我们可以通过std::memory_order_seq_cst来显示的指定这种模式.这种模式下,线程间指令重排的限制与在顺序性代码中进行指令重排的限制是一致的.
观察以下代码:
-Thread1--Thread2- y=1if(x.load()==2) x.store(2);assert(y==1)
虽然代码中的x和y是没有关联的两个变量,但是代码中指定的内存模型(译注:代码中没有显示指定,则使用默认的内存模式,即顺序一致模式)保证了线程2中的断言不会失败.线程1中对y的写入先发生于(happens-before)对x的写入,如果线程2读取到了线程1对x的写入(x.load()==2),那么线程1中对x写入之前的所有写入操作都必须对线程2可见,即使对于那些和x无关的写入操作也是如此.这意味着优化操作不能重排线程1中的两个写入操作(y=1和x.store(2)),因为当线程2读取到线程1对x的写入之后,线程1对y的写入也必须对线程2可见.
(译注:编译器或者CPU会因为性能因素而重排代码指令,这种重排操作对于单线程程序而言是无感知的,但是对于多线程程序而言就不是了,拿上面代码举例,如果将x.store(2)重排于y=1之前,那么线程2中即使读取发现x==2了,但此时y的数值也不一定是1)
加载操作也有类似的优化限制:
a=0 y=0 b=1 -Thread1--Thread2- x=a.load()while(y.load()!=b) y.store(b); while(a.load()==x)a.store(1) ;
线程2一直循环到y发生数值变更,然后对a进行赋值;线程1则一直在等待a发生数值变化.
从顺序性代码的角度来看,线程1中的代码‘while(a.load()==x)'似乎是一个无限循环,编译器编译这段代码时也可能会直接将其优化为一个无限循环(译注:优化为while(true);之类的指令);但实际上,我们必须保证每次循环都对a执行读取操作(a.load())并且将其与x进行比较,否则线程1和线程2将不能正常工作(译注:线程1将进入无限循环,与正确的执行结果不一致).
从实践的角度讲,所有的原子操作都相当于优化屏障(译注:用于阻止优化操作的指令).原子操作(load/store)可以类比为副作用未知的函数调用,优化操作可以在原子操作之间任意的调整代码顺序,但是不能越过原子操作(译注:原子操作类似于是优化调整的边界),当然,线程的私有数据并不受此影响,因为这些数据其他线程并不可见.
顺序一致模式也保证了所有线程间(原子变量(使用memory_order_seq_cst模式)的修改顺序)的一致性.以下代码中所有的断言都不会失败(x和y的初始值为0):
-Thread1--Thread2--Thread3- y.store(20);if(x.load()==10){if(y.load()==10) x.store(10);assert(y.load()==20)assert(x.load()==10) y.store(10) }
从顺序性代码的角度来看,似乎这是(所有断言都不会失败)理所当然的,但是在多线程环境下,我们必须同步系统总线才能达到这种效果(以使线程3与线程2观察到的原子变量(使用memory_order_seq_cst模式)变更顺序一致),可想而知,这往往需要昂贵的硬件同步.
由于保证顺序一致的特性,顺序一致模式成为了原子操作中默认使用的内存模式,当程序员使用这种模式时,一般不太可能获得意外的程序结果.
宽松模式(relaxed)
与顺序一致模式相对的就是std::memory_order_relaxed模式,即宽松模式.由于去除了先发生于(happens-before)这个关系限制,宽松模式仅需极少的同步指令即可实现.这种模式下,不同于之前的顺序一致模式,我们可以对原子变量操作进行各种优化了,譬如执行死代码删除等等.
看一下之前的示例:
-Thread1- y.store(20,memory_order_relaxed) x.store(10,memory_order_relaxed) -Thread2- if(x.load(memory_order_relaxed)==10) { assert(y.load(memory_order_relaxed)==20)/*assertA*/ y.store(10,memory_order_relaxed) } -Thread3- if(y.load(memory_order_relaxed)==10) assert(x.load(memory_order_relaxed)==10)/*assertB*/
由于线程间不再需要同步(译注:由于使用了宽松模式,原子操作之间不再形成同步关系,这里的不需要同步指的是不需要原子操作间的同步),所以代码中的任一断言都可能失败.
由于没有了先发生于(happens-before)的关系,从单一线程的角度来看,其他线程不再存在对其可见的特定原子变量写入顺序.如果使用时不是非常小心,宽松模式会导致很多非预期的结果.这个模式唯一保证的一点就是:一旦线程2观察到了线程1中对某一原子变量的写入数值,那么线程2就不会再看到线程1对该变量更早的写入数值.
我们还是来看个示例(假定x的初始值为0):
-Thread1- x.store(1,memory_order_relaxed) x.store(2,memory_order_relaxed) -Thread2- y=x.load(memory_order_relaxed) z=x.load(memory_order_relaxed) assert(y<=z)
代码中的断言不会失败.一旦线程2读取到x的数值为2,那么线程2后面对x的读取操作将不可能取得数值1(1较2是x更早的写入数值).这一特性导致了一个结果:
如果代码中存在多个对同一变量的宽松模式读取,但是这些读取之间存在对其他引用(可能是之前同一变量的别名)的宽松模式读取,那么我们不能把这多个对同一变量的宽松模式读取合并(多个读取并成一个).
这里还有一个假定就是某一线程对于原子变量的宽松写入将在一段合理的时间内对另一线程可见(通过宽松读取).这意味着,在一些非缓存一致的体系架构上,宽松操作需要主动的去刷新缓存(当然,刷新操作可以进行合并,譬如在多个宽松操作之后再进行一次刷新操作).
宽松模式最常用的场景就是当我们仅需要一个原子变量,而不需要使用该原子变量同步线程间共享内存的时候.(译注:譬如一个原子计数器)
获得/释放模式(acquire/release)
第三种模式混合了之前的两种模式.获得/释放模式类似于之前的顺序一致模式,不同的是该模式只保证依赖变量间产生先发生于(happens-before)的关系.这也使得独立读取操作和独立写入操作之间只需要比较少的同步.
假设x和y的初始值为0:
-Thread1- y.store(20,memory_order_release); -Thread2- x.store(10,memory_order_release); -Thread3- assert(y.load(memory_order_acquire)==20&&x.load(memory_order_acquire)==0) -Thread4- assert(y.load(memory_order_acquire)==0&&x.load(memory_order_acquire)==10)
代码中的两个断言可能同时通过,因为线程1和线程2中的两个写入操作并没有先后顺序.
但是如果我们使用顺序一致模式来改写上面的代码,那么这两个写入操作中必然有一个写入先发生于(happens-before)另一个写入(尽管运行时才能确定实际的先后顺序),并且这个顺序是多线程一致的(通过必要的同步操作),所以代码中如果一个断言通过,那么另一个断言就一定会失败.
如果我们在代码中使用非原子变量,那么事情会变的更复杂一些,但是这些非原子变量的可见性同他们是原子变量时是一致的(译注:参看下面代码).任何原子写入操作(使用释放模式)之前的写入对于其他同步的线程(使用获取模式并且读取到了之前释放模式写入的数值)都是可见的.
-Thread1- y=20; x.store(10,memory_order_release); -Thread2- if(x.load(memory_order_acquire)==10) assert(y==20);
线程1中对y的写入(y=20)先发生于对x的写入(x.store(10,memory_order_release)),因此线程2中的断言不会失败(译注:这里说的有些简略,扩展来讲的话应该是线程1中对y的写入先发生于对x的写入,而线程1中对x的写入又同步于线程2中对x的读取,由于线程2中对x的读取又先发生于对y的断言,于是线程1中对y的写入先发生于线程2中对y的断言,这个对y的断言也就不会失败了).由于有上述的同步要求,原子操作周围的共享内存(非原子变量)操作一样有优化上的限制(译注:不能随意对这些操作进行优化,以上面代码为例,优化操作不能将y=20重排于x.store(10,memory_order_release)之后).
消费/释放模式(consume/release)
消费/释放模式是对获取/释放模式进一步的改进,该模式下,非依赖共享变量的先发生于关系不再成立.
假设n和m是两个一般的共享变量,初始值都为0,并且假设线程2和线程3都读取到了线程1中对原子变量p的写入(译注:注意代码前提).
-Thread1- n=1 m=1 p.store(&n,memory_order_release) -Thread2- t=p.load(memory_order_acquire); assert(*t==1&&m==1); -Thread3- t=p.load(memory_order_consume); assert(*t==1&&m==1);
线程2中的断言不会失败,因为线程1中对m的写入先发生于对p的写入.
但是线程3中的断言就可能失败了,因为p和m没有依赖关系,而线程3中读取p使用了消费模式,这导致线程1中对m的写入并不能与线程3中的断言形成先发生于的关系,该断言自然也就可能失败了.PowerPC架构和ARM架构中,指针加载的默认内存模式就是消费模式(一些MIPS架构可能也是如此).
另外的,线程1和线程2都能够正确的读取到n的数值,因为n和p存在依赖关系(译注:p.store(&n,memory_order_release),p中写入了n的地址,于是p和n形成依赖关系).
内存模式的真正区别其实就是为了同步,硬件需要刷新的状态数量.消费/释放模式相较获取/释放模式而言,执行速度上会更快一些,可以用于一些对性能极度敏感的程序之中.
总结
内存模式其实并不像听起来的那么复杂,为了加深你的理解,我们来看下这个示例:
-Thread1- y.store(20); x.store(10); -Thread2- if(x.load()==10){ assert(y.load()==20) y.store(10) } -Thread3- if(y.load()==10) assert(x.load()==10)
当使用顺序一致模式时,所有的共享变量都会在各线程间进行同步,所以线程2和线程3中的两个断言都不会失败.
-Thread1- y.store(20,memory_order_release); x.store(10,memory_order_release); -Thread2- if(x.load(memory_order_acquire)==10){ assert(y.load(memory_order_acquire)==20) y.store(10,memory_order_release) } -Thread3- if(y.load(memory_order_acquire)==10) assert(x.load(memory_order_acquire)==10)
获取/释放模式则只要求在两个线程间(一个使用释放模式的线程,一个使用获取模式的线程)进行必要的同步.这意味着这两个线程间同步的变量并不一定对其他线程可见.线程2中的断言仍然不会失败,因为线程1和线程2通过对x的写入和读取形成了同步关系(译注:参见之前获取/释放模式介绍中的说明),但是线程3并不参与线程1和线程2的同步,所以当线程2和线程3通过对y的写入和读取发生同步关系时,线程1与线程3并没有发生同步关系,x的数值自然也不一定对线程3可见,所以线程3中的断言是可能失败的.
-Thread1- y.store(20,memory_order_release); x.store(10,memory_order_release); -Thread2- if(x.load(memory_order_consume)==10){ assert(y.load(memory_order_consume)==20) y.store(10,memory_order_release) } -Thread3- if(y.load(memory_order_consume)==10) assert(x.load(memory_order_consume)==10)
使用消费/释放模式的结果与获取/释放模式是一致的,区别只是消费/释放模式需要更少的硬件同步操作,那么我们为什么不一直使用消费/释放模式(而不使用获取/释放模式)呢?那是因为这个例子中没有涉及(非原子)共享变量,如果示例中的y是一个(非原子)共享变量,由于其与x不存在依赖关系(依赖关系是指原子变量的写入数值由(非原子)共享变量计算而得),那么我们并不一定能够在线程2中看到y的当前数值(20),即便线程2已经读取到x的数值为10.
(译注:这里说因为没有涉及(非原子)共享变量所以导致消费/释放模式和获取/释放模式表现一致应该是不准确的,将示例中的assert(y.load(memory_order_consume)==20)修改为assert(y.load(memory_order_relaxed)==20)应该也能体现出消费/释放模式和获取/释放模式之间的不同,更多的细节可以参看文章最后的示例)
-Thread1- y.store(20,memory_order_relaxed); x.store(10,memory_order_relaxed); -Thread2- if(x.load(memory_order_relaxed)==10){ assert(y.load(memory_order_relaxed)==20) y.store(10,memory_order_relaxed) } -Thread3- if(y.load(memory_order_relaxed)==10) assert(x.load(memory_order_relaxed)==10)
如果所有操作都使用宽松模式,那么代码中的两个断言都可能失败,因为宽松模式下没有同步操作发生.
混合使用内存模式
最后,我们来看下混合使用内存模式会发生什么:
-Thread1- y.store(20,memory_order_relaxed) x.store(10,memory_order_seq_cst) -Thread2- if(x.load(memory_order_relaxed)==10) { assert(y.load(memory_order_seq_cst)==20)/*assertA*/ y.store(10,memory_order_relaxed) } -Thread3- if(y.load(memory_order_acquire)==10) assert(x.load(memory_order_acquire)==10)/*assertB*/
首先,我必须提醒你不要这么做(混合使用内存模式),因为这会让人极度困惑!