cover_image

并发编程重要知识点:内存模型

程序喵大人 程序喵大人
2024年03月17日 12:56
大家好,我是程序喵,最近事情太多,好久才能肝出一篇文章,大家见谅。
这篇文章简单介绍下并发编程中一个重要的知识点:内存模型。
直接看这段代码:
#include <iostream>#include <thread>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)。
每个线程看到的只会是序列中上一次看到的之后的值,不可能是之前的,时光不能倒流。
同理,如图:
图片
如果有两个变量,它们的改动序列如图,然而同一时刻,理论上可能不同线程看到的值不同。
再回到上面那段代码:
#include <iostream>#include <thread>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中明确建议我们不使用此次序,以后会被废弃掉,咱也就不纠结它了。
尽管有6种内存序,但其实可简单划分为3种模式
  • 先后一致次序(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语义。
所以,我们看下这段使用relaxed模型的代码会不会触发assert:
#include <assert.h>#include <atomic>#include <thread>
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:
#include <assert.h>#include <atomic>#include <thread>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++的内存模型。
参考资料:
  • https://en.cppreference.com/w/cpp/atomic/memory_order
  • https://mp.weixin.qq.com/s/t5_Up2YZEZt1NLbvgYz9FQ
  • 《C++ 并发编程第二版》
更多内容在 一个优质的C++学习圈 里,来一起钻研C++吧。

图片

继续滑动看下一个
程序喵大人
向上滑动看下一个