__atomic_load_n

 

 

可以使用gcc提供的内置函数__atomic_load_n()来原子地一次性读取多个变量的值。该函数使用了GCC内置的原子操作,可以保证在不被中断的情况下完成读取。

以下是一个示例代码,读取三个整数变量a、b和c的值:

 

#include <stdio.h>
#include <stdatomic.h>

int main(void) {
    int a = 1, b = 2, c = 3;
    int abc[3];
    __atomic_load_n(&a, &abc[0], __ATOMIC_SEQ_CST);
    __atomic_load_n(&b, &abc[1], __ATOMIC_SEQ_CST);
    __atomic_load_n(&c, &abc[2], __ATOMIC_SEQ_CST);

    printf("a = %d, b = %d, c = %d\n", abc[0], abc[1], abc[2]);
    return 0;
}

在上面的示例中,先定义一个整数数组abc,用来存放a、b和c的值。然后使用__atomic_load_n()函数,将a、b和c的值一次性读取到数组abc中。

__atomic_load_n()函数接受三个参数:第一个参数是要读取的变量的地址,第二个参数是指向存储读取结果的变量的指针,第三个参数是内存序(Memory Order),常用的内存序有三种:__ATOMIC_RELAXED、__ATOMIC_ACQUIRE和__ATOMIC_SEQ_CST。在这个例子中,使用的是最强的内存序__ATOMIC_SEQ_CST,可以保证最终结果的正确性。

 

内存模型

 

因为编译器的优化,实际代码执行顺序不一定是你写的顺序。

 

Sequentially Consistent

该模型是最强的同步模式,参数表示为std::memory_order_seq_cst,同时也是默认的模型。

-Thread 1- -Thread2-
y = 1            if(x.load() ==2)
x.store      (2);   assert (y ==1)
对于上面的例子,即使x和y是不相关的,通常情况下处理器或者编译器可能会对其访问进行重排,但是在seq_cst模式下,x.store(2)之前的所有memory accesses都会happens-before在这次store操作。

另外一个角度来说:对于seq_cst模式下的操作,所有memory accesses操作的重排不允许跨域这个操作,同时这个限制是双向的。

 

 

 

 

标记上 std::memory_order_seq_cst 的原子操作不仅满足释放-获取顺序的要求(一个线程中 store-release 之前的写入操作在另一个 load-acquire 之后都可见),而且为所有如此标记的原子操作建立了唯一的全局统一修改顺序(single total modification order)。

正式地说,在不考虑 std::atomic_thread_fence 的情况下,对于每个 load 原子变量 M 的操作 B(标记为 std::memory_order_seq_cst),它读取到的值来自以下三种可能:

  • 在上述唯一的全局统一修改顺序中的上一个修改了 M 的操作 A 的结果;
  • 若存在这样的 A,B 还可能读到另一个修改了 M 的操作 C,它没有标记为 std::memory_order_seq_cst,并且不先于(happens-before) A;
  • 若不存在这样的 A,B 读取的结果来自另一个修改了 M 的没有标记为 std::memory_order_seq_cst的操作 D。

顺序一致对于多生产者多消费者的情形是必要的。这是因为,所有消费者必须能够以相同的顺序观察到所有生产者的行为。

在所有多核系统中(注:逻辑核),完全的顺序一致都会插入大量内存屏障指令。这使得相应的内存访问需要对所有核心进行广播,因而可能成为性能瓶颈。

下例中,顺序一致即是必要的。其他更弱的顺序模型可能导致线程 C 和线程 D 观察到原子变量 x 和 y 以不同的顺序修改,从而导致 assert 失败。

#include <thread>
#include <atomic>
#include <cassert>

std::atomic<bool> x = {false};
std::atomic<bool> y = {false};
std::atomic<int> z = {0};

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() {
    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);  // will never happen
    return 0;
}

 

理解2

SC的作者Lamport给的严格定义是:
“… the result of any execution is the same as if the operations of all the processors were executed in some sequential order, and the operations of each individual processor appear in this sequence in the order specified by its program.”

这个概念初次理解起来拗口,不过不要紧,下面我会给出个很直观的例子帮助理解。

假设我们有两个线程(线程1和线程2)分别运行在两个CPU上,有两个初始值为0的全局共享变量x和y,两个线程分别执行下面两条指令:

初始条件: x = y = 0;

 因为多线程程序是交错执行的,所以程序可能有如下几种执行顺序:

 

当然上面三种情况并没包括所有可能的执行顺序,但是它们已经包括所有可能出现的结果了,所以我们只举上面三个例子。我们注意到这个程序只可能出现上面三种结果,但是不可能出现r1==0 and r2==0的情况。

SC其实就是规定了两件事情:
(1)每个线程内部的指令都是按照程序规定的顺序(program order)执行的(单个线程的视角)
(2)线程执行的交错顺序可以是任意的,但是所有线程所看见的整个程序的总体执行顺序都是一样的(整个程序的视角)

第一点很容易理解,就是说线程1里面的两条语句一定在该线程中一定是x=1先执行,r1=y后执行。第二点就是说线程1和线程2所看见的整个程序的执行顺序都是一样的,举例子就是假设线程1看见整个程序的执行顺序是我们上面例子中的Execution 1,那么线程2看见的整个程序的执行顺序也是Execution 1,不能是Execution 2或者Execution 3。

有一个更形象点的例子。伸出你的双手,掌心面向你,两个手分别代表两个线程,从食指到小拇指的四根手指头分别代表每个线程要依次执行的四条指令。SC的意思就是说:
(1)对每个手来说,它的四条指令的执行顺序必须是从食指执行到小拇指
(2)你两个手的八条指令(八根手指头)可以在满足(1)的条件下任意交错执行(例如可以是左1,左2,右1,右2,右3,左3,左4,右4,也可以是左1,左2,左3,左4,右1,右2,右3,右4,也可以是右1,右2,右3,左1,左2,右4,左3,左4等等等等)

其实说简单点,SC就是我们最容易理解的那个多线程程序执行顺序的模型。

Acquire/Release

GCC的wiki可能讲的不太清楚,查看下面的典型Acquire/Release的使用例子:

 

 

 

毫无疑问,如果是seq_cst,那么上面的操作一定是成功的(打印变量b显示为1)。

a. memory_order_release保证在这个操作之前的memory accesses(store/load)不会重排到这个操作之后去,但是这个操作之后的memory accesses可能会重排到这个操作之前去。通常这个主要是用于之前准备某些资源后,通过store+memory_order_release的方式”Release”给别的线程;

b. memory_order_acquire保证在这个操作之后的memory accesses(store/load)不会重排到这个操作之前去,但是这个操作之前的memory accesses可能会重排到这个操作之后去。通常通过load+memory_order_acquire判断或者等待某个资源,一旦满足某个条件后就可以安全的“Acquire”消费这些资源了。
 

#include <thread>
#include <atomic>
#include <cassert>
#include <string>
std::atomic<bool> ready{ false };
int data = 0;
void producer()
{
    data = 100;                                       // A
    ready.store(true, std::memory_order_release);     // B
}
void consumer()
{
    while (!ready.load(std::memory_order_acquire))    // C
        ;
    assert(data == 100); // never failed              // D
}
int main()
{
    std::thread t1(producer);
    std::thread t2(consumer);
    t1.join();
    t2.join();
    return 0;
}

 让我们分析一下这个过程:

  • 首先 A 不允许被移动到 B 的后面。
  • 同样 D 也不允许被移动到 C 的前面。
  • 当 C 从 while 循环中退出了,说明 C 读取到了 B store()的那个值,此时,Thread-2 保证能够看见 Thread-1 执行 B 之前的所有写入操作(也即是 A)

 

Consume(数据依赖)

这是一个相比Acquire/Release更加宽松的内存模型,对非依赖的变量也去除了happens-before的限制,减少了所需同步的数据量,可以加快执行的速度。打上此标记的 load 操作对相关内存位置施加消费操作(consume operation):当前线程中,所有依赖当前 load 操作读取的值的读写操作不得重排序至当前操作之前。

 

若在线程 A 当中的原子 store 操作被标记上 std::memory_order_release,而若在线程 B 当中相同原子变量的 load 操作被标记上 std::memory_order_consume,则所有在线程 A 看来先于(happens-before)该 store 操作的那些内存写入(包括非原子变量写入和宽松顺序的原子变量写入),在线程 B 中依赖该原子变量的表达式和函数看来都有可见副作用(Visible side-effects)。也就是说,一旦线程 B 的原子 load 操作完成,线程 B 中依赖该原子变量的表达式和函数可见线程 A 写入内存的所有内容

这一同步仅只建立在对同一原子变量执行消费操作和获取操作的线程中。其他线程观察到的内存访问顺序可能异于同步的线程之中的任意一个。

在除 DEC Alpha 之外的主流 CPU 上,释放-消费顺序(亦称:依赖顺序)是自动保证的。因此,对于释放-获取顺序的同步来说,无需引入额外的 CPU 指令(来确保内存顺序);但在编译器优化阶段,仍需加入一些限制(例如:编译器不能将非原子的 store 操作挪到原子 store-release 操作之后;亦不能将涉及到依赖链的非原子的 load 操作挪到原子 load-consume 操作之前)。

该顺序的使用,往往见于对并发共享数据结构有频繁读取而极少写入的场景(例如路由表、安全策略、防火墙规则等)。

注意,截至 2015 年 2 月,尚未有编译器追踪了依赖链条,因此,消费操作被提升为获取操作。

下例中,通过原子变量 ptr 建立起了 producer 线程和 consumer 线程之间的释放-消费同步,因此第一个 assert 永远不会失败,但第二个 assert 可能失败。

#include <thread>
#include <atomic>
#include <cassert>
#include <string>

std::atomic<std::string*> ptr;
int data;

void producer() {
    std::string* p  = new std::string("Hello");
    data = 42;
    ptr.store(p, std::memory_order_release);
}

void consumer() {
    std::string* p2;
    while (nullptr == (p2 = ptr.load(std::memory_order_consume)));
    assert(*p2 == "Hello"); // never fires: *p2 carries dependency from ptr
    assert(data == 42); // may or may not fire: data does not carry dependency from ptr
}

int main() {
    std::thread t1(producer);
    std::thread t2(consumer);
    t1.join(); t2.join();
    return 0;
}

 

 Relaxed

 Relaxed 是最弱的 memory order。事实上,relaxed 应该称为“没有 memory order”:如果某个原子操作使用 relaxed 作为其 memory order,那么这个原子操作将退化为一个单纯的原子操作,不再具有线程同步节点的作用

 

 在这种模型下,std::atomicload()store()都要带上memory_order_relaxed参数。Relaxed ordering 仅仅保证load()store()是原子操作,除此之外,不提供任何跨线程的同步。
  先看看一个简单的例子:

 

             std::atomic<int> x = 0;     // global variable
             std::atomic<int> y = 0;     // global variable
          
Thread-1:                                  Thread-2:
r1 = y.load(memory_order_relaxed); // A    r2 = x.load(memory_order_relaxed); // C
x.store(r1, memory_order_relaxed); // B    y.store(42, memory_order_relaxed); // D

执行完上面的程序,可能出现r1 == r2 == 42。理解这一点并不难,因为编译器允许调整 C 和 D 的执行顺序。如果程序的执行顺序是 D -> A -> B -> C,那么就会出现r1 == r2 == 42

 

1. 比如对于a的修改,内存模型应该使用release。保证在这个操作之前的memory accesses不会重排到这个操作之后去,这样就不会向消费者提前释放可用信号。

__atomic_store_n(&a, true, __ATOMIC_RELEASE);

 

2 .对于a的读取,内存模型应该使用acquire。保证在这个操作之后的memory accesses不会重排到这个操作之前去,这样就不会提前读到生产者还未写完的数据。

__atomic_load_n(&a, __ATOMIC_ACQUIRE);

3 .对b的修改,调用atomic_compare_exchange_n函数,最后两个参数应该都是ATOMIC_RELAXED,即内存模式使用relaxed,即没有约束。因为b只是多个操作者之间用来做类似互斥的竞争

 

 

C++11中的内存模型下篇 - C++11支持的几种内存模型

posted on 2023-10-14 11:44  tycoon3  阅读(591)  评论(0编辑  收藏  举报

导航