并发编程重要知识点:内存模型
时间:2025-12-07 19:38:58
手机看文章
扫描二维码
随时随地手机看文章
这篇文章简单介绍下并发编程中一个重要的知识点:内存模型。直接看这段代码:
看图,随着时间的推移,一个变量可能做了图中的改动,产生了一个改动序列,即(1,2,3,4,5,6,7,8,9,10),然而理论上来说,不同线程不能保证他们看见的是最新的值,比如同一时刻,线程a可能看见的是5,线程b可能看见的是4,线程c可能看见的是3,d是4,e是2。然后过了一段时间,可能变成了a(10),b(4),c(8),d(6),e(3)。每个线程看到的只会是序列中上一次看到的之后的值,不可能是之前的,时光不能倒流。
同理,如图:
如果有两个变量,它们的改动序列如图,然而同一时刻,理论上可能不同线程看到的值不同。再回到上面那段代码:
int x = 0;int y = 0; void func1() { x = 100; y = 2;} void func2() { while (y == 2) { std::cout << x << std::endl; break; }} int main() { std::thread t1(func1); std::thread t2(func2); t1.join(); t2.join();}大家猜一猜这段代码会输出什么?100吗?绝大数时候会是100,但是也有极小概率会是0,理论上有输出0的可能。这里涉及到内存序(memory order)的知识点,在这之前,需要先了解下什么是改动序列。在一个C++程序中,每个对象都具有一个改动队列,它由所有线程在对象上的全部写操作构成,变量的值会随着时间推移形成一个序列,不同线程观察同一个变量的序列,正常情况下是一致的,如果出现不一致,就说明出现了数据竞争线程不安全的问题。
看图,随着时间的推移,一个变量可能做了图中的改动,产生了一个改动序列,即(1,2,3,4,5,6,7,8,9,10),然而理论上来说,不同线程不能保证他们看见的是最新的值,比如同一时刻,线程a可能看见的是5,线程b可能看见的是4,线程c可能看见的是3,d是4,e是2。然后过了一段时间,可能变成了a(10),b(4),c(8),d(6),e(3)。每个线程看到的只会是序列中上一次看到的之后的值,不可能是之前的,时光不能倒流。同理,如图:
如果有两个变量,它们的改动序列如图,然而同一时刻,理论上可能不同线程看到的值不同。再回到上面那段代码:
int x = 0;int y = 0;void func1() { x = 100; y = 2;}void func2() { while (y == 2) { std::cout << x << std::endl; break; }}int main() { std::thread t1(func1); std::thread t2(func2); t1.join(); t2.join();}t1和t2线程看到的x和y值可能有(0,0)(0,2)(100,0)(100,2),所以上面的代码运行时,正常会输出100,但是也有可能会输出0。你可能会说,可得了吧,我测试了几十万次,输出的都是100,从没出现过0。是的,大概率都是100,这种现象只有理论上会出现,而且估计意外只会在内存序相对宽松的Arm机器上会出现,正常的X86应该不会出现这种问题。但我们写代码还是要往标准了写。为什么会出现这种现象?因为编译器有指令乱序的优化。还是上面那段代码:
int x = 0;int y = 0;void func() { x = 100; y = 2;}函数func()中,按顺序来看可能是先执行x = 100再执行y = 2,但实际情况可能不同,有可能编译器会做一些指令重排序的优化,真正优化后的结果可能会是y = 2,再x = 100,重排序后再运行,结果和顺序执行完全相同。(你可能会问,为什么要做这种优化,先执行谁后执行谁都需要执行,有意义吗?文中我只是举一个比较简单的例子,可能这里没有意义,但遇到真正复杂的代码时指令重排序还是很有效的优化策略,其实如果你学过计算机体系结构就会知道,这种没有任何依赖关系的指令是可以做并行优化的,具体是什么术语我也记不起来了,好像是SIMD。)编译器只会保证单线程环境下,优化执行的最终结果是一致的,所以这种优化就会导致多线程情况下的数据冲突问题,比如上面的代码:
void func1() { x = 100; y = 2;}void func2() { while (y == 2) { std::cout << x << std::endl; break; }}int main() { std::thread t1(func1); std::thread t2(func2); t1.join(); t2.join();}由于执行重排序的原因,无法保证另一个线程在执行func2的时候,x和y的赋值顺序,所以上面x的输出,有可能是0,也有可能是100。这也就是为什么会出现上面介绍的改动序列的原因。那怎么解决这种问题?肯定是要在某些情况下,禁止这种指令重排序。可以引入原子操作,我们可以把上面的x和y的定义改为:
std::atomic<int> x = 0;std::atomic<int> y = 0;结果自然而然就会变得正常。为什么?因为C++的atomic不仅仅是原子操作,它很重要的一点是可以禁止这种指令重排序。我们平时使用atomic可能都是这样使用:
int value = x.load();x.store(100);
但其实atomic的多数函数都是重载函数,它可以配置一些参数,这些参数就是内存序的类型参数:
x.store(100, std::memory_order_relaxed);
C++里关于一共引入了6种内存序的类型:
- memory_order_relaxexd:只有普通的原子性,没有任何内存次序的要求。
- memory_order_seq_cst:与代码顺序严格一致。
- memory_order_acquire:载入语义,当前线程,load操作之后的读写操作不能被重排序到当前指令前面。如果其它线程对此变量使用release的store操作,在当前线程是可见的。
- memory_order_release:存储语义,当前线程,store操作之前的读写操作不能重排序到当前指令后面,如果其它线程对此变量使用了acquire的load操作,当前线程store之前的任何读写操作都对其它线程可见。
- memory_order_acq_rel:它等于acquire + release
- memory_order_consume:C++17中明确建议我们不使用此次序,以后会被废弃掉,咱也就不纠结它了。
- 先后一致次序(Sequential Consistency Ordering):这就是atomic默认的内存次序,它是最直观、最符合直觉的内存次序,所有关于此次序的实例,都严格保持先后顺序,这种内存模型无法重新编排次序,它要求在所有线程间进行全局同步,因此也是代价最高的内存次序。
- 宽松次序(Relaxed Ordering):你可以理解为使用搭配这种次序的atomic,只有原子性,而对内存次序没有任何要求,指令重排序之类的优化还是正常进行。
- 获取-释放次序(Acquire-Release Ordering):它比宽松次序严格一些,却没有先后一致次序那样特别严格。在此次序模型中,载入(load)操作可以使用memory_order_acquire语义,存储(store)可以使用memory_order_release语义,而读-改-写(fetch_add、exchange)可以使用memory_order_acq_rel语义。
std::atomic<bool> x, y;std::atomic<int> z; void write_x_then_y() { x.store(true, std::memory_order_relaxed); y.store(true, std::memory_order_relaxed); } void read_y_then_x() { while (!y.load(std::memory_order_relaxed)) ; 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(); assert(z.load() != 0); }再看下使用acquire-release模型的代码会不会触发assert:
std::atomic<bool> x, y;std::atomic<int> z;void write_x_then_y() { x.store(true, std::memory_order_relaxed); y.store(true, std::memory_order_release);} void read_y_then_x() { while (!y.load(std::memory_order_acquire)) ; 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(); assert(z.load() != 0);}使用先后一致次序模型的代码这里就不过多介绍了,atomic的默认次序,肯定没问题的。所以在我们平时开发过程中,普通开发者不用管那么多,使用默认的atomic次序就行,资深程序员可以自由选用,充分利用更加细分的次序关系来提升性能,比如写一个高性能的无锁队列。一般使用默认的atomic足以,我估计大多数人写的代码,性能瓶颈一般都在业务逻辑上,而不是这种内存模型上。这里还有个memory fence的概念,大体作用和上面介绍的类似,感兴趣的可以自己了解一下哈。写到这里,推荐大家看看这段无锁队列的代码 https://github.com/taskflow/taskflow/blob/master/taskflow/core/tsq.hpp ,有助于理解C++的内存模型。





