Volatile,Atomic与Memory barrier
三者的关联
C++ 中的 volatile 关键字,std::atomic 变量及手动插入内存屏障指令(Memory Barrier)均是为了避免内存访问过程中出现一些不符合预期的行为。这三者的适用场景与区别如下:
- 如果需要原子性的访问支持,只能选择 atomic;
- 如果仅需保证内存访问不会被编译器优化掉,优先考虑 volatile;
- 如果需要保证 Memory Order,也优先考虑 atomic,只有当不需要保证原子性,而且很明确要在哪插入内存屏障时才考虑手动插入 Memory Barrier
| volatile | Memory Barrier | atomic | |
|---|---|---|---|
| 抑制编译器重排 | Yes | Yes | Yes |
| 抑制编译器优化 | Yes | No | Yes |
| 抑制 CPU 乱序 | No | Yes | Yes |
| 保证访问原子性 | No | No | Yes |
编译器重排
编译器重排是指编译器在生成目标代码的过程中交换没有依赖关系的内存访问顺序的行为。
比如以下代码,编译器不保证在最终生成的汇编代码中对 p_a 内存的写入在对 p_b 内存的读取之前。
1 | *p_a = a; |
为了保证编译器不会进行错误的优化,可以通过以下三种方式来实现:
- 把对应的变量声明为 volatile 的,C++ 标准保证对 volatile 变量间的访问编译器不会进行重排,不过仅仅是 volatile 变量之间, volatile 变量和其他变量间还是有可能会重排的;
- 在需要的地方手动添加合适的 Memory Barrier 指令,Memory Barrier 指令的语义保证了编译器不会进行错误的重排操作;
- 把对应变量声明为 atomic 的, 与 volatile 类似,C++ 标准也保证 atomic 变量间的访问编译器不会进行重排。不过 C++ 中不存在所谓的 “atomic pointer” 这种东西,如果需要对某个确定的地址进行 atomic 操作,需要靠一些技巧性的手段来实现,比如在那个地址上进行 placement new 操作强制生成一个 atomic 等;
编译器优化
此处的编译器优化特指编译器不生成其认为无意义的内存访问代码,如下代码对变量 a 的内存访问基本都会被优化掉。
1 | void f() { |
为了抑制编译器优化,可以把变量声明为 volatile 或 atomic,C++ 标准保证对 volatile 或 atomic 内存的访问肯定会发生。
不过需要注意的是,这时候手动添加内存屏障指令是没有意义的,在上述代码的 for 循环中加入 mfence 指令后,仅仅是让循环没有被优化掉,然而每次循环中对变量 a 的赋值依然会被优化掉,结果就是连续执行了 1000 次 mfence。
CPU 乱序
由于CPU Store Buffer/Invalidate queue 的存在,CPU 对一个变量的赋值操作可能无法被另一个核心及时观察到,即cache一致性问题。另外,CPU还有流水线、分支预测、乱序等特性,他们能提高CPU利用率,但也带来了CPU重排的现象,造成内存一致性(memory consistency)问题,这些是MESI协议解决不了的。
这些问题从根本上只能通过插入 Memory Barrier 内存屏障来解决,这些指令会使得 CPU 保证特定的内存访问序及内存写入操作在多核间的可见性。然而由于不同处理器架构间的内存模型和具体 Memory Barrier 指令均不相同,需要在什么位置添加哪条指令并不具有通用性,因此 C++ 11 在此基础上做了一层抽象,引入了 atomic 类型及 Memory Order 的概念,有助于写出更通用的代码。从本质上看就是靠编译器来根据代码中指定的高层次 Memory Order 来自动选择是否需要插入特定处理器架构上低层次的内存屏障指令。
写屏障会等待 Store Buffer 中的数据同步刷到cache后再执行屏障后面的写入操作。
读屏障会将 Invalid Queue 中的数据处理完成后再执行屏障后面的读取操作。
访问原子性
访问原子性就是 Read,Write 操作是否存在中间状态,具体如何实现原子性的访问与处理器指令集有很大关系,如果处理器本身就支持某些原子操作指令,如 Atomic Store, Atomic Load,Atomic Fetch Add,Atomic Compare And Swap(CAS)等,那只需要在代码生成时选择合适的指令即可,否则需要依赖锁来实现。C++ 中提供的可移植通用方法就是 std::atomic,volatile 及 Memory Barrier 均与此完全无关。
Memory order
在atomic变量的store和load中提供以下选项,现在解释它们的含义:
含义解释
memory_order_seq_cst
要求底层提供顺序一致性模型,这个是默认提供的最强的一致性模型,在这种模型下不存在任何重排,可以解决一切问题
在底层实现上:程序的运行底层架构如果是非内存强一致模型,会使用cpu提供的内存屏障等操作保证强一致,在软件上,要求代码进行编译的时候不能够做任何指令重排。
memory_order_release/acquire/consume
允许 cpu 或者编译器做一定的指令乱序重排,但是由于 tso, pso 的存在,可能产生 store-load 乱序、store-store 乱序导致问题。涉及到多核交互的时候,需要手动使用release 和 acquire去避免这样的这个问题了,与memory_order_seq_cst最大的不同的是,其是对具体代码可能出现的乱序做具体解决而不是要求全部都不能重排。
memory_order_relaxed
提供松散一致性模型保障,不提供 operation order 保证。
使用例
写顺序保证
std::memory_order_release的含义是:在本行代码之前任何写内存的操作,都不能放到本行语句之后。尽管要求{1,2,3}代码的执行不能放到4的后面,但是{1,2,3}本身是可以被乱序的。release可以认为是发布一个版本,也就是说应该在发布之前做的,不能放到release之后。
1 | std::atomic<bool> has_release; |
读顺序的保证
std::memory_order_acquire 的含义是:后续的读操作都不能放到这条指令之前。
例如 thread_1 按照 {line 1, line2} 的顺序呈现给 thread_2,thread_2 检查是否已经发布再读取,符合预期。 但如果采用 memory_order_relaxed,int x = *data 就可能会被重排到循环之前。
1 | std::atomic<bool> has_release; |
读顺序的消弱
std::memory_order_consume 的含义是:所有后续对本原子类型的操作,都不能放到这条指令之前。
std::memory_order_acquire 与 std::memory_order_consume 的区别在于:
- memory_order_acquire 要求后面所有的读都不得提前:
- memory_order_consume 要求后面依赖于本次形成读则不能乱序。
例如,由于 global_addr, addr, x 形成了读依赖,所以这几个变量不能乱序。但是 d, f 可以放到 global_addr.load(std::memory_order_consume) 前面。而 std::memory_order_acquire 则要求都不能放到前面。
1 | void func(int *data) { |
最强约束
std::memory_order_seq_cst 表示最强约束。所有关于 std::atomic 的使用,如果不带函数(x.store or x.load),而是直接 std::atomic a; a = 1 ,那么就是强一制性的。所有这条指令前面的语句不能放到后面,所有这条语句后面的语句不能放到前面来执行。
CAS
CAS(Compare-and-Swap)是用于多线程以实现同步的原子指令。CAS 使用了乐观锁的概念执行原子操作,其内部的执行原理为:
- 获取内存中的当前值,然后传入期待值(expect)和新的值(desire),比较当前值和期待值(Compare)
- 若相等,则将内存中的当前值更新为新的值(Swap)
- 若不相等,则重复该操作(原子操作失败,完全不执行,回滚至初始状态后重新操作)。
这种操作是CPU的一条原子指令,需要CPU支持,现在绝大部分CPU指令都支持CAS的原子操作。
C++提供了 atomic 模板支持一系列原子操作的类,提供的方法能保证操作具有原子性,不会获得修改过程之中的值,以确保不会在并发操作时产生不明确的行为。
该类提供了如下几种接口:
- store 原子写
- load 原子读
- exchange 修改当前值,并保证过程具有原子性
- compare_exchange_weak
- compare_exchange_strong
compare_exchange_weak 和 compare_exchange_strong 即为CAS操作,参数传入期待值 expected 和设定值 val:
- 若当前值(原子对象包含)与期待值相等,则将当前值修改为设定值,返回true
- 若当前值与期待值不等,则将期待值修改为当前值,返回false
1 | bool compare_exchange_weak (T& expected, T val, memory_order sync = memory_order_seq_cst) volatile noexcept; |
compare_exchange_weak和compare_exchange_strong的区别:weak允许偶然出乎意料的返回,比如当前值和期待值相等时候返回false,但在多数循环算法中是可以接受的,通常比strong具有更高的性能。
根据上述概念,可以使用C++实现一个简单的CAS无锁栈:
Folding
1 | /* CAS无锁栈 */ |
Copy elision
Copy elision是指编译器为了优化,将不需要的copy/move 操作(含析构函数,为了行文简洁,本文忽略析构函数的省略)直接去掉了。
- RVO 是返回值优化,就是将返回值的创建省略了。
- NRVO 是函数内具有名字的局部变量充当返回值的时候,它的创建也被省略了。
它们的省略结果都是在最终的变量里创建对象。C++17以后,RVO是保证会发生的,因为标准这么定义了。而NRVO并没有保证会发生,但是大部分编译器都会实现。
RVO
1 | Obj fun() { |
在编译器不进行优化的情况下,这段代码一共会调用:
- 1 次构造函数:对应代码 Obj()
- 2 次拷贝构造函数:
- 函数返回使用到的临时对象的拷贝构造
- 对象 obj 的拷贝构造
- 3 次析构函数:将上述构造出的对象析构
当一个未具名且未绑定到任何引用的临时变量被移动或复制到一个相同的对象时,拷贝和移动构造可以被省略。当这个临时对象在被构造的时候,它会直接被构造在将要拷贝/移动到的对象。编译器明确知道函数会返回哪一个局部对象,那么编译器会把存储这个局部对象的地址和存储返回临时对象的地址进行复用,也就是说避免了从局部对象到临时对象的拷贝操作。
在这个例子中,代码会被优化成这样:
1 | void fun(Obj &_obj) { |
另外,这种针对未具名临时对象的拷贝构造优化同样也发生在容器操作中,在使用 vector::push_back 操作时,根据 push_back 的内容,会执行不同的步骤:
- 编译器优化:如果 push_back 的参数是 T 的一个对象,那么会直接调用拷贝构造函数在 vector 中构造对象,这时行为和 emplace_back 一致
- 正常行为:如果 push_back 的参数和 T 的某一个 implicit 构造函数一致,那么就会先调用该构造函数构造一个临时对象,然后调用移动构造函数将该临时对象移动到 vector 中
NRVO
在RVO的基础上,NRVO 优化以下代码中的一次多余的拷贝构造
1 | Obj fun() { |
优化失效
1.返回对象不为局部对象
当返回的对象不是局部对象而是全局变量、函数参数或者成员变量时,会禁用 (N)RVO。
2.返回对象类型和函数返回类型不同
这时会触发隐式类型转换,会禁用 (N)RVO。
3.运行时依赖
当编译器无法单纯通过函数来决定返回哪个实例对象时,会禁用 (N)RVO。
1 | Obj fun(bool flag) { |
如果逻辑上允许,最好优化成这样:
1 | Obj fun(bool flag) { |
4.存在赋值行为
(N)RVO 只能在从返回值创建对象时发送,在现有对象上使用 operator= 而不是拷贝/移动构造函数,这样是不会进行优化的。
1 | Obj fun() { |
5.使用std::move()返回
在返回值上调用 std::move() 进行返回是一种错误的方式。它会尝试强制调用移动构造函数,但这样会导致 (N)RVO 失效。因为即使没有显示调用 std::move(),编译器优化中也会执行 move 操作。
Auto && 模板类型推导
变量类型的占位符,编译器确定。用于定义 STL 迭代器、泛型编程中不知道变量具体类型或不想指定类型时
推导规则:
- 数组名或者函数名实参会退化为指针(除非被用于初始化引用)
- 有引用的实参会被视为无引用,在
int &a_ref = a; auto b = a_ref中 auto 推导为 int 而非 int& - 对于传值类型推导,const 和 volatile 属性会被忽略,在
const int a = 1; auto b = a中 auto 推导为 int 而非 const int - 在万能引用中,
T&&接收左值实参会被推导为T&,发生了引用折叠
注意事项:
- 用auto声明的变量必须初始化,根据后面的值来推测这个变量的类型
- 函数和模板参数不能被声明为auto
- 不能用于类型转换等操作,如sizeof、typeid
- 初始化auto用
=,圆括号会得到函数声明,返回值自动推断;大括号会推断成initializer_list<SomeType>
Lambda
利用lambda表达式可以编写内嵌的匿名函数,用以替换独立函数或者函数对象;
定义一个lambda表达式后,编译器会自动生成一个匿名类(重载了()运算符),称为闭包类型,并把 capture 的变量作为该类的属性。闭包的一个强大之处是可以捕捉其作用域内的变量,比如在方括号中通过传值或引用方式捕获
Tip1:lambda必须使用尾置返回来指定返回类型,可以忽略参数列表和返回值,但必须永远包含捕获列表和函数体
Tip2:[=] 按值捕获的变量默认是不可修改的,类似const形参传递。想修改这些变量要添加 mutable,此时仍然不会影响外部的变量。类似的,按值捕获的对象也只能调用常方法
1 | int a = 10; |
Tip3:用显式的按引用捕获,默认捕获可能会导致悬空引用,如局部变量生命周期结束
Tip4:用显式的按值捕获,默认的按值捕获对于悬空指针很敏感(尤其是this指针),并且它会误导人产生lambda是独立的想法。注意,通过[=] [&] [this] 显式捕获 this 都是 by-reference 的,只有 [*this] 是 by-copy 的
1 | class Widget { |
左值与右值
C++11通过引入右值引用来优化性能,配合移动语义来避免拷贝。通过std::move()语义来将临时生成的左值中的资源无代价的转移到另外一个对象中去。std::move() 的实现原理就是的强转右值引用类型并返还之,由于函数返还值类型是临时值,且返还的还是右值引用类型,因此该返还值会被判断为右值。
左值右值分类
lvalue:左值,有标识符、可以取地址的表达式
- 变量、函数
- 返回左值引用的表达式,++x、x=1
- 字符串字面量 “hello world”
函数调用时,左值可以绑定到T&,常量只能绑定到const T&
rvalue:右值,如常量值、函数返回值、lambda表达式
- prvalue:纯右值,传统意义上的右值,不具名的临时对象,无名无地址
- 返回非引用类型表达式,x++、x+1、make_shared
(32) - 非字符串字面量,42、true
- 特点:不能作为赋值操作的左值
- 返回非引用类型表达式,x++、x+1、make_shared
- xvalue:将亡值表示即将被销毁的对象,通常是通过右值引用操作产生的,表示一个可以被移动的对象,通常用于移动语义避免拷贝
- 如通过
std::move产生
- 如通过
左值右值引用
右值引用和左值引用都是属于引用类型,都必须立即进行初始化,因为引用类型只是一个别名,并不拥有绑定对象的内存。
左值引用:具名变量的别名。左值引用通常不能绑定到右值,但常量左值引用可以接受非常量左值、常量左值、右值,不过常量左值所引用的右值在它的“余生”中只能是只读的。
右值引用:用于绑定右值或即将销毁的变量,允许移动而非复制操作,减少深拷贝,提高程序效率。
用途:
- 移动语义:通过移动构造函数和移动赋值运算符,可以将资源从一个对象“移动”到另一个对象,而不是复制资源
- 完美转发:右值引用与模板结合使用,可以实现完美转发,即将参数原封不动地传递给另一个函数。这在编写泛型代码时非常有用。
注意事项:
- 右值引用变量是左值:尽管右值引用可以绑定到右值,但右值引用本身是一个左值。需要使用
move或forward来显式地将其转换为右值 - 避免使用已移动的对象:移动操作后,源对象进入一种有效但未指定的状态,应该避免使用
临时对象在对应表达式估值完成后、按生成顺序逆序销毁。若纯右值被绑定到引用上,其生命周期会延长至与引用变量相同
特殊成员函数生成机制
1. 默认构造函数
编译器默认生成条件:
- 没有显式声明的构造函数
- 所有的数据成员和基类都拥有自己的默认构造函数
2. 析构函数
编译器默认生成条件:没有显式声明析构函数
3. 拷贝操作
编译器默认生成条件:
- 不存在显式声明的拷贝操作
- 不存在显式声明的移动操作(理论上也不能声明析构,但不严格要求)
- 所有的成员变量都能够被拷贝构造或拷贝赋值
拷贝构造和拷贝赋值运算符的生成是独立的:只声明其中一个,编译器能自动生成另一个
注意:拷贝操作的生成规则不是很严格,并不严格符合 The Rule of Three,这是为了不破坏历史遗留代码。C++11标准只规定:在已经存在拷贝操作或析构函数的条件下,把自动生成拷贝操作列为废弃行为
The Rule of Three:如果我们声明了析构/拷贝构造/拷贝赋值中的一个,就要同时声明另外的两个。换句话说,如果声明了析构函数,那么拷贝操作就不应该被生成,因为它们的行为可能不正确。
4. 移动操作
编译器默认生成条件:
- 不存在显式声明的移动操作
- 不存在显式声明的析构函数和拷贝操作
- 所有的数据成员都是可以被拷贝或移动
移动构造和移动赋值运算符的生成并不独立:声明了其中一个,编译器就不会生成另一个
[TODO] 一个令人困扰的点
以下代码显式声明了析构函数,但使用了移动构造函数时仍然编译运行通过。有一种解释是:确实不会隐式生成移动构造函数,在需要移动构造函数时,会用复制构造函数代替。真是如此吗,还是生成了移动构造?暂时没法验证…
1 | class Widget { |
完美转发
在模板函数内给另一个函数传递参数时,无论对象是左值/右值引用类型,都会被视为左值。因为,完美转发用于保证不丢失其左值/右值属性:如果形参推导出是右值引用则作为右值传入,如果是左值引用则作为左值传入
完美转发依赖于三个概念:
1. 万能引用:发生类型推导(模板、auto)的时候,使用 T&& 类型表示为万能引用。它可以绑定到(接收)左值和右值,从而使得模板函数能处理不同类型参数。T&& 推导后的类型:
- 如果传入左值,T 会被推导为左值引用类型。
- 如果传入右值,T 会被推导为普通类型。
2. 引用折叠:当万能引用作为参数时,有可能被一个左值引用或右值引用的参数初始化,这时经过类型推导出来T&&类型,相比右值引用会发生类型的变化,这种变化就称为引用折叠
- 所有右值引用折叠到右值引用上仍然是一个右值引用
- 所有的其他引用类型之间的折叠都将变成左值引用
3. std::forward():原理为 return static_cast(形参),利用了折叠规则。从而接受右值引用类型时,返还值为右值;接受左值引用类型时,返还值为左值
虚函数
Vptr与Vtable
虚函数表:为了实现虚函数,C++ 通过虚表解决了后期绑定时查找调用函数的问题。每个含有虚函数的类都有虚表,它是编译时期初始化的静态数组,数组中每个条目是一个函数指针。虚表全局共享,存储在常量区。
虚指针:编译器还会添加一个隐藏的 vptr 指向虚表。vptr在构造函数中初始化,先在父类构造函数中指向父类的虚表,之后在子类构造函数中改为指向子类虚表。与this指针不同,this指针实际上是编译器用来解析自引用的函数参数,vptr 是一个真正的指针,占用4B/8B大小,也会被派生类继承。
调用过程:
- 对于:
ptr -> z() - 不知道ptr所指类型,但可以读取该对象
virtual table - 不知道哪个z()被调用,但知道每个z()地址都放在
slot k - 转化为:
(*ptr -> vptr[k])(ptr)
如何构造Vtable
单继承(A->B)
- 继承A的虚函数实体:将其地址拷贝到B的
virtual table对应slot中 - 重写A的虚函数实体:将自己的函数实体地址放到
virtual table对应slot中 - 加入新的虚函数:
virtual table尺寸增大一个slot,并放入函数地址
多继承(A1,A2->B)
多重继承下,有几个基类就对应几个虚函数表,虚函数表的顺序和继承时相同。
派生类和第一个基类共用虚函数表,派生类自己的虚函数放在第一个虚函数表的后面,派生类如果要覆盖基类中的虚函数,那么会在虚函数表中代替其位置
多继承下影响到虚函数的调用的实质上为this的调整:
- 如果
A1类型引用B对象时就不用调整,因为起始地址一致 - 如果
A2类型引用B对象并且调用B重写的虚函数时,由于A2引用的起始地址和B不同,它的 this 指针地址比B对象多了 n 字节的偏移量,所以直接调用虚函数会导致错误,需要调用前调整 this 指针指向B
Thunk技术解决了这个问题,它允许虚函数表中slot包括两个类型的地址:
- 不需要调整地址,指向虚函数实体地址
- 需要调整地址,指向一个相关的Thunk偏移量,从而将 this 指针调整到正确的位置,即减少若干字节的偏移量,然后再去调用虚函数

Folding
指向成员函数的指针
“指向Nonstatic Member Functions”的指针
定义并初始化:
double (Point::*coord)() = &Point::x;调用:
(origin.*coord)()或(ptr->*coord)()被编译器转化为:
(coord)(&origin);和(coord)(ptr);
“指向Virtual Member Functions”的指针
1 | class Point { |
对nonstatic函数取地址:
- 取x()的地址:
&Point::x()得到的是函数在内存中的地址
对virtual函数取地址:
- 取y()的地址:
&Point::y();得到的结果是2(索引值.)
1 | float (Point::*pmf)() = &Point::y; |
对指向虚函数的指针调用:
- 通过pmf来调用y()仍能实现虚函数机制,会被编译转化为
(*ptr->vptr[(int)pmf])(ptr); - 对一个”指向member function的指针”求值,由于该值有两种意义,其调用也将有别于常规操作
- pmf的内部定义为
float (Point::*pmf)(),可以指向nonvirtual x()和virtual z()。前者代表内存地址,后者代表 virtual table中的索引值。因此编译器必须定义pmf,使它含有这两种值并区分代表哪种
能否使用虚函数
- 模板类中可以使用虚函数吗?模板成员函数可以是虚函数吗?
- 前者可以,后者不行:编译阶段不知道程序中对虚成员模板函数的调用,就无法确定virtual table大小
- 构造函数能用虚函数吗:不能
- 从使用上,构造函数是在创建对象时自己主动调用的,不可能通过父类的指针去调用,用虚函数没意义。
- 从实现上,此时虚指针还没建立,找不到vtable,违反了先实例化后调用的准则
- 析构函数能用虚函数吗:推荐用,基类指针自动调用派生类析构方法,防止内存泄漏
- 构造函数和析构函数能调用虚函数吗:不提倡,并不会使用动态联编
- 构造函数中,子类对象还没初始化,调用子类不安全
- 析构函数中,子类对象已经销毁,同样调用虚函数没有意义
- 虚函数能声明inline吗:
- 当指向派生类的指针调用虚函数时,不会内联展开,因为编译器无法确定展开哪个函数
- 当是对象本身调用虚函数时,会内联展开,但前提需要函数并不复杂
- 析构函数能抛异常吗:不能
- 异常点后无法执行,内存泄漏
- 出现异常时,在栈展开过程中会调用在栈中构造好的对象的析构函数,此时如果上个异常还没解决,析构函数中又有新异常,会崩溃
多线程简例
1. 线程池
Folding
1 | class ThreadPool { |
2. 同步交替打印
Folding
1 |
|
3. 生产者消费者
Folding
1 |
|
4. 哲学家就餐
Folding
1 |
|
异步编程
std::future\promise
future表示一个可能还没有实际完成的异步任务的结果,针对这个结果可以添加回调函数以便在任务执行成功或失败后做出对应的操作;C++11 future内定义了一个原子对象,主线程通过自旋锁不断轮询,此外会进行
sys_futex系统调用promise交由任务执行者,任务执行者通过promise可以标记任务完成或者失败
所以 future\promise 编程模型本质上还是message pass(任务线程与主线程消息传递)。在future模型中阻塞和非阻塞都有:拉起一个新线程(非阻塞),在主线程 .get() (阻塞)。整个流程见下图:

其实future/promise最强大的功能是能够:
- 获得结果返回值
- 处理异常(如果任务线程发生异常)
- 链式回调(目前c++标准库不支持,但folly支持)
std::future 不支持拷贝,支持移动构造。c++提供的另一个类std::shared_future 支持拷贝。
1 | void compute(std::promise<int>& pms) { |
std::async
std::async 的默认启动策略是异步和同步执行兼有的。这个灵活性导致访问 thread_local 的不确定性,隐含了任务可能不会被执行的意思,会影响调用基于超时的wait的程序逻辑
如果异步执行任务非常关键,则指定 std::launch::async
1 | // std::launch::deferred 在调用 f1.wait() 时执行 |
std::packaged_task
std::packaged_task 将任何可调用对象(函数、lambda表达式等)封装成 task,可以异步执行,结果用 std::future 获取
1 | int f(int x, int y) { return std::pow(x, y); } |