现代C++内存模型
现代C++内存模型
CPU有很多优化技术如流水线(Pipeline)
,动态分支预测(Dynamic Branch Prediction)
,推断执行(Speculative Execution)
,寄存器重命名(Register Renaming)
,各种深奥看不懂的东西,都会使指令的乱序执行。
为了解决底层这个混乱的世界,硬件处理器会提供一些内存屏障(memory barrier)指令,简单地理解就是在指令之间插入一道栅栏,栅栏前后的指令不会跨过栅栏去执行,同时也会确保存储操作同步到其他核。当然如果再细化的话可以分成读内存屏障和写内存屏障,但这些太底层了,如果上层的服务器程序要天天面对这些可能会疯掉。
幸运的是一些高级语言或编译器会定义一套统一的内存模型,比如C++11的原子操作可以指定下面的内存顺序:
- memory_order_seq_cst 默认的顺序,该原子操作前后的读写不能跨过该操作乱序;该原子操作之前的写操作都能被所有线程观察到,比如这个例子,如果指定为这个模式(C++里不指定就是这个模式):
-Thread 1- |
assert总是会成功,因为线程1将x写为2的时候,y=1这个写操作的结果已经刷新到所有线程,所以线程2加x出来判断等于2的时候,线程2一定已经观察到y的最新值了。
memory_order_relaxed 这是最宽松的顺序,除了保证操作的原子性之外,没有限定前后指令的顺序,其他线程看到数据的变化顺序也可能不一样,上面两个例子断言可能会失败。这种模式一般用于变量的store和load,且这个变量比较独立,没有和其他变量有相关性,这样就没有顺序问题。
memory_order_release/memory_order_acquire 这两个模式通常成对使用,memory_order_release用于写操作(store),memory_order_acquire用于读操作(load):
- memory_order_release
原子操作之前的读写
不能往后乱序;并且之前的写操作,会被使用acquire/consume的线程观察到,这里要注意它和seq_cst不同的是只有相关的线程才能观察到写变化,所谓相关线程就是使用acquire或consume模式加载同一个共享变量的线程;而seq_cst是所有线程都观察到了。 - memory_order_acquire
原子操作之后的读写
不能往前乱序;它能看到release线程在调用load之前的那些写操作。
从描述看release/acquire的效率要比seq_cst高,常用于spinlock和读写锁的实现。
看下面这个例子:
-Thread 1-
y.store(20,memory_order_release);
x.store(10, memory_order_release);
-Thread 2-
if (x.load(memory_order_acquire) == 10) {
assert (y.load() == 20)
y.store (10, memory_order_release)
}
-Thread 3-
if (y.load() == 10)
assert (x.load() == 10)线程2的assert会成功,因为x.load如果等于10,线程2必然观察到线程1对y的修改;但线程3不一定会成功,因为y.load没有使用acquire,它是不相关的线程,它未必能观察到x的修改(也可以反过来这样想,使用release的线程,只会把修改同步给使用acquire/consume的线程)。
- memory_order_release
memory_order_consume 和acquire比较接近,也是和release一起使用的;和acquire不一样的地方是加了一个限定条件:
依赖于该读操作
的后续读写不能往前乱序;它可以看到release线程在调用load之前那些依赖的写操作
,依赖于的意思是和该共享变量有关的写操作,举个例子可能会容易明白一点:-Thread 1-
n = 1
m = 1
p.store (&n, memory_order_release)
-Thread 2-
t = p.load (memory_order_acquire);
if (*t == 1)
assert(m == 1);
-Thread 3-
t = p.load (memory_order_consume);
if (*t == 1)
assert(m == 1);线程2的断言会成功,因为线程1对 n 和 m 在store之前修改;线程2 load之后,可以观察到m的修改。
但线程3的断言不一定会成功,因为m是和load/store操作不相关的变量,线程3不一定能观察看到。
memory_order_acq_rel是memory_order_release/memory_order_acquire的合并,前后的读写都是不能跨过这个原子操作,但仅相关的线程能看到前面写的变化。
这些内存模型非常的细化,在使用的时候总是先用memory_order_seq_cst模式,然后再根据情影慢慢优化。
上面在描述原子操作的内存顺序时,是从两个方面来说的:
- 原子操作前后的乱序,是否能跨过该原子操作,这相当于内存屏障的作用;会阻止CPU和编译器进行乱序优化。
- 线程之间的对共享变量修改的可见性顺序。
这两个维度决定了内存模式的区别,当然了,如果我们使用锁来同步共享内存时,内部已经自己实现了这些设施,写起来也会轻松很多;如果你想对多线程进行极致的优化,想写一些Lockless代码,那么就必须像Kernel代码那样,准确地使用这些内存模式。