第5章 C++内存模型和原子类型操作
5.1 内存模型基础
这里从两方面来讲内存模型:一方面是基本结构,这与事务在内存中是怎样布局的有关;另一方面就是并发。对于并发基本结构很重要,特别是在低层原子操作。所以我将会从基本结构讲起.
5.1.1 对象和内存位置
- 在一个C++程序中的所有数据都是由对象(objects)构成
- 对象还是可以将自己的特性赋予其他对象,比如,其类型和生命周期
- 一个对象都会存储在一个或多个内存位置上
- 每一个内存位置不是一个标量类型的对象,就是一个标量类型的子对象
这里有四个需要牢记的原则:
-
每一个变量都是一个对象,包括作为其成员变量的对象。
-
每个对象至少占有一个内存位置。
3. 基本类型都有确定的内存位置(无论类型大小如何,即使他们是相邻的,或是数组的一部分)。
4. 相邻位域是相同内存中的一部分5.1.2 对象、内存位置和并发
-
这部分对于C++的多线程应用来说是至关重要的:所有东西都在内存中
-
若多线程访问不同位置,无影响;
-
若访问相同位置但无数据修改(处于只读模式),无影响
-
若访问相同内存位置且存在数据修改,则会造成条件竞争
-
为了避免条件竞争,两个线程就需要一定的执行顺序:
-
使用互斥量来确定访问的顺序 —— 构建锁
-
使用原子操作同步机制
5.2 C++中的原子操作和原子类型
- 原子操作是个不可分割的操作,它要么就是做了,要么就是没做,只有这两种可能。不存在部分执行的情况。
- 非原子操作:可能会被另一个线程观察到只完成一半;
1)如果这个操作是一个存储操作,那么其他线程看到的值,可能既不是存储前的值,也不是存储的值,而是别的什么值。
2)如果这个非原子操作是一个加载操作,它可能先取到对象的一部分,然后值被另一个线程修改,然后它再取到剩余的部分, 所以它取到的既不是第一个值,也不是第二个值,而是两个值的某种组合
5.2.1 标准原子类型
- 标准原子类型定义在头文件 < atomic>中
- 在语言定义中只有这些类型的操作是原子的,不过你可以用互斥锁来模拟原子操作
- is_lock_free()成员函数: 这个函数让用户可以查询某原子类型的操作是直接用的原子指令(x.is_lock_free()返回true), 还是编译器和库内部用了一个锁(x.is_lock_free()返回false)
- 只用std::atomic_flag类型不提供is_lock_free()成员函数,在这种类型上的操作都需要是无锁的
- 剩下的原子类型都可以通过特化std::atomic<>类型模板而访问到,并且拥有更多的功能,但可能不都是无锁的
5.2.2 std::atomic_flag的相关操作
- 是最简单的标准原子类型,它表示了一个布尔标志
- 这个类型的对象可以在两个状态间切换:设置和清除
- std::atomic_flag类型的对象必须被ATOMIC_FLAG_INIT初始化,初始化标志位是“清除”状态
std::atomic_flag f = ATOMIC_FLAG_INIT;
- 它是唯一需要以如此特殊的方式初始化的原子类型,但它也是唯一保证无锁的类型
- 当你的标志对象已初始化,那么你只能做三件事情:销毁,清除或设置(查询之前的值) —— clear()成员函数,和test_and_set()成员函数
- 每一个原子操作,默认的内存顺序都是memory_order_seq_cst
f.clear(std::memory_order_release); // 1
bool x=f.test_and_set(); // 2
这里,调用clear()①明确要求,使用释放语义清除标志,当调用test_and_set()②使用默认内存顺序设置表示,并且检索旧值。
- 你不能拷贝构造另一个std::atomic_flag对象;并且,你不能将一个对象赋予另一个std::atomic_flag对象 —— 所有原子类型共有的特性
- std::atomic_flag非常适合于作自旋互斥锁:
class spinlock_mutex
{
std::atomic_flag flag;
public:
spinlock_mutex():
flag(ATOMIC_FLAG_INIT)
{}
void lock()
{
while(flag.test_and_set(std::memory_order_acquire));
}
void unlock()
{
flag.clear(std::memory_order_release);
}
};
局限性太强,因为它没有非修改查询操作,它甚至不能像普通的布尔标志那样使用
5.2.3 std::atomic的相关操作
- 最基本的原子整型类型就是
std::atomic<bool>
- 依旧不能拷贝构造和拷贝赋值
- 但是你可以使用一个非原子的bool类型构造它,所以它可以被初始化为true或false,并且你也可以从一个非原子bool变量赋值给
std::atomic<bool>
- 相关构建如下:
定义 : //类模版
template< class T > struct atomic; (since C++11)
template<> struct atomic<Integral>;
template<> struct atomic<bool>;
template< class T > struct atomic<T*>; 指针特化
- 使用bool型进行初始化,如下所示:
std::atomic<bool> b(true);
b=false;
- test_and_set()函数也可以被更加通用的exchange()成员函数所替换
- exchange()成员函数允许你使用你新选的值替换已存储的值,并且自动的检索原始值
std::atomic<bool> b;
bool x=b.load(std::memory_order_acquire);
b.store(true);
x=b.exchange(false, std::memory_order_acq_rel);
std::atomic<bool>
提供的exchange(),不仅仅是一个“读-改-写”的操作;它还介绍了一种新的存储方式:当当前值与预期值一致时,存储新值的操作。
- 例子
// atomic::compare_exchange_weak example:
#include <iostream> // std::cout
#include <atomic> // std::atomic
#include <thread> // std::thread
#include <vector> // std::vector
// a simple global linked list:
struct Node { int value; Node* next; };
std::atomic<Node*> list_head (nullptr);
void append (int val) { // append an element to the list
Node* newNode = new Node {val,list_head};
// next is the same as: list_head = newNode, but in a thread-safe way:
while (!list_head.compare_exchange_weak(newNode->next,newNode)) {}
// (with newNode->next updated accordingly if some other thread just appended another node)
}
int main ()
{
// spawn 10 threads to fill the linked list:
std::vector<std::thread> threads;
for (int i=0; i<10; ++i) threads.push_back(std::thread(append,i));
for (auto& th : threads) th.join();
// print contents:
for (Node* it = list_head; it!=nullptr; it=it->next)
std::cout << ' ' << it->value;
std::cout << '\n';
// cleanup:
Node* it; while (it=list_head) {list_head=it->next; delete it;}
return 0;
}
存储一个新值(或旧值)取决于当前值
- 这是一种新型操作,叫做**“比较/交换”**,它的形式表现为
compare_exchange_weak()
和compare_exchange_strong()
成员函数。 - “比较/交换”操作是原子类型编程的基石;
- 它比较原子变量的当前值和一个期望值,当两值相等时,存储提供值。当两值不等,期望值就会被更新为原子变量中的值
- “比较/交换”函数值是一个bool变量,当返回true时执行存储操作,当false则更新期望值。
compare_exchange_weak()
- 对于compare_exchange_weak()函数,当原始值与预期值一致时,存储也可能会不成功;
- 这种情况下,变量的值不会发生改变,并且compare_exchange_weak()的返回是false
- 这可能发生在缺少单条CAS操作(“比较-交换”指令)的机器上,当处理器不能保证这个操作能够自动的完成——可能是因为线程的操作将指令队列从中间关闭,并且另一个线程安排的指令将会被操作系统所替换(这里线程数多于处理器数量)
- 这被称为"伪失败”(spurious failure),因为造成这种情况的原因是时间,而不是变量值。
bool expected=false;
extern atomic<bool> b; // 设置些什么
while(!b.compare_exchange_weak(expected,true) && !expected);
在这个例子中,循环中expected的值始终是false,表示compare_exchange_weak()会莫名的失败
compare_exchange_strong()
- 如果实际值与期望值不符,compare_exchange_strong()就能保证值返回false。这就能消除对循环的需要,就可以知道是否成功的改变了一个变量,或已让另一个线程完成
每次循环的时候,期望值都会重新加载,所以当没有其他线程同时修改期望时,循环中对compare_exchange_weak()
或compare_exchange_strong()
的调用都会在下一次(第二次)成功
5.2.4 std::atomic<T*>:指针运算
- 原子指针类型,可以使用内置类型或自定义类型T
- std::atomic<T*>也有
load(), store(), exchange(), compare_exchange_weak()和compare_exchage_strong()
成员函数 - 基本操作有
fetch_add()
和fetch_sub()
提供,它们在存储地址上做原子加法和减法,为+=, -=, ++和–提供简易的封装 - 如果x是
std::atomic<Foo*>
类型的数组的首地址,然后x+=3让其偏移到第四个元素的地址,并且返回一个普通的Foo*
类型值,这个指针值是指向数组中第四个元素;x.ftech_add(3)
让x指向第四个元素,并且函数返回指向第一个元素的地址 - 返回值是一个普通的T值,而非是std::atomic<T>对象的引用
class Foo{};
Foo some_array[5];
std::atomic<Foo*> p(some_array); // 用some_array的首地址对atomic对象p赋值
Foo* x=p.fetch_add(2); // p加2,并返回原始值
assert(x==some_array);
assert(p.load()==&some_array[2]);
x=(p-=1); // p减1,并返回原始值
assert(x==&some_array[1]);
assert(p.load()==&some_array[1]); //p.load()为新值
备注:
assert:
- assert宏的原型定义在<assert.h>中,其作用是如果它的条件返回错误,则终止程序执行。
#include <assert.h>
void assert( int expression );
- assert的作用是先计算表达式 expression ,如果其值为假(即为0),那么它先向stderr打印一条出错信息,然后通过调用 abort 来终止程序运行
store void store(T val, memory_order = memory_order_seq_cst) volatile;
//将T值设为val
loadT load(memory_order = memory_order_seq_cst) const volatile;
//访问T值
5.2.5 标准的原子整型的相关操作
类模板:
template < class T > struct atomic {
bool is_lock_free() const volatile;//判断atomic<T>中的T对象是否为lock free的,若是返回true。lock free(锁无关)指多个线程并发访问T不会出现data race,任何线程在任何时刻都可以不受限制的访问T
bool is_lock_free() const;
atomic() = default;//默认构造函数,T未初始化,可能后面被atomic_init(atomic<T>* obj,T val )函数初始化
constexpr atomic(T val);//T由val初始化
atomic(const atomic &) = delete;//禁止拷贝
atomic & operator=(const atomic &) = delete;//atomic对象间的相互赋值被禁止,但是可以显示转换再赋值,如atomic<int> a=static_cast<int>(b)这里假设atomic<int> b
atomic & operator=(const atomic &) volatile = delete;//atomic间不能赋值
T operator=(T val) volatile;//可以通过T类型对atomic赋值,如:atomic<int> a;a=10;
T operator=(T val);
operator T() const volatile;//读取被封装的T类型值,是个类型转换操作,默认内存序是memory_order_seq需要其它内存序则调用load
operator T() const;//如:atomic<int> a,a==0或者cout<<a<<endl都使用了类型转换函数
//以下函数可以指定内存序memory_order
T exchange(T val, memory_order = memory_order_seq_cst) volatile;//将T的值置为val,并返回原来T的值
T exchange(T val, memory_order = memory_order_seq_cst);
void store(T val, memory_order = memory_order_seq_cst) volatile;//将T值设为val
void store(T val, memory_order = memory_order_seq_cst);
T load(memory_order = memory_order_seq_cst) const volatile;//访问T值
T load(memory_order = memory_order_seq_cst) const;
bool compare_exchange_weak(T& expected, T val, memory_order = memory_order_seq_cst) volatile;//该函数直接比较原子对象所封装的值与参数 expected 的物理内容,所以某些情况下,对象的比较操作在使用 operator==() 判断时相等,但 compare_exchange_weak 判断时却可能失败,因为对象底层的物理内容中可能存在位对齐或其他逻辑表示相同但是物理表示不同的值(比如 true 和 2 或 3,它们在逻辑上都表示"真",但在物理上两者的表示并不相同)。可以虚假的返回false(和expected相同)。若本atomic的T值和expected相同则用val值替换本atomic的T值,返回true;若不同则用本atomic的T值替换expected,返回false。
bool compare_exchange_weak(T &, T, memory_order = memory_order_seq_cst);
bool compare_exchange_strong(T &, T, memory_order = memory_order_seq_cst) volatile;//
与compare_exchange_weak 不同, strong版本的 compare-and-exchange 操作不允许(spuriously 地)返回 false,即原子对象所封装的值与参数 expected 的物理内容相同,比较操作一定会为 true。不过在某些平台下,如果算法本身需要循环操作来做检查, compare_exchange_weak 的性能会更好。因此对于某些不需要采用循环操作的算法而言, 通常采用compare_exchange_strong 更好
bool compare_exchange_strong(T &, T, memory_order = memory_order_seq_cst);
};
5.3 同步操作和强制排序
假设你有两个线程,一个向数据结构中填充数据,另一个读取数据结构中的数据。为了避免恶性条件竞争,第一个线程设置一个标志,用来表明数据已经准备就绪,并且第二个线程在这个标志设置前不能读取数据。下面的程序清单就是这样的情况。
#include <vector>
#include <atomic>
#include <iostream>
std::vector<int> data;
std::atomic<bool> data_ready(false);
void reader_thread()
{
while(!data_ready.load()) // 1
{
std::this_thread::sleep(std::milliseconds(1));
}
std::cout<<"The answer="<<data[0]<<"\m"; // 2
}
void writer_thread()
{
data.push_back(42); // 3
data_ready=true; // 4
}
先把等待数据的低效循环①放在一边(你需要这个循环,否则想要在线程间共享数据就是不切实际的:数据的每一项都必须是原子的)。你已经知道,当非原子读②和写③对同一数据结构进行无序访问时,将会导致未定义行为的发生,因此这个循环就是确保访问循序被严格的遵守的。
5.3.1 同步发生
- “同步发生”只能在原子类型之间进行操作
- 在变量x进行适当标记的原子写操作W,同步与对x进行适当标记的原子读操作,读取的是W操作写入的内容;或是在W之后,同一线程上的原子写操作对x写入的值;亦或是任意线程对x的一系列原子读-改-写操作(例如,fetch_add()或compare_exchange_weak())
- 先将“适当的标记”放在一边,因为所有对原子类型的操作,默认都是适当标记的
- 实际上,如果线程A存储了一个值,并且线程B读取了这个值,线程A的存储操作与线程B的载入操作就是同步发生的关系
5.3.2 先行发生
- “先行发生”关系是一个程序中,基本构建块的操作顺序;它指定了某个操作去影响另一个操作
- 对于单线程来说,就简单了:当一个操作排在另一个之后,那么这个操作就是先行执行的
- 如果操作在同时发生,因为操作间无序执行,通常情况下,它们就没有先行关系了
- 对于多线程,新意的是线程间的互相作用:如果操作A在线程上,并且线程先行于另一线程上的操作B,那么A就先行于B
- 如果A线程间先行于B,并且B线程间先行于C,那么A就线程间先行于C
- 线程间先行可以与排序先行关系相结合:如果操作A排序先行于操作B,并且操作B线程间先行于操作C,那么A线程间先行于C。同样的,如果A同步于B,并且B排序先于C,那么A线程间先行于C
5.3.3 原子操作的内存顺序
- 这里有六个内存序列选项可应用于对原子类型的操作:memory_order_relaxed, memory_order_consume,
memory_order_acquire, memory_order_release, memory_order_acq_rel,
以及memory_order_seq_cst - 除非你为特定的操作指定一个序列选项,要不内存序列选项对于所有原子类型默认都是memory_order_seq_cst
- 虽然有六个选项,但是它们仅代表三种内存模型:排序一致序列(sequentially consistent),获取-释放序列(memory_order_consume, memory_order_acquire, memory_order_release和memory_order_acq_rel),和自由序列(memory_order_relaxed)。
- 不同的内存序列模型,在不同的CPU架构下,功耗是不一样的
- 排序一致队列
默认序列命名为排序一致,是因为程序中的行为从任意角度去看,序列顺序都保持一致;
在一个多核排序的机器上,它会加强对性能的惩罚,因为整个序列中的操作都必须在多个处理器上保持一致,可能需要对处理器间的同步操作进行扩展(代价很昂贵!)
以下清单展示了序列一致的行为,对于x和y的加载和存储都显示标注为memory_order_seq_cst,不过在这段代码中,标签可能会忽略,因为其是默认项
#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_seq_cst); // 1
}
void write_y()
{
y.store(true,std::memory_order_seq_cst); // 2
}
void read_x_then_y()
{
while(!x.load(std::memory_order_seq_cst));
if(y.load(std::memory_order_seq_cst)) // 3
++z;
}
void read_y_then_x()
{
while(!y.load(std::memory_order_seq_cst));
if(x.load(std::memory_order_seq_cst)) // 4
++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); // 5
}
assert⑤语句是永远不会触发的,因为不是存储x的操作①发生,就是存储y的操作②发生
如果在read_x_then_y中加载y③返回false,那是因为存储x的操作肯定发生在存储y的操作之前,那么在这种情况下在read_y_then_x中加载x④必定会返回true,因为while循环能保证在某一时刻y是true
因为memory_order_seq_cst的语义需要一个单全序将所有操作都标记为memory_order_seq_cst,这就暗示着“加载y并返回false③”与“存储y①”的操作,有一个确定的顺序
当然,因为所有事情都是对称的,所以就有可能以其他方式发生,比如,加载x④的操作返回false,或强制加载y③的操作返回true。在这两种情况下,z都等于1。当两个加载操作都返回true,z就等于2,所以任何情况下,z都不能是0
序列一致是最简单、直观的序列,但是他也是最昂贵的内存序列,因为它需要对所有线程进行全局同步。在一个多处理系统上,这就需要处理期间进行大量并且费时的信息交换
顺序一致性模型在所有多核系统上要求完全的内存栅栏CPU指令。这可能成为性能瓶颈,因为它强制受影响的内存访问传播到每个核心
2.非排序一致内存模型
- 再也不会有全局的序列了
- 不同线程看到相同操作,不一定有着相同的顺序
- 对于不同线程的操作,都会整齐的,一个接着另一个执行的想法是需要摒弃的
- 线程没必要去保证一致性
- 唯一的要求就是,所有线程都要统一对每一个独立变量的修改顺序
- 对不同变量的操作可以体现在不同线程的不同序列上,提供的值要与任意附加顺序限制保持一致
- 踏出排序一致世界后,最好的示范就是使用memory_order_relaxed对所有操作进行约束
1)自由序列
- 在原子类型上的操作以自由序列执行,没有任何同步关系
- 唯一要求:在同一线程中对于同一变量的操作还是服从先发执行的关系
- 适用于只要求原子操作,不需要其它同步保障的情况
- 典型例子:程序计数器
#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
}
这次assert⑤可能会触发,因为加载x的操作④可能读取到false,即使加载y的操作③读取到true,并且存储x的操作①先发于存储y的操作②。x和y是两个不同的变量,所以这里没有顺序去保证每个操作产生相关值的可见性。
①和②的执行顺序可以发生变化,同样③④执行顺序也可能变化;
释放-获取次序(release-acquire ordering)
- 这个序列是自由序列(relaxed ordering)的加强版
- 操作依旧没有统一的顺序,但是在这个序列引入了同步
- 原子加载就是获取(acquire)操作(memory_order_acquire),即 load — acquire
- 原子存储就是释放(memory_order_release)操作, 即store — release
- fetch_add 或者 exchange 对应 acquire或release或二者都是
- 同步在线程释放和获取间是成对的(pairwise),其它线程有可能看到不一样的内存访问顺序
- 一个特点:线程A中所有发生在release x之前的写操作(包括非原子或宽松原子),对在线程B acquire x之后都可见
#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③时会得到true,和存储时写入的一样②。因为存储使用的是memory_order_release,读取使用的是memory_order_acquire,存储就与读取就同步了
因为这两个操作是由同一个线程完成的,所以存储x①先行于加载y②。对y的存储同步与对y的加载,存储x也就先行于对y的加载,并且扩展先行于x的读取
因此,加载x的值必为true,并且断言⑤不会触发