Datenlord |内存顺序问题(一)

发布时间:2023-02-04 09:30

内存顺序,通俗地讲,是关于代码编译成机器指令后的执行顺序问题。内存顺序和编译器、硬件架构密切相关。那为什么会产生内存顺序问题呢?有两方面原因: 一方面,编译器为了优化程序性能,不会完全按照开发者写的代码的顺序来生成机器指令; 另一方面,在程序运行时,为了提高性能,CPU也不完全按照程序的指令顺序执行,比如体系结构里经典的Tomasulo算法。

对于大部分开发者而言,在写单线程程序,或者基于锁(Mutex)和信号量(Semaphore)之类编程框架提供的同步元语写多线程程序的时候,并不需要关心内存顺序的问题。 这是因为编译器和硬件架构保证了,虽然指令执行顺序可能跟开发者写的代码语句的顺序不一致,但是执行后的结果是一样的,即语义一致。 换句话讲,编译器和硬件架构提供了一层抽象用以屏蔽内存顺序问题,保证代码和编译出来的程序执行语义一致。 这样一方面提高程序性能,另一方面让开发者不用关心底层细节。编译器和硬件架构提供的这一层抽象叫作内存模型(Memory Model)。

这种为了便于理解和使用而提出一层抽象以屏蔽底层复杂细节的做法,在各个学科中比比皆是。 类比经典力学和相对论,在远低于光速的运动中,适用经典力学,在接近或达到光速的运动中,适用相对论而不适用经典力学; 经典力学和相对论之间,有一层抽象,速度远低于光速,抽象成立,速度接近或达到光速,抽象被打破。 类似的,编译器和硬件架构提供了内存模型这一层抽象用以屏蔽内存顺序问题。 对于大部分开发者而言,写单线程程序,或基于编程框架提供的同步元语写多线程程序的时候,内存模型抽象成立,无需考虑内存顺序问题; 当开发者写多线程程序,对于多线程并发访问(或读或写)共享数据,使用原子操作,而不是基于锁互斥访问数据,即无锁化编程的时候, 这时内存模型的抽象被打破,开发者必须考虑内存顺序问题。

内存顺序问题涉及编译器和硬件架构的很多细节,我尝试用对于大部分开发者来说浅显易懂的语言来描述内存顺序问题, 尽可能避免编译器和硬件架构的实现细节,以便于大家理解。 下面依次介绍内存模型、内存顺序、原子操作,最后以C++11为例讲解开发者如何规约内存顺序。

内存模型

内存模型是编程语言对程序运行时内存访问模式的抽象,即内存被多个程序(进程和线程)共享,程序对内存的访问是无法预知的。 通俗地讲,内存模型指的是CPU并发随机访问内存,或从内存加载数据(Load)或把数据写入到内存(Store)。 Load和Store是机器指令(或汇编语言)的术语,其实就是读(Read)操作和写(Write)操作。 这里,内存模型屏蔽了很多硬件的细节,比如CPU的寄存器、缓存等等(因为寄存器和缓存属于程序执行上下文,CPU访问寄存器和缓存不存在并发)。 内存模型比较好理解,每个开发者或多或少都接触到内存模型。 有了内存模型这一层抽象,那么内存顺序问题可以等价于读操作和写操作的执行顺序问题,因为内存模型里CPU对内存的访问只有读和写两种操作。

开发者在写代码时,代码语句的先后顺序往往约定了对内存访问的先后顺序的,即使访问的不是同一个内存地址。但是这个约定是基于内存模型这层抽象成立的前提。 前面提到,内存模型在单线程编程和基于编程框架提供的同步元语实现多线程编程的情况下,对内存顺序问题进行屏蔽,怎么理解呢?

下面通过例子说明单线程程序的内存顺序问题:

int x, y = 0;
x = y + 1;
y = 2;

这段代码定义了两个整数,x和y,并对y初始化赋值为0,然后给x赋值的时候用到y的值,之后再给y赋值。 看上去,对y的写操作必须在对x的写操作之后,但是改写上述代码片段如下:

int x, y = 0;
int tmp;
tmp = y;
y = 2;
x = tmp + 1;

增加了变量tmp之后,首先把y的值付给tmp,然后就可以先对y赋新值,再给x赋值。对x和y来说,上面两段程序的执行结果是等价的。 变量tmp在这里可以理解为是CPU的寄存器,有了寄存器的帮助,代码里的读操作和写操作先后顺序可能被改变。 通俗地讲,编译器对代码语句的顺序调整也是类似的原理 (仅供对编译器不熟的读者理解编译器如何对代码语句顺序的调整,实际编译器对代码的优化很复杂,细节暂不展开)。

上述例子说明了,单线程情况下,内存模型的抽象成立,开发者无需考虑内存顺序问题。 再考虑多线程的情况,把对x的写操作和对y的写操作放在不同的线程里:

int x, y = 0;

void thread_func1() {
    x = y + 1;
}

void thread_func2() {
    y = 2;
}

可以看出,x会有多种结果,取决于程序运行时两个线程的执行顺序,这就跟之前单线程的执行结果不一致了。 因为这里没有采用编程框架提供的同步元语来实现线程间同步,内存模型的抽象被打破,编译器和硬件架构无法保证语义一致。 此时,开发者要么采用编程框架提供的同步元语实现线程间同步以满足内存模型的抽象,要么显式规约指令执行顺序以保证结果正确。 改写上面的例子,可以采用编程框架提供的同步元语,规约程序运行时线程的执行顺序,这里使用信号量来实现线程间同步:

sem_t semaphore; 
// 初始化信号量,初始值为0,最大值为1
sem_init(&semaphore, 0, 1); 

int x, y = 0;

void thread_func1() {
    x = y + 1;
    sem_post(&semaphore); 
}

void thread_func2() {
    sem_wait(&semaphore);
    y = 2;
}

可以看出,使用信号量规约了两个线程在程序运行时的执行顺序,线程函数thread_func2要等待线程函数thread_func1对x完成赋值后才能对y赋值。 上述例子中,采用信号量之后,内存模型的抽象成立,多线程情况下的执行结果和单线程情况下一样,即语义一致。

内存顺序

从上面对内存模型的介绍可以看出,内存顺序,通俗地讲,就是规约编译器和硬件架构对读写操作的执行顺序。 当内存模型抽象成立时,内存模型对内存顺序做出规约,从而对开发者屏蔽内存顺序问题;当内存模型不成立时,开发者就需要显式规约内存顺序。

前述讲内存模型用到的例子展示了对两个写操作的内存顺序问题。推而广之,内存顺序包含四种情况:

四种情况 读操作在后 写操作在后
读操作在先 读读 读写
写操作在先 写读 写写

即,读操作与读操作、读操作与写操作、写操作与读操作、写操作与写操作,四种情况下的指令执行顺序问题(不论是否读写同一个内存地址)。 开发者可以要求编译器和硬件架构在上述四种情况下分别做出规约,即:

  • 读读,读操作之后的读操作,之间的顺序不能改变;
  • 读写,读操作之后的写操作,之间的顺序不能改变;
  • 写读,写操作之后的读操作,之间的顺序不能改变;
  • 写写,写操作之后的写操作,之间的顺序不能改变。

ItVuer - 免责声明 - 关于我们 - 联系我们

本网站信息来源于互联网,如有侵权请联系:561261067@qq.com

桂ICP备16001015号