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

7次阅读

共计 4243 个字符,预计需要花费 11 分钟才能阅读完成。

上一篇文章介绍了内存模型,并介绍了两种内存程序,memory_order_acquire(Acquire)和 memory_order_release(Release)。集体认为,这两种内存程序是 C ++ 定义的六种内存程序中最重要的两种,只有了解了 Acquire 和 Release 的语义,能力更好了解其余四种内存程序的语义。更进一步,在理论应用场景中,Acquire 和 Release 是最常见的两种内存程序。

如何判断该应用哪种内存程序?这是开发者在应用原子类型和无锁化编程时最常碰到的问题。本篇 Blog 用理论的例子来阐明,如何判断该应用哪种内存程序。此外,为了更深刻了解基于原子操作和基于锁实现的同步关系的本质区别,本篇 Blog 还会介绍 Happen-Before 关系和 Synchronize-With 关系。

Happen-Before 关系

线程间的同步关系,是要约定不同线程里产生的事件的先后秩序,互斥关系实质也是一种同步关系。Happen-Before 关系就是用于定义不同事件之间的先后秩序。Happen-Before 关系能够是在代码里动态约定好(基于锁的形式),也能够是在程序运行时动静发现(基于原子操作和内存程序的形式)。

先来看一个简略的例子,这个例子解释了 Happen-Before 关系:

int data = 0;
int flag = 0;

// thread 1
void thread_func1() {
    data = 42;
    flag = 1; // 事件 1
}

// thread 2
void thread_func2() {if (flag == 1) // 事件 2
        printf("%d", data);
}

下面的例子里定义了两个全局变量,线程 1 设置 flag = 1 示意实现对 data 的赋值,线程 2 读取 flag 的值用以判断线程 1 是否实现对 data 的赋值,如果 flag == 1 则输入 data 的值。咱们定义两个事件,事件 1 为 thread_func1 里对 flag 赋值示意对 data 的赋值实现,事件 2 为 thread_func2 里判断 flag == 1,如果 flag == 1 则输入 data 的值。因为没有用锁的形式在代码里动态规约事件 1 和事件 2 的先后顺序,程序运行时能够有多种后果,有些后果是正当的,有些后果是不合理的。其中两种正当的后果是:要么线程 2 输入 data 的值 42,要么不输入。也就是说要么事件 1 Happen-Before 事件 2,要么事件 2 Happen-Before 事件 1。然而,还有些不合理的后果,比方线程 2 有可能输入 data 的值为 0,为什么呢?因为编译器或 CPU 会对程序进行优化,使得指令的执行程序跟代码的逻辑程序不统一。比方编译器可能对 thread_func2 进行如下优化:

// thread 2
void thread_func2() {
    int tmp = data;
    if (flag == 1)
        printf("%d", tmp);
}

这里 tmp 代表某个寄存器,编译器优化 thread_func2 导致在判断 flag == 1 前把 data 的值先载入寄存器,此时 data 的值可能为 0,判断完 flag == 1 之后再输入寄存器的值,此时即使 data 曾经被 thread_func1 赋值为 1,然而寄存器 tmp 里的值依然是 0。也就是说,程序运行时产生不合理的后果,是因为没有保障事件 1 和事件 2 之间的先后秩序,导致两个事件在运行时有重叠。因而,为了保障下面的例子运行产生正当的后果,咱们须要确保要么事件 1 Happen-Before 事件 2,要么事件 2 Happen-Before 事件 1。能够采纳基于锁的信号量机制,在代码里动态约定事件 1 在事件 2 之前产生,也能够采纳原子操作和内存程序在程序运行时动静发现事件 1 和事件 2 之间的关系。

这里咱们先给出基于原子操作和内存程序实现线程同步的实现。分两个步骤,先确定采纳何种内存程序,再确定采纳哪种原子操作。

下面的程序产生不合理的后果,究其原因,是因为编译器和 CPU 对程序指令的优化,导致代码逻辑程序和理论指令执行程序不统一。因而,咱们要用内存程序来通知编译器和 CPU 确保指令执行程序和代码的逻辑程序统一。上述例子里,thread_func1 里的两行赋值语句(两个写操作)程序不能颠倒,thread_func2 里判断语句和打印语句(两个读操作)程序不能颠倒:

int data = 0;
int flag = 0;

// thread 1
void thread_func1() {
    data = 42;
    // 写操作之前的写操作,之间的程序不能扭转
    flag = 1; // 事件 1
}

// thread 2
void thread_func2() {if (flag == 1) // 事件 2
        // 读操作之后的读操作,之间的程序不能扭转
        printf("%d", data);
}

不相熟读写操作程序的读者倡议先读一下上一篇 Blog 里介绍的四种读操作与写操作的先后顺序关系。回忆上一篇 Blog 定义过 Acquire 和 Release 的语义:

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

能够看出:要规约“写操作之前的写操作之间的程序不能扭转”(写写),得采纳 Release 语义;要规约“读操作之后的读操作,之间的程序不能扭转”(读读),得采纳 Acquire 语义。

确定了内存程序,咱们再思考该如何应用原子操作,确保要么事件 1 Happen-Before 事件 2,要么事件 2 Happen-Before 事件 1,不能让两个事件在运行时有重叠。一种做法,咱们能够让 data 成为原子变量,那就不须要 flag 这个告诉变量了,两个线程间接原子拜访 data。然而理论中,往往 data 代表的数据会比拟大,不适宜作为原子变量,因而才须要 flag 这个告诉变量。因而,咱们让 flag 成为原子变量,两个线程原子拜访 flag 来实现同步,进而确保事件 1 和事件 2 之间的先后顺序:

#include <atomic>

std::atomic_int flag(0); // 初始值为零
int data = 0;

// thread 1
void thread_func1() {
    data = 42;
    flag.store(1, // 事件 1
            std::memory_order_release); 
}

// thread 2
void thread_func2() {
    int ready = flag.load( // 事件 2
            std::memory_order_acquire);
    if (ready == 1)
        printf("%d", data);
}

要留神一点,下面采纳原子操作和内存程序,只能确保事件 1 和事件 2 之间先后产生,存在先后秩序关系,然而不能保障事件 1 肯定在事件 2 之前产生,或者事件 2 肯定在事件 1 之前产生。两个事件谁先谁后(Happen-Before 关系)须要在程序运行时能力确定。

Synchronize-With 关系

Synchronize-With 关系是指,两个事件,如果事件 1 Happen-Before 事件 2,那要把事件 1 同步给事件 2,确保事件 2 得悉事件 1 曾经产生。

先来看采纳信号量机制来实现前述事件 1 和事件 2 之间的同步:

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

int  = 0;

void thread_func1() {
    data = 42;
    sem_post(&flag); // 事件 1
}

void thread_func2() {sem_wait(&flag); // 事件 2
    printf("%d", data);
}

采纳信号量,使得这两个线程运行后果只有一种(动态规约),即只有一种 Happen-Before 关系,事件 1 Happen-Before 事件 2:

不管 thread_func1 和 thread_func2 谁先开始运行,thread_func2 都会等 thread_func1 执行完 sem_post(&flag) 之后,才输入 data 的值 42。
显然,大家看到了基于原子操作和内存程序,与基于信号量的实现,失去不同的后果。这也就是我在上一篇 Blog 里提到的,基于原子操作和内存程序,跟基于锁和信号量实现的线程间同步关系有实质的差别。基于锁和信号量的线程间同步关系,比基于原子操作和内存程序的线程间同步关系要更强。

回到之前的例子,基于信号量实现两个线程间的同步,只有一种运行后果(动态规约),thread_func1 里 sem_post(&flag) 肯定在 thread_func2 输入 data 的值之前。也就是说,信号量确保了事件 1 Happen-Before 事件 2,同时也在运行时确保了事件 1 Synchronize-With 事件 2(通过 thread_func1 里 sem_post(&flag) 和 thread_func2 里 sem_wait(&flag) 来确保 Synchronize-With 关系),因此基于信号量的实现保障最终后果肯定是 thread_func2 输入 data 的值 42。

然而,对于上述例子,基于原子操作和内存程序实现两个线程间的同步,会有两种运行后果(动静发现),要么 thread_func2 输入 data 的值 42,要么 thread_func2 不输入 data 的值。也就是说,基于原子操作和内存程序,只能保障事件 1 和事件 2 之间存在先后秩序,即要么事件 1 Happen-Before 事件 2,要么事件 2 Happen-Before 事件 1。可见,基于原子操作和内存程序,无奈保障肯定只有事件 1 Happen-Before 事件 2 这一种关系。另外,在运行时,如果事件 1 Happen-Before 事件 2,基于 flag 这个原子变量的原子操作和内存程序的实现能够确保事件 1 Synchronize-With 事件 2(通过 thread_func1 里 flag.store(1, std::memory_order_release) 和 thread_func2 里 flag.load(std::memory_order_acquire) 来确保);如果在运行时,事件 2 Happen-Before 事件 1,那基于 flag 这个原子变量的原子操作和内存程序的实现无奈确保事件 2 和事件 1 之间有 Synchronize-With 关系,须要另行实现。

一句话总结,基于锁的同步机制,是在代码里动态约定不同线程里产生的事件之间的 Happen-Before 关系和 Synchronize-With 关系;而基于原子操作和内存程序,是在程序运行时动静发现事件之间的 Happen-Before 关系以及 Synchronize-With 关系。

作者 | 王璞

正文完
 0