C++ Concurrency In Action 笔记(二) - 原子操作与内存序

参考:

1. 概述

内存序原本是操作系统/物理结构上的概念,c++11 在操作系统的基础上,进一步封装定义了 6 种 std::memory_order 以供程序员使用。
内存序的概念个人感觉非常抽象和难以理解,本篇文章只做简单的学习总结,如需深入需要仔细阅读相关论文。

2. 操作系统内存模型

本节主要参考:

考虑有两个变量 A B,初始化时都为 0,分别有两个线程在不加锁的情况下同时执行如下操作:

thread T1:           |      thread T2:
a. A = 1             |      c. B = 2
b. print(B)          |      d. print(A)

程序可能会输出如下结果:

(0,0)、(1,0)、(0,2)、(1,2)、(0,1)、(2,0)、(2,1)

下面通过分析程序的输出结果,来引入内存模型的概念。

  • 情况一:(0,1)、(0,2)
    这种情况很好理解,即存在于两个线程依次运行时,例如 T1 先运行,运行完成后 T2 再运行,即 a > b > c > d,或者反过来 T2 先运行。这时可能输出 (0,1)、(0,2)
  • 情况二:(1,2)、(2,1)
    这种情况也很好理解,即存在于两个线程交替运行时,例如 T1 先运行,一种可能序列是 a > c > b > d。这时可能输出 (1,2)、(2,1)
  • 情况三:(0,0)、(1,0)、(2,0)
    这种情况似乎不会出现,但是在有些内存模型下,这种情况也可能存在,下文将进行分析。

2.1 Sequential Consistency(顺序一致性)内存模型

Sequential Consistency 要求最严格,但是也最看起来符合'直觉',其要求如下:

  • 每个处理器操作的是同一个内存,即写入操作是所有处理器立即可见的,所以不用担心 cpu cache 的问题
  • 单个处理器内,代码执行顺序与代码的书写顺序一样,所以不同担心乱序执行的问题

在这个内存模型下,前面情况三 (0,0)、(1,0)、(2,0) 这几种序列是不可能输出的。

2.2 Total Store Ordering(全存储排序)内存模型

这种内存模型主要引入了 cpu cache 的概念,例如对于输出 (0, 0) 的情况:

               +-------------------------+               +-------------------------+
a. A = 1       |         core 1          |               |         core 2          |      c. B = 2
b. print(B)    |--------+-------+--------|               |--------+-------+--------|      d. print(A)
               |  A = 1 | B = 0 |  ...   |               |  A = 0 | B = 2 |  ...   |
               +--------+-------+--------+               +--------+-------+--------+
                            |                                         |                        
                            |                                         |
                            +-----------------------------------------+
                                                 |
                                                 v
                                     +-------------------------+
                                     |      global memory      |
                                     |--------+-------+--------| 
                                     |  A = 0 | B = 0 |  ...   | 
                                     +--------+-------+--------+ 

如上图,可能存在如下时序:

  • 执行操作 a,执行完成后,只是将结果写入 core 1 的私有缓存,而没有写入 global memory
  • 执行操作 c,执行完成后,只是将结果写入 core 2 的私有缓存,而没有写入 global memory
  • 执行操作 b,访问 core 1 私有缓存或 global memory,得到 0
  • 执行操作 d,访问 core 2 私有缓存或 global memory,也得到 0

2.3 Relaxed Memory Models(松弛型内存模型)

这种内存模型不仅引入了 cpu cache 的概念,还主要引入了乱序执行的概念。
引用 https://www.codedump.info/post/20191214-cxx11-memory-model-1/ 的例子:

int A, B;
void foo() {
  A = B + 1;
  B = 0;
}

编译器可以有两种动作:

  • 动作一:先将 B 存入 cpu 寄存器,再让寄存器值+1 赋给变量 A,最后让 B 赋 0
  • 动作二:先将 B 存入 cpu 寄存器,然后让 B 赋 0,最后让寄存器值+1 赋给变量 A

可以看到,只要不影响逻辑,让 B 赋 0 的动作可以先于赋值 A 执行,也可以后于赋值 A 执行,这就是乱序执行的概念。
乱序执行在单线程下逻辑一定是正确的,但这种乱序在多线程下,就可能出问题(见下文)。

3. 原子操作

c++11 定义了 std::mutex 原子类,用于让用户创建并操作一个原子变量,无论是什么内存序,对原子变量的操作有如下语义:

  • 操作可见性。即 T1 线程写入数据到原子变量后,T2 线程就能立即读取到最新的值,即解决 cpu cache 一致性的问题
  • 操作原子性。即一个 读取-修改-写入 的 CAS 操作,3 个指令不会从中间被打断

4. std::memory_order

c++11 在原子操作的基础上,进一步定义了 6 种内存序,实际上可以大致分为 3 种:

  • sequentially consistent ordering(顺序一致内存序),即 std::memory_order_seq_cst
  • acquire-release ordering(获取-发布内存序),即 std::memory_order_acquire、std::memory_order_release、std::memory_order_acq_rel、std::memory_order_consume
  • relaxed ordering(松散内存序),即 std::memory_order_relaxed

需要注意的是:

  • 内存序首先是在原子变量上的操作,所以无论是什么内存序列,都一定符合原子变量的操作规则
  • 所以可以看到,c++11 这里定义的内存序,虽然有些名称上与上文描述的操作系统内存模型很相似,但是并不能完全按照操作系统内存模型来类比 c++11 的内存序
  • 另外,无论指令重排如何进行,在单线程环境下是绝对不会出逻辑问题的,重排只会导致多线程下相关联的程序可能发生错误

4.1 内存序到底约束了什么

本小节主要参考:

内存序到底约束了什么呢?首先,内存序依托于原子变量,原子变量本身已经符合前面描述的规则。
那么根据上面的参考,可以粗略的认为,c++11 内存序主要是限制以原子变量为中点,前后其它内存操作的指令重排程度。

4.2 relaxed ordering(松散内存序)

std::memory_order_relaxed 不对原子操作的前后指令重排做任何约束,不能指靠 relaxed ordering 来获得任何同步功能。
不同线程对于同一份语句,看到的执行顺序可能都是不同的,即变量的可见性对不同线程来说是不同的。
引用 C++ Concurrency In Action 2rd 第5章示例如下,此代码试图通过变量 y 来进行同步,即递增 z,但是实际上可能会失败:

#include <atomic>
#include <thread>
#include <assert.h>

std::atomic<bool> x,y;
std::atomic<int> z;

void write_x_then_y()
{
  x.store(true,std::memory_order_relaxed);  // 1
  y.store(true,std::memory_order_relaxed);  // 2
}
void read_y_then_x()
{
  while(!y.load(std::memory_order_relaxed));  // 3
  if(x.load(std::memory_order_relaxed))  // 4
    ++z;
}
int main()
{
  x=false;
  y=false;
  z=0;
  std::thread a(write_x_then_y);
  std::thread b(read_y_then_x);
  a.join();
  b.join();
  assert(z.load()!=0);  // 5
}

如上,断言 5 语句可能会被触发,因为写入 x 的操作 1 和写入 y 的操作 2 可能乱序执行,同时加载 y 的操作 3 和加载 x 的操作 4 也可能乱序执行,最终 z 可能等于 0。

4.3 acquire-release ordering(获取-发布内存序)

语义规则如下:

  • std::memory_order_acquire 限制了原子操作后续的指令不能重排到 acquire 之前
  • std::memory_order_release 限制了原子操作前面的指令不能重排到 release 之后
  • std::memory_order_acq_rel 相对于 acquire 和 release 的集合,限制了原子操作前后的指令都不能进行重排
  • std::memory_order_consume 是 std::memory_order_acquire 的轻量限制版本,即只限制原子操作后续的与原子操作相关的指令,不能重排到 consume 之前

4.3.1 同步功能

acquire-release ordering 内存序加上了许多指令重排限制,以此提供了同步功能:

  • 线程 T1 进行了原子 store,随机线程 T2 成功 load,那么线程 T1 进行了原子 store 之前的所有内存写入操作,对于 T2 来说都是可见的,这便是同步功能

参考 http://senlinzhan.github.io/2017/12/04/cpp-memory-order/ 中的示例:

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

如上,通过 ready 进行同步,使得语句 4 总能看到语句 1 执行后的结果。
注意,acquire-release ordering 内存序还是可能存在不同线程看到的代码执行顺序不一致(见下文 sequentially consistent ordering)。

4.3.2 acquire、release

引用 C++ Concurrency In Action 2rd 第5章示例如下,此代码通过变量 y 来进行同步,即递增 z:

#include <atomic>
#include <thread>
#include <assert.h>

std::atomic<bool> x,y;
std::atomic<int> z;

void write_x_then_y()
{
  x.store(true,std::memory_order_relaxed);  // 1
  y.store(true,std::memory_order_release);  // 2
}
void read_y_then_x()
{
  while(!y.load(std::memory_order_acquire));  // 3 自旋,等待 y 被设置为true
  if(x.load(std::memory_order_relaxed))  // 4 
    ++z;
}
int main()
{
  x=false;
  y=false;
  z=0;
  std::thread a(write_x_then_y);
  std::thread b(read_y_then_x);
  a.join();
  b.join();
  assert(z.load()!=0);  // 5
}

如上,写入 y 语句 2 使用了 std::memory_order_release 内存序,那么写入 x 语句 1 就不能被重排到语句 2 之后。
另一个线程中,读取 y 的语句 3 使用了 std::memory_order_acquire 内存序,那么读取 x 的语句 4 就不能被重排到语句 3 前执行。所以通过 y 同步了读取 x 的操作,最终 z 自增一定成功,assert 语句不会被触发。

4.3.3 consume

引用 C++ Concurrency In Action 2rd 第5章示例如下,此代码通过变量 p 来进行同步:

struct X
{
int i;
std::string s;
};

std::atomic<X*> p;
std::atomic<int> a;

void create_x()
{
  X* x=new X;
  x->i=42;
  x->s="hello";
  a.store(99,std::memory_order_relaxed);  // 1
  p.store(x,std::memory_order_release);  // 2
}

void use_x()
{
  X* x;
  while(!(x=p.load(std::memory_order_consume)))  // 3
    std::this_thread::sleep(std::chrono::microseconds(1));
  assert(x->i==42);  // 4
  assert(x->s=="hello");  // 5
  assert(a.load(std::memory_order_relaxed)==99);  // 6
}

int main()
{
  std::thread t1(create_x);
  std::thread t2(use_x);
  t1.join();
  t2.join();
}

如上,写入 p 语句 2 使用了 std::memory_order_release,那么前面的语句都不能重排到语句 2 之后。
读取 p 语句 3 使用了 std::memory_order_consume,那么后面与 p(x) 相关的两个 assert 语句 4 和 5 都不会被触发。
但是语句 6 不受 std::memory_order_consume 控制,其可以乱序执行到语句 3 之前,即先于语句 1 执行,所以语句 6 的 assert 可能会被触发。

4.4 sequentially consistent ordering(顺序一致内存序)

std::memory_order_seq_cst 内存序可以认为在 std::memory_order_acq_rel 的基础上,进一步限制了原子变量的执行顺序。
引用 C++ Concurrency In Action 2rd 第5章示例如下,此代码试图通过变量 x、y 来进行同步,即递增 z,但实际可能递增失败:

#include <atomic>
#include <thread>
#include <assert.h>

std::atomic<bool> x,y;
std::atomic<int> z;
void write_x()
{
  x.store(true,std::memory_order_release);    // 1
}
void write_y()
{
  y.store(true,std::memory_order_release);    // 2
}
void read_x_then_y()
{
  while(!x.load(std::memory_order_acquire));  // 3
  if(y.load(std::memory_order_acquire))       // 4
    ++z;
}
void read_y_then_x()
{
  while(!y.load(std::memory_order_acquire));  // 5
  if(x.load(std::memory_order_acquire))       // 6
    ++z;
}
int main()
{
  x=false;
  y=false;
  z=0;
  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); // 7
}

如上,写入 x 的语句 1 和写入 y 的语句 2,在线程 c 看来,可能是先写 x,再写 y;但是在线程 d 看来,可能是先写 y,再写 x。
所以线程 c 中语句 3 执行成功后,语句 4 可能失败;对于线程 d 同理,参考 https://stackoverflow.com/questions/14861822/acquire-release-versus-sequentially-consistent-memory-order。
如上程序语句 7 要想一直成功,那么对 x、y 的读取和写入都必须替换成 std::memory_order_seq_cst,这样线程 c 和 线程 d 都将看到一致的执行顺序,即要么语句 1 先执行,要么语句 2 先执行。

5. 解决 double-check singleton 的问题

在经典的 double-check singleton 写法中,在多核时代可能存在 new 操作被打断的问题,而导致一些未定义行为。
借助 std::atomic 和 std::memory_order,可以完全解决这个问题:

template <class T>
class A {
public:
  static T& get_instance() {
    T* tmp = instance.load(std::memory_order_acquire);
    if (!tmp) {
      std::lock_guard<std::mutex> lg(lk);
      tmp = instance.load(std::memory_order_relaxed);
      if (!tmp) {
        void* buf = operator new(sizeof(T));
        tmp = ::new(buf) T();
        instance.store(tmp, std::memory_order_release);      // 1
      }
    }
    return *tmp;
  }

private:
  A() = delete;
  A(const A& a) = delete;
  A& operator=(const A& a) = delete;

  static std::atomic<T*> instance;
  static std::mutex lk;
};

template <class T> std::atomic<T*> A<T>::instance(nullptr);
template <class T> std::mutex A<T>::lk;

如上,关键在于语句 1,std::memory_order_release 保证了前面申请内存和构造语句不会重排到语句 1 之后,写入 instance 的原子操作也不能被打断。

posted @ 2022-07-21 17:46  小夕nike  阅读(430)  评论(0编辑  收藏  举报