C++ Concurrency In Action 笔记(二) - 原子操作与内存序
参考:
- C++ Concurrency In Action 2rd 第5章
- https://stackoverflow.com/questions/14861822/acquire-release-versus-sequentially-consistent-memory-order
- https://www.zhihu.com/question/24301047
- https://en.cppreference.com/w/cpp/atomic/memory_order
- https://stackoverflow.com/questions/66054666/memory-order-relaxed-and-visibility
- https://www.codedump.info/post/20191214-cxx11-memory-model-1/
- https://www.cs.utexas.edu/~bornholt/post/memory-models.html
1. 概述
内存序原本是操作系统/物理结构上的概念,c++11 在操作系统的基础上,进一步封装定义了 6 种 std::memory_order 以供程序员使用。
内存序的概念个人感觉非常抽象和难以理解,本篇文章只做简单的学习总结,如需深入需要仔细阅读相关论文。
2. 操作系统内存模型
本节主要参考:
- https://www.cs.utexas.edu/~bornholt/post/memory-models.html
- https://www.codedump.info/post/20191214-cxx11-memory-model-1/
考虑有两个变量 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 的原子操作也不能被打断。