乐趣区

关于内存:Datenlord-内存顺序问题一

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

内存程序

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

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

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

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

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

也能够换一种表白:

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

换一种表白是为了不便前面了解 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 ++ 的其余内存程序和同步告诉机制做具体介绍。

作者 | 王璞

退出移动版