一、几种关系术语
1.1、sequenced-before
sequenced-before用于表示同一个线程中,两个操作上的先后顺序,这个顺序是非对称、可以进行传递的关系。
它不仅仅表示两个操作之间的先后顺序,还表示了操作结果之间的可见性关系。两个操作A和操作B,如果有A sequenced-before B,除了表示操作A的顺序在B之前,还表示了操作A的结果操作B可见
1.2、happens-before
与sequenced-before不同的是,happens-before它既包括同一个线程里的操作顺序,也包不同线程操作间的顺序,同样的也是非对称、可传递的关系。
如果A happens-before B,则A的内存状态将在B操作执行之前就可见
1.3、synchronizes-with
synchronizes-with 描述的是两个线程对同一个原子变量的修改和读取之间的关系,如果一个线程修改某变量的之后的结果能被其它线程可见,那么就是满足synchronizes-with关系的。
显然,满足synchronizes-with关系的操作一定满足happens-before关系了。
二、C++11中支持的内存模型
从C++11开始,就支持以下几种内存模型:
enum memory_order {
memory_order_relaxed,
memory_order_consume,
memory_order_acquire,
memory_order_release,
memory_order_acq_rel,
memory_order_seq_cst
};
与内存模型相关的枚举类型有以上六种,但是其实分为四类,如下图所示,其中对一致性的要求逐渐减弱,以下来分别讲解。
在原子操作上添加六种内存顺序标记(中的一部分),会影响(但不一定改变;视 CPU 架构)原子操作附近的内存访问顺序(包括其他原子操作,亦包含对非原子变量的读写操作)。注意,内存顺序(通过六种标记)讨论的实际上是线程内原子操作附近非原子操作访问内存的顺序,而非是多线程之间的执行顺序。只不过,因为原子变量自身可能建立了线程间的同步关系,所以两个线程内各自的内存顺序会经由原子变量的同步建立间接的顺序关系。亦即,内存顺序本质上是在讨论单线程内指令执行顺序对多线程影响的问题
2.1、顺序一致顺序 (sequentially-consistent ordering)
以 memory_order_seq_cst 为参数对原子变量进行load、store或者read-modify-write操作。这是默认的内存模型,c++11所有的原子操作都是采用memory_order_seq_cst作为默认参数。所有以memory_order_seq_cst为参数的原子操作(不限于同一个原子变量),对所有线程来说有一个全局顺序(total order),并且两个相邻memory_order_seq_cst原子操作之间的其它操作(包括非原子变量操作),不能reorder到这两个相邻操作之外。【注:同一个程序的不同运行,这个全局顺序是可以不一样的】。
我们通过下面的例子来理解一下顺序一致性:
std::atomic<bool> x = {false};
std::atomic<bool> y = {false};
std::atomic<int> z = {0};
// 线程A
void write_x() {
x.store(true, memory_order_seq_cst);
}
// 线程B
void write_y() {
y.store(true, memory_order_seq_cst);
}
// 线程C
void read_x_then_y() {
while (!x.load(memory_order_seq_cst))
;
if (y.load(memory_order_seq_cst)) {
++z;
}
}
// 线程D
void read_y_then_x() {
while (!y.load(memory_order_seq_cst))
;
if (x.load(memory_order_seq_cst)) {
++z;
}
}
int main() {
std::thread thread_a(write_x);
std::thread thread_b(write_y);
std::thread thread_c(read_x_then_y);
std::thread thread_d(read_y_then_x);
thread_a.join(); thread_b.join(); thread_c.join(); thread_d.join();
assert(z.load() != 0); // 一定不会失败
}
4个线程对两个原子变量x和y的操作都是memory_order_seq_cst,构成了顺序一致性(sequentially-consistent),因此对4个线程来说,对原子变量x和y的操作顺序是一致的,在这个全局一致的操作顺序里:
(Ⅰ)要么x.store(true, memory_order_seq_cst)
发生在y.store(true, memory_order_seq_cst)
之前,这时候线程D的x.load(memory_order_seq_cst)
一定读到true,从而执行z++ ;
(Ⅱ)要么y.store(true, memory_order_seq_cst)
发生在x.store(true, memory_order_seq_cst)
之前,这时候线程C的y.load(memory_order_seq_cst)
一定读到true,从而执行z++ ;
不论哪种情况,z值都会被修改,因此main()函数最后的assert语句一定不会失败。
2.2、获取-释放顺序 (acquire-release ordering)
- memory_order_acquire:用来修饰一个读操作,表示在本线程中,所有后续的关于此变量的内存操作都必须在本条原子操作完成后执行。
- memory_order_release:用来修饰一个写操作,表示在本线程中,所有之前的针对该变量的内存操作完成后才能执行本条原子操作
- memory_order_acq_rel:同时包含memory_order_acquire和memory_order_release标志
struct Point {
int x_;
int y_;
};
Point g_point;
std::atomic<int> g_guard(0);
// 线程A
void writePoint() {
g_point.x_ = 1;
g_point.y_ = 2;
// 以memory_order_release写入1到原子变量
g_guard.store(1, memory_order_release);
}
// 线程B
void readPoint() {
// 以memory_order_acquire读取原子变量,直到读到1 (线程A所写入的值)
while (g_guard.load(memory_order_acquire) != 1) {
this_thread::yield();
}
// while 循环结束时,一定是以memory_order_acquire读到线程A写入的值1
assert(g_point.x_ == 1 && g_point.y_ == 2); // 不会失败
}
这个例子中的g_guard读写就是synchronize-with 关系, 这里synchronize-with 描述的是两个线程对同一个原子变量的修改和读取之间的关系,它在两个相关线程建提供了一个比较强的memory order约束: 线程A的store操作之前的所有内存修改,对线程B的load之后的操作都可见,并且线程A的store操作之前的指令不允许reorder到store操作之后,线程B的load操作之后的指令不允许reorder到load之前。synchronize-with 关系像是在两个线程之间建立了一个内存屏障,这个屏障引入了一种较强的先后顺序,屏障前的内存修改对屏障后的所有操作都可见。
具体到上面这个例子,就是线程A对结构体g_point的修改,在线程B的“while循环最后一次g_guard.load(memory_order_acquire)
”之后都是可见的,因此线程B的assert不会失败:
2.3、释放-消费顺序 (Release-Consume ordering)
一个线程以memory_order_release对原子变量进行store操作,另一个线程以memory_order_consume对同一个原子变量进行load操作,从上面对Acquire-Release模型的分析可以知道,虽然可以使用这个模型做到两个线程之间某些操作的synchronizes-with关系,然后这个粒度有些过于大了。
在很多时候,线程间只想针对有依赖关系的操作进行同步,除此之外线程中的其他操作顺序如何无所谓。比如下面的代码中:
b = *a;
c = *b;
其中第二行代码的执行结果依赖于第一行代码的执行结果,此时称这两行代码之间的关系为“carry-a-dependency ”。C++中引入的memory_order_consume内存模型就针对这类代码间有明确的依赖关系的语句限制其先后顺序。
来看下面的示例代码:
#include <string>
#include <thread>
#include <atomic>
#include <assert.h>
struct X
{
int i;
std::string s;
};
std::atomic<X*> p;
std::atomic<int> a;
void create_x()
{
X* x=new X;
x->i=42;
x->s="hello";
a.store(99,std::memory_order_relaxed);
p.store(x,std::memory_order_release);
}
void use_x()
{
X* x;
while((x = p.load(std::memory_order_consume)) != nullptr)
std::this_thread::sleep_for(std::chrono::microseconds(1));
assert(x->i==42);
assert(x->s=="hello");
assert(a.load(std::memory_order_relaxed)==99);
}
int main()
{
std::thread t1(create_x);
std::thread t2(use_x);
t1.join();
t2.join();
}
以上的代码中:
- create_x线程中的store(x)操作使用memory_order_release,而在use_x线程中,有针对x的使用memory_order_consume内存模型的load操作,两者之间由于有carry-a-dependency关系,因此能保证两者的先后执行顺序。所以,x->i == 42以及x->s==“hello"这两个断言都不会失败。
- 然而,create_x中针对变量a的使用relax内存模型的store操作,use_x线程中也有针对变量a的使用relax内存模型的load操作。这两者的先后执行顺序,并不受前面的memory_order_consume内存模型影响,所以并不能保证前后顺序,因此断言a.load(std::memory_order_relaxed)==99真假都有可能。
以上可以对比Acquire-Release以及Release-Consume两个内存模型,可以知道:
- Acquire-Release能保证不同线程之间的Synchronizes-With关系,这同时也约束到同一个线程中前后语句的执行顺序。
- 而Release-Consume只约束有明确的carry-a-dependency关系的语句的执行顺序,同一个线程中的其他语句的执行先后顺序并不受这个内存模型的影响。
2.4、宽松顺序 (relaxed ordering)
以memory_order_relaxed作为参数的原子操作,这种类型对应的松散内存模型,这个对于操作顺序没有任何约束,只保证操作的原子性
atomic<int> x = {0};
atomic<int> y = {0};
// Thread 1:
r1 = y.load(std::memory_order_relaxed); // A
x.store(r1, std::memory_order_relaxed); // B
// Thread 2:
r2 = x.load(std::memory_order_relaxed); // C
y.store(42, std::memory_order_relaxed); // D
可能产生r1 = r2 = 42
的结果,尽管在线程1里 A sequenced-before B, 在线程2里 C sequenced-before D,但是因为memory_order_relaxed不提供任何顺序约束,对于线程1来说,可能是D发生在C的前面,整个执行顺序可能是D ==> A ==> B ==> C,从而导致r1 = r2 = 42
。
三、与 volatile 的关系
voldatile关键字首先具有“易变性”,声明为volatile变量编译器会强制要求读内存,相关语句不会直接使用上一条语句对应的的寄存器内容,而是重新从内存中读取。
其次具有”不可优化”性,volatile告诉编译器,不要对这个变量进行各种激进的优化,甚至将变量直接消除,保证代码中的指令一定会被执行。
最后具有“顺序性”,能够保证Volatile变量间的顺序性,编译器不会进行乱序优化。不过要注意与非volatile变量之间的操作,还是可能被编译器重排序的。
需要注意的是其含义跟原子操作无关,比如:volatile int a; a++; 其中a++操作实际对应三条汇编指令实现”读-改-写“操作(RMW),并非原子的。
3.1、区别
上面内存模型操作都是原子的,而volatile不是原子的。
参考文档
https://www.codedump.info/post/20191214-cxx11-memory-model-2/
https://liam.page/2021/12/11/memory-order-cpp-02/