赞
踩
在多核CPU时代,多线程成为一种更高效的编程方式,为了更好的利用多线程编程,我们有必要了解和学习linux内存模型。
讲cache之前,简单了解一下程序是怎么在机器运行的, linux环境下程序是运行在 RAM之中(默认都知道啊),RAM 就是所谓的DDR,称之为main memory(主存)。当我们在Linux环境下启动一个线程时,操作系统会进行调度,从磁盘中将可执行文件加载到主存中,然后开始运行程序,这个运行过程中会涉及到主存和CPU之间交互,为了方便叙述,我们用一个简单的例子说明这个程序执行过程,代码如下:
int a=0;a=a+1;
如果CPU需要将一个变量a(因为实际是内存操作,我们假设地址是A啊)加1,一般分为以下3个步骤:
整个过程如下图所示:
图1
上图显示的只是一个理想过程,理想是丰满的,但是现实是骨感的啊,其实现实中,CPU通用寄存器的速度和主存之间存在着太大的差异。两者之间的速度差距很大,CPU register的速度一般小于1ns,主存的速度一般是65ns左右。速度差异近百倍啊。因此,上面举例的3个步骤中,步骤1和步骤3实际上速度很慢。当CPU试图从主存中load/store 操作时,由于主存的速度限制,CPU不得不等待这漫长的65ns时间。所以为了提升处理效率,我们有必要提升主存的速度,出于成本和效率考虑,人们考虑在cpu与主存之间加入缓存在硬件上,我们将cache放置在CPU和主存之间,作为主存数据的缓存。当CPU试图从主存中load/store数据的时候, CPU会首先从cache中查找对应地址的数据是否缓存在cache中。如果数据缓存在cache中,直接从cache中拿到数据并返回给CPU。当存在cache的时候,程序运行流程如下:
图2
有没有感觉redis就是和这玩意差不多呢,事实上cache的花样很多,上图只是一个简单模式,事实上现代多核CPU都长成下图这样:
图3
但是,这就满足人们对性能的追求了吗?并没有。为了进一步提升性能,引入多级cache。前面提到的cache,称之为L1 cache(第一级cache)。我们在L1 cache 后面连接L2 cache,在L2 cache 和主存之间连接L3 cache。等级越高,速度越慢,容量越大,每个CPU也都有自己内部才能访问的缓存,结构变成了图3所示的这个样子啊,执行如下:
写到这你可能在想,这个缓存的结构和我们写c++代码有啥关系呢?我们考虑一种c++多线程的情况,不同的线程在不同核心运行时,这些缓存的存在会给数据同步带来挑战,当CPU结构发生变化,增加了只能由内部才能访问的缓存之后,一些在旧架构上不会出现的问题,在新的架构上就会出现。而本篇的主角内存模型(memory model),其作用就是规定了各种不同的访问共享内存的方式,不同的内存模型,既需要编译器的支持,也需要硬件CPU的支持。
我们从一个c++多线程处理数据开始引入多线程数据同步问题:
#include#include #include #include int A,B; void FuncA(){ A=1; std::cout<} void FuncB(){ B=2; std::cout<} int main(){ A=0; B=0; std::thread thread1(FuncA); std::thread thread2(FuncB); thread1.join(); thread2.join(); return 0; }
上图的程序主要做了这样的实验,程序执行之前,A=B=0,有两个线程同时分别执行如下的代码:
线程1(thread1) | 线程2(thread2) |
---|---|
1. A=1 | 3. B=2 |
2. print(B) | 4. print(A) |
猜猜这个程序最后的执行结果是啥?有兴趣可以跑跑这个程序。
其实这个程序的输出结果是不可控的,有多种可能:其可能的结果包括:(0,0)、(0,1)、(0,2)、(1,2)、(1,0)、(2,0)、(2,1)这7个结果,我么简单分析一下(0,1)、(0,2)、(1,2)、(1,0)、(2,0)、(2,1) 这6个结果。
以(0,1) 结果为例,考虑代码执行顺序 1->2->3->4;我们仅仅考虑一种理想状况,内部处理逻辑如下:
图4
具体步骤如下:
所以执行了以上步骤,最终输出的结果才是(0,1)。出现 (0,2)、(1,2)、(1,0)、(2,0)、(2,1) 这类结果分析过程如上,在此就不做详细说明,事实上仅仅从代码的执行顺序的可能,我们也能分析到以上可能结果。
接下来我们谈谈(0,0)这个结果,代码执行正常情况下有一个基本原则,就是“happen-before“ ,解释成中文就是优先,具体什么意思呢?我们给个例子吧,例如:
int a=100;int b=200;
就是程序在执行顺序和代码顺序一致,也就是必须先执行第一行,然后第二行,很简单的原则啊。在这个原则下,如果想输出(0,0),必须有以下顺序:2→3,4->1,但是基于happen-before这个原则,必须还有以下顺序:1→2; 3→4, 这四个顺序,不觉得矛盾吗?所以(0,0)看着像是不可能的,但是在新的CPU架构下可能会出现这种情况:新的CPU架构加入了写缓存,也就是在图3所示,写一个值可能值写到本核心的缓冲区中就返回了,接着执行下面的一条指令,因此可能出现以下的情况:
这种情况下(0,0)这个结果是会出现了。从上面的例子,我们可以得出以下结论:1)多核CPU加入缓存,对线程数据同步带来了额外的开销;2)多线程之间执行,彼此结果可以是不可见的。
注释:由于现在c++代码编译做了优化,程序在执行顺序和代码顺序不一定一致!
我们另外添加一个例子加以说明:
int data = 0;bool flag = false; void Task1(){ data=618 ; flag = true;} void Task2(){ while (!flag) { } std::cout<} int main(){ std::thread thread1(Task1); std::thread thread2(Task2); thread1.join(); thread2.join(); return 0;}
这个例子想通过thread1设置flag来输出结果。这里的问题是在并发执行下,thread1输出的结果可能不是618。原因在于现代CPU所采用的一致性模型。这里不会展开讨论有哪些一致性模型,重点在:
回顾一下图3中多核CPU结构,缓存的因素,现在CPU利用L1缓存,命令的提前执行,缓存一致性协议等多种技术,虽然能保证单线程程序按照代码顺序执行,但是默认不保证多线程之间的执行顺序关系。
具体来说,并发执行下,thread2有可能看到thread1执行顺序为先写入flag再写入data,thread1中data和flag不存在依赖关系,如果采取了编译优化,看到的是先data还是flag,也不好说。在线程之间数据同步过程中,因为thread1,thread2所在CPU各自有缓存,thread1把data和flag都写入后,就会发生这样一种可能,thread2读取自己缓存的data发现是0,过了一段时间,data同步到了thread2的CPU缓存,从而导致了这种不一致。
通过上面多线程读写数据的例子,由于缓存的存在,导致我们写c++代码执行后结果的不确定性,所以我们有必要为了消除这种确定性,C++11认为并发程序不能正确执行是因为顺序问题,所以规定了顺序就可以正确执行并发程序。从C++11开始,就支持以下几种内存模型:
typedef enum memory_order { memory_order_relaxed, memory_order_consume, memory_order_acquire, memory_order_release, memory_order_acq_rel, memory_order_seq_cst} memory_orde
与内存模型相关的枚举类型有以上六种,但是其实分为四类,如上所示,其中对一致性的要求逐渐增强,以下来分别讲解。
顺序一致性(Sequential Consistency,简写SC),定义如下:
A load operation with this memory order performs an acquire operation, a store performs a release operation, and read-modify-write performs both an acquire operation and a release operation, plus a single total order exists in which all threads observe all modifications in the same order
sc是一致性最强的,主要有两个要求:
我们用《C++ Concurrency in Action》中一个例子来说明这个问题:
#include #include #include std::atomic x,y;std::atomic z;void write_x(){ x.store(true,std::memory_order_seq_cst);}void write_y(){ y.store(true,std::memory_order_seq_cst);}void read_x_then_y(){ while(!x.load(std::memory_order_seq_cst)); if(y.load(std::memory_order_seq_cst)) ++z;}void read_y_then_x(){ while(!y.load(std::memory_order_seq_cst)); if(x.load(std::memory_order_seq_cst)) ++z;}int main(){ 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(); assert(z.load()!=0);}
执行上述代码,z的值不可能为0,我们简单分析一下为啥不可能为0,如果z为0,在c线程中必须有x=true,y=false;在d线程中必须有y=true,x=false,考虑以下三种情况:
顺序一致性要保证每个线程执行结果要迅速同步到其他线程,这个数据的同步代价是很大的。
下面我们引入c++ 11支持的第二种内存模型——松弛型内存模型(memory_order_relaxed),定义如下:
Relaxed operation: there are no synchronization or ordering constraints imposed on other reads or writes, only this operation's atomicity is guaranteed
也可以总结为以下特点:
我们同样用《C++ Concurrency in Action》中一个例子来说明这个问题:
#include #include #include std::atomic x,y;std::atomic z;void write_x_then_y(){ x.store(true,std::memory_order_relaxed); // 1 y.store(true,std::memory_order_relaxed); // 2}void read_y_then_x(){ while(!y.load(std::memory_order_relaxed)); // 3 if(x.load(std::memory_order_relaxed)) // 4 ++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); // 5
执行上面的代码,z的值是可能为0的,线程a的结果对b是不可见的,数据同步的不一致性,可能导致在b线程中y=true,x=false这种情况。
我们引入c++ 11支持的3种内存模型(本质是一类),首先是memory_order_acquire,定义如下:
A load operation with this memory order performs the acquire operation on the affected memory location: no reads or writes in the current thread can be reordered before this load. All writes in other threads that release the same atomic variable are visible in the current thread
这种内存模型主要是对读操作进行锁定,保证所有读写操作,不允许被移动到这个load()的前面。
与memory_order_acquire 相对的是memory_order_release,memory_order_release的定义如下:
A store operation with this memory order performs the release operation: no reads or writes in the current thread can be reordered after this store. All writes in the current thread are visible in other threads that acquire the same atomic variable and writes that carry a dependency into the atomic variable become visible in other threads that consume the same atom
这种内存模型主要是是对写操作进行锁定,保证所有读写操作,不允许被移动到这个store()的后面。
memory_order_acq_rel 是上面两者的综合,定义如下:
A read-modify-write operation with this memory order is both an acquire operation and a release operation. No memory reads or writes in the current thread can be reordered before or after this store. All writes in other threads that release the same atomic variable are visible before the modification and the modification is visible in other threads that acquire the same atomic variable.
由于memory_order_acquire和memory_order_release的特点,在使用过程中就可以定义顺序,假如在A线程中对变量V用memory_order_release执行store操作,在B线程中对V用memory_order_acquire执行load操作,release V之前的写操作,对在线程 B acquire V之后的任何读操作都可见,我们采用《C++ Concurrency in Action》中两个例子来说明这个问题:
#include #include #include std::atomic x,y;std::atomic z;void write_x(){ x.store(true,std::memory_order_release);}void write_y(){ y.store(true,std::memory_order_release);}void read_x_then_y(){ while(!x.load(std::memory_order_acquire)); if(y.load(std::memory_order_acquire)) // 1++z; }void read_y_then_x(){ while(!y.load(std::memory_order_acquire)); if(x.load(std::memory_order_acquire))++z; }int main() { 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(); assert(z.load()!=0); // 3}
事实上程序最后的执行结果 z仍然可能为0,因为改动在两个线程中,不能保证最后的结果。
#include #include #include std::atomic x,y;std::atomic z;void write_x_then_y(){ x.store(true,std::memory_order_relaxed); // 1 自旋,等待y被设置为true y.store(true,std::memory_order_release); // 2}void read_y_then_x(){ while(!y.load(std::memory_order_acquire)); // 3 if(x.load(std::memory_order_relaxed)) // 4 ++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); // 5
程序最后的执行结果 z不可能为0,x和y的store操作都是在同一个线程中,y采用的是memory_order_acquire内存模型,x的内存操作在y之前,当y=true时能保证x的数据变更到位,所以最后z不可能为0。
最后一种内存模型是memory_order_consume,定义如下:
A load operation with this memory order performs a consume operation on the affected memory location: no reads or writes in the current thread dependent on the value currently loaded can be reordered before this load. Writes to data-dependent variables in other threads that release the same atomic variable are visible in the current thread. On most platforms, this affects compiler optimizations only
memory_order_acquire 的同步机制太强大了,它将release之前的操作全部同步了,有时候没必要,memory_order_consume在此基础上做了弱化。
struct X{int i;std::string s;};std::atomic p;std::atomic a;void create_x(){ X* x=new X; x->i=42; x->s="hello"; a.store(99,std::memory_order_relaxed); // 1 p.store(x,std::memory_order_release); // 2}void use_x(){ X* x; while(!(x=p.load(std::memory_order_consume))) // 3 std::this_thread::sleep(std::chrono::microseconds(1)); assert(x->i==42); // 4 assert(x->s=="hello"); // 5 assert(a.load(std::memory_order_relaxed)==99); // 6}int main() { std::thread t1(create_x); std::thread t2(use_x); t1.join(); t2.join();}
下面我们介绍最后一种内存模型memory_order_consume,memory_order_consume的一致性比memory_order_acquire要差一点,同线程中,不能保证其余变量的变更同步到位,例如上面示例中,在线程t1中,a的代码顺序在x之前,但是对在t2线程中当x的数据同步到位时,a的数据变更仍然可能未同步到t2线程。
1.内存顺序一致性:https://blog.csdn.net/peterlin666/article/details/39080495
2.多线程内存模型:https://blog.csdn.net/pongba/article/details/1659952
3.Memory Consistency Models: A Tutorial:https://www.cs.utexas.edu/~bornholt/post/memory-models.html
4.Memory Ordering at Compile Time:https://preshing.com/20120625/memory-ordering-at-compile-time/
5.memory_order:https://en.cppreference.com/w/cpp/atomic/memory_order
6.理解 C++ 的 Memory Order:http://senlinzhan.github.io/2017/12/04/cpp-memory-order/
7.C++11 的六种 memory order https://www.zhihu.com/question/24301047
8.C++11中的内存模型:https://www.zhihu.com/question/24301047/answer/1194631375?utm_source=com.tencent.wework&utm_medium=social&utm_oi=1141391342323343360
9.Anthony Williams:C++ Concurrency in Action
董志雄,毕业于西安交通大学,现于贝壳找房语言智能与搜索部工作。
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。