内存程序,艰深地讲,是对于代码编译成机器指令后的执行程序问题。内存程序和编译器、硬件架构密切相关。那为什么会产生内存程序问题呢?有两方面起因: 一方面,编译器为了优化程序性能,不会齐全依照开发者写的代码的程序来生成机器指令; 另一方面,在程序运行时,为了进步性能,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,最大值为1sem_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赋值。 上述例子中,采纳信号量之后,内存模型的形象成立,多线程状况下的执行后果和单线程状况下一样,即语义统一。

内存程序

从上面对内存模型的介绍能够看出,内存程序,艰深地讲,就是规约编译器和硬件架构对读写操作的执行程序。 当内存模型形象成立时,内存模型对内存程序做出规约,从而对开发者屏蔽内存程序问题;当内存模型不成立时,开发者就须要显式规约内存程序。

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

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

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

  • 读读,读操作之后的读操作,之间的程序不能扭转;
  • 读写,读操作之后的写操作,之间的程序不能扭转;
  • 写读,写操作之后的读操作,之间的程序不能扭转;
  • 写写,写操作之后的写操作,之间的程序不能扭转。

也能够换一种表白:

  • 读读,读操作之前的读操作,之间的程序不能扭转;
  • 读写,写操作之前的读操作,之间的程序不能扭转;
  • 写读,读操作之前的写操作,之间的程序不能扭转;
  • 写写,写操作之前的写操作,之间的程序不能扭转。

换一种表白是为了不便前面了解C++原子操作的内存程序。

原子操作

原子操作要么执行胜利,要么尚未开始执行,不存在中间状态。 原子操作是要靠底层硬件架构来实现,只有硬件架构的某些指令能力保障原子操作,比方Compare and Swap(CAS)指令。 编程语言基于硬件架构的原子操作指令封装了一些原子类型以及原子操作(函数调用),以不便开发者应用。 如前所述,当内存模型形象成立的时候,开发者无需思考内存程序问题; 当开发者应用原子操作的时候,内存模型的形象被突破,此时开发者必须显式规约原子操作的内存程序。

另外,当CPU读写地址对齐的内存数据的时候,有可能是原子操作。 比方32位CPU,拜访一个32位整数,如果这个整数的地址是4的倍数,即内存地址对齐,那么拜访操作就是原子的, 即CPU执行一条指令(在一个指令周期内)读取或写入这个整数; 然而如果这个整数的地址不是4的倍数,那CPU还是要两次拜访(执行两条指令)能力读取或写入这个整数,在这两次拜访两头CPU有可能被其余程序抢占。 因为在编程的时候不能假如数据的内存地址肯定是4的倍数,所以开发者要默认每一条代码语句都不是原子操作,除非明确应用原子操作。

C++的内存程序

上面以C++语言为例,介绍开发者如何显式对原子操作的内存程序做出规约,即要求编译器和硬件架构保障依照冀望的程序来执行原子操作指令。

C++11提供了Atomic泛型,用于封装原子类型和原子操作。C++还定义了atomic_int、atomic_long、atomic_bool等类型,不便开发者间接应用。 上面的代码片段给出了Atomic泛型的定义,以及三个Atomic泛型的办法(为了便于读者了解,办法的定义略有删节):

template <class T> struct atomic;...T load (memory_order sync) const noexcept;void store (T val, memory_order sync) noexcept;bool compare_exchange_strong (T& expected, T val,        memory_order sync) noexcept;...

下面Atomic泛型的办法里有个输出参数sync的类型memory_order,用于规约Atomic泛型办法的内存程序。memory_order在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_order;

限于篇幅,这里只介绍memory_order_acquire(简称Acquire)和memory_order_release(简称Release)这两种内存程序,后续再介绍C++的其余内存程序。 下表给出了Acquire和Release的语义:

先后秩序语义
Acquire读操作在前读读、读写
Release写操作在后读写、写写

即,Acquire要求,针对某个读操作,该读操作之后的读操作或写操作,这两种状况下的指令程序不能扭转; Release要求,针对某个写操作,该写操作之前的读操作或写操作,这两种状况下的执行程序不能扭转。、 能够看出,Acquire和Release波及四种内存程序中的三种状况,读读、读写和写写,不波及写读这种状况。

采纳原子操作和Acquire和Release语义改写之前介绍内存模型用到的例子:

#include <atomic>#include <iostream> #include <vector>std::atomic_int indicator (0); // 初始值为零int x, y = 0;void thread_func1() {    x = y + 1;    // 告诉thread_func2    indicator.store(1, // 写操作            std::memory_order_release); }void thread_func2() {    int ready = indicator.load( // 读操作            std::memory_order_acquire);    // 期待thread_func1    if (read > 0) {        y = 2;    }}

上述代码定义了一个原子整数变量indicator, 线程函数thread_func1在对x赋值实现之后,用indicator告诉线程函数thread_func2。 特地要留神两个原子操作indicator.store()和indicator.load()的内存程序, 为什么indicator.store()用Release语义,而indicator.load()用Acquire语义呢?

先思考indicator.store()的Release语义。 线程函数thread_func1先对x赋值,而后调用indicator.store()把indicator的值改为1, indicator.store()的执行程序不容许扭转,绝不能是先调用indicator.store()再给x赋值。 也就是说,对indicator的写操作indicator.store()之前的操作(对x赋值),必须保障在indicator.store()之前执行,合乎Release的语义。

再看indicator.load()的Acquire语义。 线程函数thread_func2先是调用indicator.load()读取indicator的值,查看是否不等于零,如果不为零,则对y赋值, indicator.load()的执行程序不容许扭转,绝不能是先对y赋值,再读取indicator的值。 也就是说,对indicator的读操作indicator.load()之后的操作(对y赋值),必须保障在indicator.load()之后执行,合乎Acquire的语义。

简略来说,对于原子写操作要求Release语义,对于原子读操作要求Acquire语义。 有些文章把Acquire和Release比作是对一个锁进行加锁和解锁操作,集体认为这样的比喻不是很精确。 因为,Release和Acquire这类内存程序,跟锁和信号量的形象层面不一样,内存程序比锁和信号量更底层,能够用原子操作和内存程序来实现锁和信号量。 原子操作indicator.store()和indicator.load()别离采纳Release和Acquire语义,定义了两个线程间的同步告诉关系(Synchronize with), 用indicator这个原子变量来批示x是否实现赋值。这种同步告诉关系不是动态规约好的,而是在程序运行时动静查看, 即x是否实现赋值并不阻塞线程函数thread_func2的执行,只是x是否实现赋值会影响线程函数thread_func2的执行后果, 有可能x尚未实现赋值但线程函数thread_func2曾经执行结束(此时线程函数thread_func2没有对y赋新值)。 如果采纳锁或信号量,则x尚未实现赋值会阻塞线程函数thread_func2的执行,这样能够保障线程函数thread_func2对y赋新值。 可见,采纳原子操作和内存程序规约的线程同步告诉机制,弱于锁和信号量等编程框架提供的同步元语实现的同步机制。 因而Release不是解锁操作,Acquire也不是加锁操作,这跟锁的互斥机制不一样。

当然能够改写线程函数thread_func2,使其忙期待线程函数thread_func1对x实现赋值:

void thread_func2() {    int ready;    // 期待thread_func1    do {        ready = indicator.load( // 读操作                std::memory_order_acquire);    } while (ready == 0);            y = 2;}

这样一来,相当于是基于原子操作和内存程序实现了一个信号量,只是这个信号量让线程函数thread_func2忙期待而不是阻塞休眠。 这种做法在Linux内核里很常见,比方某个中断响应程序采纳自旋锁(Spin Lock)忙期待某个资源,而不采纳锁以防止阻塞休眠, 因为中断处理程序自身如果阻塞休眠或被其余程序抢占,会导致很简单的程序上下文切换,也可能导致死锁。

限于篇幅,我后续再对C++的其余内存程序和同步告诉机制做具体介绍。

作者 | 王璞