一、内存可见性和顺序性问题
在单核时代,程序按照代码的书写顺序运行,一切都看似自然。但在多核 CPU 的世界里,程序执行的环境变得复杂,主要源于三大优化:
-
编译器优化重排
- 编译器为了提升程序性能,会在不改变单线程语义的前提下,重新排列指令的执行顺序。
- 示例:
a = 1; b = 2;
编译器可能会先执行b=2
,再执行a=1
,因为这可能能更好地利用CPU指令流水线。在单线程下,这没问题。但在多线程下,如果另一个线程正在读取a
和b
,它可能会看到b
变成了2而a
还是0,这就产生了问题。
-
CPU指令级优化重排(指令并行)
- 现代CPU为了充分利用其内部的多功能单元,也会对指令进行重排执行(Out-of-Order Execution)。
- 如果一条指令需要等待内存读取(很慢),CPU可能会先执行后面不依赖该结果的指令。
- 这在单线程下完美无误,但在多线程下,其它线程看到的内存操作顺序可能和代码顺序不一致。
-
CPU缓存一致性延迟(内存缓存)
- 每个CPU核心都有自己的高速缓存。当一个核心修改了数据时,该数据首先存在于它的缓存中,不会立即写回主内存。其他核心的缓存中的副本也不会立即更新。
- 示例:线程A在CPU0上执行
data = 100; flag = true;
。由于缓存,flag=true
可能会先被写入内存并被线程B看到,而data=100
还留在CPU0的缓存里。线程B看到flag
为 true 后去读data
,读到的却是旧的0。
编译器和CPU的优化破坏了代码的“顺序性”,加上多级缓存的存在,导致一个线程对内存的修改何时以及以何种顺序被另一个线程看到,变得不确定。这就需要一套严格的规则来约束这些优化,这套规则就是内存模型,而规则的具体条款就是内存序。
二、内存模型要解决什么
内存模型就像一个契约,它规定了多线程程序中,对内存访问行为的最低保证:哪些优化是允许的,哪些必须禁止。。它主要规定了两件事:
-
同步(Synchronization):“某个线程对内存地址的更新何时能被其它线程看见”
- 这解决了可见性问题。它确保当一个线程修改了共享数据后,其他线程在某个时间点之后一定能看到这个更新。最严格的保证是立即看到,最宽松的保证是迟早会看到。
-
顺序(Ordering):“某个线程对内存地址访问附近可以做怎么样的优化”
- 这解决了顺序性问题。它限制了编译器和CPU能否对指令进行重排。它规定了:在当前线程中,某些特定操作之前或之后的指令,必须保持怎样的相对顺序。
三、C++中的6种内存序
C++11标准在原子操作中引入了6种内存序,用来精确控制同步和顺序的强度。从弱到强,它们提供了不同的性能和正确性保证。
内存序 | 中文名 | 作用 | 性能 | 适用场景 |
---|---|---|---|---|
std::memory_order_relaxed | 松散内存序 | 只保证原子性。不提供任何同步和顺序保证。 | 最高 | 计数器递增,顺序无关紧要,只需原子。 |
std::memory_order_consume | 消费内存序 | 依赖顺序保证。当前线程中后续依赖于此原子变量的操作必须在本操作之后执行。 | 高 | 极少使用,难以正确理解和使用。 |
std::memory_order_acquire | 获得内存序 | 读操作。当前线程中后续的所有读写操作必须在本操作之后执行。 | 配对使用,用于实现锁或同步。 | |
std::memory_order_release | 释放内存序 | 写操作。当前线程中之前的的所有读写操作必须在本操作之前执行。 | 一个线程释放锁,另一个线程获取锁。 | |
std::memory_order_acq_rel | 获取-释放内存序 | 读-修改-写操作。同时具备 acquire 和 release 的效果。 | 用于“比较并交换”(CAS)等RMW操作。 | |
std::memory_order_seq_cst | 顺序一致性内存序 | 默认模式。最强保证。全局顺序一致。所有线程看到的操作顺序都一样。 | 最低 | 最容易理解,适用于大多数需要同步的场景。 |
- memory_order_relaxed:松散内存序,只用来保证对原子对象的操作是原子的,在不需要保证顺序时使用;可以理解大家各自干各自的,最终结果没错就行。
memory_order_release
:释放操作,在写入某原子对象时, 当前线程的任何前面的读写操作都不允许重排到这个操作的后面 去,并且当前线程的所有内存写入都在对同一个原子对象进行获 取的其他线程可见;通常与memory_order_acquire
或memory_order_consume
配对使用
memory_order_acquire
:获得操作,在读取某原子对象时, 当前线程的任何后面的读写操作都不允许重排到这个操作的前面 去,并且其他线程在对同一个原子对象释放之前的所有内存写入 都在当前线程可见;
-
memory_order_consume
:同 memory_order_acquire 类似,区别是它仅对依赖于该原子变量操作涉及的对象,比如这个操作发生在原子变量 a 上,而 s = a + b;那 s 依赖于 a,但 b 不依赖于 a;当然这里也有循环依赖的问题,例如:t = s + 1,因 为 s 依赖于 a,那 t 其实也是依赖于 a 的;在大多数平台上,这 只会影响编译器的优化;不建议使用。 -
memory_order_acq_rel
:获得释放操作,一个读‐修改‐写操作同时具有获得语义和释放语义,即它前后的任何读写操作都不允许重排,并且其他线程在对同一个原子对象释放之前的所有内存 写入都在当前线程可见,当前线程的所有内存写入都在对同一个原子对象进行获取的其他线程可见; -
memory_order_seq_cst
:顺序一致性语义,对于读操作相当于获得,对于写操作相当于释放,对于读‐修改‐写操作相当于获得释放,是所有原子操作的默认内存序,并且会对所有使用此模型的原子操作建立一个全局顺序,保证了多个原子变量的操作在所有线程里观察到的操作顺序相同,当然它是最慢的同步模型。
四、代码示例
4.1 relaxed
代码创建了两个线程:
thread_func1
循环100,000次,将递增的i
值存储到原子变量x
中thread_func2
循环100,000次,将递减的-i
值存储到原子变量x
中
由于使用了std::atomic<int>
,保证了对x
的读写操作是原子的(不会产生数据竞争),但使用std::memory_order_relaxed
意味着:
- 不保证两个线程操作的执行顺序
- 不提供任何跨线程的内存可见性约束
- 最终
x
的值取决于哪个线程的最后一次存储操作获胜
程序的输出结果是不确定的,可能是任意一个线程最后写入的值,这展示了在松散内存序下,并发操作原子变量的结果特性。
#include <atomic>
#include <thread>
#include <iostream>
std::atomic<int> x{0};
void thread_func1()
{
for (int i = 0; i < 100000; ++i)
{
x.store(i, std::memory_order_relaxed);
}
}
void thread_func2()
{
for (int i = 0; i < 100000; ++i)
{
x.store(-i, std::memory_order_relaxed);
}
}
int main()
{
std::thread t1(thread_func1);
std::thread t2(thread_func2);
t1.join();
t2.join();
std::cout << "Final value of x = " << x.load(std::memory_order_relaxed) << std::endl;
return 0;
}
4.2 release/acquire
代码通过两个线程展示了一种同步机制:
- 线程
a
执行write_x_then_y
,先以relaxed
内存序设置x
为true
,再以release
内存序设置y
为true
- 线程
b
执行read_y_then_x
,先以acquire
内存序自旋等待y
变为true
,然后读取x
的值
这里的关键是release
和acquire
的配对:
- 当线程
b
通过acquire
内存序看到y
为true
时(操作3),可以保证它一定能看到线程a
在y.store(release)
之前对x
的修改(操作1) - 因此,
x.load(relaxed)
(操作4)必然会读到true
,最终z
的值一定是1
#include <atomic>
#include <thread>
#include <assert.h>
#include <iostream>
std::atomic<bool> x,y;
std::atomic<int> z;
// 写线程函数:先写x再写y
void write_x_then_y()
{
x.store(true, std::memory_order_relaxed); // 松散内存序写入x
y.store(true, std::memory_order_release); // release内存序写入y
}
// 读线程函数:先等y再读x
void read_y_then_x()
{
// 用acquire内存序等待y变为true
while(!y.load(std::memory_order_acquire));
// release-acquire配对确保能看到x的写入
if(x.load(std::memory_order_relaxed))
++z;
}
int main()
{
x=false; y=false; z=0;
std::thread a(write_x_then_y);
std::thread b(read_y_then_x);
a.join(); b.join();
std::cout << z << std::endl; // 结果一定是1
return 0;
}
4.3 seq_cst
代码创建了四个线程,分别执行:
write_x
:用顺序一致内存序将x
设为true
write_y
:用顺序一致内存序将y
设为true
read_x_then_y
:等待x
为true
后,检查y
是否为true
,如果是则递增z
read_y_then_x
:等待y
为true
后,检查x
是否为true
,如果是则递增z
在循环20次的执行中,由于使用了std::memory_order_seq_cst
,所有线程对原子变量的操作会形成一个全局统一的执行顺序,保证了:
- 不可能出现
z
为0的情况 - 每次运行的结果只能是
z=1
或z=2
#include <atomic>
#include <thread>
#include <iostream>
#include <unistd.h>
std::atomic<bool> x, y;
std::atomic<int> z;
void write_x() {
sleep(1);
x.store(true, std::memory_order_relaxed);
}
void write_y() {
sleep(1);
y.store(true, std::memory_order_relaxed);
}
void read_x_then_y() {
while (!x.load(std::memory_order_relaxed))
;
if (y.load(std::memory_order_relaxed))
++z;
}
void read_y_then_x() {
while (!y.load(std::memory_order_relaxed))
;
if (x.load(std::memory_order_relaxed))
++z;
}
int main() {
int zero_count = 0;
for (int i = 0; i < 1000; i++) {
x = false;
y = false;
z = 0;
std::thread a(write_x);
std::thread b(write_y);
std::thread c(read_x_then_y);
std::thread d(read_y_then_x);
a.join();
b.join();
c.join();
d.join();
std::cout << z.load() << " ";
}
// std::cout << "Total iterations with z=0: " << zero_count << std::endl;
return 0;
}