07 | 线程同步之原子变量
C++11 提供了一个原子类型 std::atomic
由于原子操作是通过指令提供的支持,因此它的性能相比锁和消息传递会好很多。相比较于锁而言,原子类型不需要开发者处理加锁和释放锁的问题,同时支持修改,读取等操作,还具备较高的并发性能,几乎所有的语言都支持原子类型。
可以看出原子类型是无锁类型,但是无锁不代表无需等待,因为原子类型内部使用了 CAS 循环,当大量的冲突发生时,该等待还是得等待!但是总归比锁要好。
CAS 全称是 Compare and swap, 它通过一条指令读取指定的内存地址,然后判断其中的值是否等于给定的前置值,如果相等,则将其修改为新的值
TAS 为 test and set
atomic 类成员
类定义
// 定义于头文件 <atomic>
template< class T >
struct atomic;
通过定义可得知:在使用这个模板类的时候,一定要指定模板类型.
构造函数
// ①
atomic() noexcept = default;
// ②
constexpr atomic( T desired ) noexcept; //使用 desired 初始化原子变量的值。
// ③
atomic( const atomic& ) = delete; // 使用 =delete 显示删除拷贝构造函数,**不允许进行对象之间的拷贝**
公共成员函数
原子类型在类内部重载了 = 操作符,并且不允许在类的外部使用 = 进行对象的拷贝。
T operator=( T desired ) noexcept;
T operator=( T desired ) volatile noexcept;
atomic& operator=( const atomic& ) = delete;
atomic& operator=( const atomic& ) volatile = delete;
原子地以 desired 替换当前值。按照 order 的值影响内存。
void store( T desired, std::memory_order order = std::memory_order_seq_cst ) noexcept;
void store( T desired, std::memory_order order = std::memory_order_seq_cst ) volatile noexcept;
原子地加载并返回原子变量的当前值。按照 order 的值影响内存。直接访问原子对象也可以得到原子变量的当前值。
- desired:存储到原子变量中的值
- order:强制的内存顺序
原子地加载并返回原子变量的当前值。按照 order 的值影响内存。直接访问原子对象也可以得到原子变量的当前值。
T load( std::memory_order order = std::memory_order_seq_cst ) const noexcept;
T load( std::memory_order order = std::memory_order_seq_cst ) const volatile noexcept;
特化成员函数
- 复合赋值运算符重载,主要包含以下形式
- 以上各个 operator 都会有对应的 fetch_* 操作,详细见下表:
内存顺序约束
通过上面的 API 函数我们可以看出,在调用 atomic 类提供的 API 函数的时候,需要指定原子顺序,在 C++11 给我们提供的 API 中使用枚举用作执行原子操作的函数的实参,以指定如何同步不同线程上的其他操作。
定义如下:
typedef enum memory_order {
memory_order_relaxed, // relaxed
memory_order_consume, // consume
memory_order_acquire, // acquire
memory_order_release, // release
memory_order_acq_rel, // acquire/release
memory_order_seq_cst // sequentially consistent
} memory_order;
- memory_order_relaxed, 这是最宽松的规则,它对编译器和 CPU 不做任何限制,可以乱序
- memory_order_release 释放,设定内存屏障 (Memory barrier),保证它之前的操作永远在它之前,但是它后面的操作可能被重排到它前面
- memory_order_acquire 获取 , 设定内存屏障,保证在它之后的访问永远在它之后,但是它之前的操作却有可能被重排到它后面,往往和 Release 在不同线程中联合使用
- memory_order_consume:改进版的 memory_order_acquire ,开销更小
- memory_order_acq_rel,它是 Acquire 和 Release 的结合,同时拥有它们俩提供的保证。比如你要对一个 atomic 自增 1,同时希望该操作之前和之后的读取或写入操作不会被重新排序
- memory_order_seq_cst 顺序一致性, memory_order_seq_cst 就像是 memory_order_acq_rel 的加强版,它不管原子操作是属于读取还是写入的操作,只要某个线程有用到 memory_order_seq_cst 的原子操作,线程中该 memory_order_seq_cst 操作前的数据操作绝对不会被重新排在该 memory_order_seq_cst 操作之后,且该 memory_order_seq_cst 操作后的数据操作也绝对不会被重新排在 memory_order_seq_cst 操作前。
一致性模型
并行执行的多个线程,从某种宏观层面上讨论,可以粗略的视为一种分布式系统。 在分布式系统中,任何通信乃至本地操作都需要消耗一定时间,甚至出现不可靠的通信。
如果我们强行将一个变量 v 在多个线程之间的操作设为原子操作,即任何一个线程在操作完 v 后, 其他线程均能同步感知到 v 的变化,则对于变量 v 而言,表现为顺序执行的程序,它并没有由于引入多线程 而得到任何效率上的收益。对此有什么办法能够适当的加速呢?答案便是削弱原子操作的在进程间的同步条件。
从原理上看,每个线程可以对应为一个集群节点,而线程间的通信也几乎等价于集群节点间的通信。 削弱进程间的同步条件,通常我们会考虑四种不同的一致性模型:
- 线性一致性:又称强一致性或原子一致性。它要求任何一次读操作都能读到某个数据的最近一次写的数据,并且所有线程的操作顺序与全局时钟下的顺序是一致的。
x.store(1) x.load()
T1 ---------+----------------+------>
T2 -------------------+------------->
x.store(2)
在这种情况下线程 T1, T2 对 x 的两次写操作是原子的,且 x.store(1) 是严格的发生在 x.store(2) 之前,x.store(2) 严格的发生在 x.load() 之前。 值得一提的是,线性一致性对全局时钟的要求是难以实现的,这也是人们不断研究比这个一致性更弱条件下其他一致性的算法的原因。
2. 顺序一致性:同样要求任何一次读操作都能读到数据最近一次写入的数据,但未要求与全局时钟的顺序一致。
x.store(1) x.store(3) x.load()
T1 ---------+-----------+----------+----->
T2 ---------------+---------------------->
x.store(2)
或者
x.store(1) x.store(3) x.load()
T1 ---------+-----------+----------+----->
T2 ------+------------------------------->
x.store(2)
在顺序一致性的要求下,x.load() 必须读到最近一次写入的数据,因此 x.store(2) 与 x.store(1) 并无任何先后保障,即 只要 T2 的 x.store(2) 发生在 x.store(3) 之前即可。
3. 因果一致性:它的要求进一步降低,只需要有因果关系的操作顺序得到保障,而非因果关系的操作顺序则不做要求。
a = 1 b = 2
T1 ----+-----------+---------------------------->
T2 ------+--------------------+--------+-------->
x.store(3) c = a + b y.load()
或者
a = 1 b = 2
T1 ----+-----------+---------------------------->
T2 ------+--------------------+--------+-------->
x.store(3) y.load() c = a + b
亦或者
b = 2 a = 1
T1 ----+-----------+---------------------------->
T2 ------+--------------------+--------+-------->
y.load() c = a + b x.store(3)
上面给出的三种例子都是属于因果一致的,因为整个过程中,只有 c 对 a 和 b 产生依赖,而 x 和 y 在此例子中表现为没有关系(但实际情况中我们需要更详细的信息才能确定 x 与 y 确实无关)
- 最终一致性:是最弱的一致性要求,它只保障某个操作在未来的某个时间节点上会被观察到,但并未要求被观察到的时间。因此我们甚至可以对此条件稍作加强,例如规定某个操作被观察到的时间总是有界的。当然这已经不在我们的讨论范围之内了。
x.store(3) x.store(4)
T1 ----+-----------+-------------------------------------------->
T2 ---------+------------+--------------------+--------+-------->
x.read() x.read() x.read() x.read()
在上面的情况中,如果我们假设 x 的初始值为 0,则 T2 中四次 x.read() 结果可能但不限于以下情况:
3 4 4 4 // x 的写操作被很快观察到
0 3 3 4 // x 的写操作被观察到的时间存在一定延迟
0 0 0 4 // 最后一次读操作读到了 x 的最终值,但此前的变化并未观察到
0 0 0 0 // 在当前时间段内 x 的写操作均未被观察到,但未来某个时间点上一定能观察到 x 为 4 的情况
内存顺序
为了追求极致的性能,实现各种强度要求的一致性,C++11 为原子操作定义了六种不同的内存顺序 std::memory_order 的选项,表达了四种多线程间的同步模型:
- 宽松模型:在此模型下,单个线程内的原子操作都是顺序执行的,不允许指令重排,但不同线程间原子操作的顺序是任意的。类型通过 std::memory_order_relaxed 指定。我们来看一个例子:
std::atomic<int> counter = {0};
std::vector<std::thread> vt;
for (int i = 0; i < 100; ++i) {
vt.emplace_back([&](){
counter.fetch_add(1, std::memory_order_relaxed);
});
}
for (auto& t : vt) {
t.join();
}
std::cout << "current counter:" << counter << std::endl;
- 释放/消费模型:在此模型中,我们开始限制进程间的操作顺序,如果某个线程需要修改某个值,但另一个线程会对该值的某次操作产生依赖,即后者依赖前者。具体而言,线程 A 完成了三次对 x 的写操作,线程 B 仅依赖其中第三次 x 的写操作,与 x 的前两次写行为无关,则当 A 主动 x.release() 时候(即使用 std::memory_order_release),选项 std::memory_order_consume 能够确保 B 在调用 x.load() 时候观察到 A 中第三次对 x 的写操作。我们来看一个例子:
// 初始化为 nullptr 防止 consumer 线程从野指针进行读取
std::atomic<int*> ptr(nullptr);
int v;
std::thread producer([&]() {
int* p = new int(42);
v = 1024;
ptr.store(p, std::memory_order_release); //如果我们把 v=1024移到这条语句的后面,则另一个线程可能会输出不正确的值!
});
std::thread consumer([&]() {
int* p;
while(!(p = ptr.load(std::memory_order_consume)));
std::cout << "p: " << *p << std::endl;
std::cout << "v: " << v << std::endl;
});
producer.join();
consumer.join();
- 释放/获取模型:在此模型下,我们可以进一步加紧对不同线程间原子操作的顺序的限制,在释放 std::memory_order_release 和获取 std::memory_order_acquire 之间规定时序,即发生在释放(release)操作之前的所有写操作,对其他线程的任何获取(acquire)操作都是可见的,亦即发生顺序(happens-before)。
可以看到,std::memory_order_release 确保了它之前的写操作不会发生在释放操作之后,是一个向后的屏障(backward),而 std::memory_order_acquire 确保了它之前的写行为不会发生在该获取操作之后,是一个向前的屏障(forward)。对于选项 std::memory_order_acq_rel 而言,则结合了这两者的特点,唯一确定了一个内存屏障,使得当前线程对内存的读写不会被重排并越过此操作的前后:
我们来看一个例子:
std::vector<int> v;
std::atomic<int> flag = {0};
std::thread release([&]() {
v.push_back(42);
flag.store(1, std::memory_order_release);
});
std::thread acqrel([&]() {
int expected = 1; // must before compare_exchange_strong
while(!flag.compare_exchange_strong(expected, 2, std::memory_order_acq_rel)) {
expected = 1; // must after compare_exchange_strong
}
// flag has changed to 2
});
std::thread acquire([&]() {
while(flag.load(std::memory_order_acquire) < 2);
std::cout << v.at(0) << std::endl; // must be 42
});
release.join();
acqrel.join();
acquire.join();
在此例中我们使用了 compare_exchange_strong,它便是比较交换原语(Compare-and-swap primitive),它有一个更弱的版本,即 compare_exchange_weak,它允许即便交换成功,也仍然返回 false 失败。其原因是因为在某些平台上虚假故障导致的,具体而言,当 CPU 进行上下文切换时,另一线程加载同一地址产生的不一致。除此之外,compare_exchange_strong 的性能可能稍差于 compare_exchange_weak,但大部分情况下,compare_exchange_strong 应该被有限考虑。
- 顺序一致模型:在此模型下,原子操作满足顺序一致性,进而可能对性能产生损耗。可显式的通过 std::memory_order_seq_cst 进行指定。最后来看一个例子:
std::atomic<int> counter = {0};
std::vector<std::thread> vt;
for (int i = 0; i < 100; ++i) {
vt.emplace_back([&](){
counter.fetch_add(1, std::memory_order_seq_cst);
});
}
for (auto& t : vt) {
t.join();
}
std::cout << "current counter:" << counter << std::endl;
C++20 新增成员
在 C++20 版本中添加了新的功能函数,可以通过原子类型来阻塞线程,和条件变量中的等待 / 通知函数是一样的。
类型别名
原子变量的使用
假设我们要制作一个多线程交替数数的计数器,我们使用互斥锁和原子变量的方式分别进行实现,对比一下二者的差异:
互斥锁版本
#include <iostream>
#include <thread>
#include <mutex>
#include <atomic>
#include <functional>
using namespace std;
struct Counter
{
void increment()
{
for (int i = 0; i < 10; ++i)
{
lock_guard<mutex> locker(m_mutex);
m_value++;
cout << "increment number: " << m_value
<< ", theadID: " << this_thread::get_id() << endl;
this_thread::sleep_for(chrono::milliseconds(100));
}
}
void decrement()
{
for (int i = 0; i < 10; ++i)
{
lock_guard<mutex> locker(m_mutex);
m_value--;
cout << "decrement number: " << m_value
<< ", theadID: " << this_thread::get_id() << endl;
this_thread::sleep_for(chrono::milliseconds(100));
}
}
int m_value = 0;
mutex m_mutex;
};
int main()
{
Counter c;
auto increment = bind(&Counter::increment, &c);
auto decrement = bind(&Counter::decrement, &c);
thread t1(increment);
thread t2(decrement);
t1.join();
t2.join();
return 0;
}
原子变量版本
#include <iostream>
#include <thread>
#include <atomic>
#include <functional>
using namespace std;
struct Counter
{
void increment()
{
for (int i = 0; i < 10; ++i)
{
m_value++;
cout << "increment number: " << m_value
<< ", theadID: " << this_thread::get_id() << endl;
this_thread::sleep_for(chrono::milliseconds(500));
}
}
void decrement()
{
for (int i = 0; i < 10; ++i)
{
m_value--;
cout << "decrement number: " << m_value
<< ", theadID: " << this_thread::get_id() << endl;
this_thread::sleep_for(chrono::milliseconds(500));
}
}
// atomic<int> == atomic_int
atomic_int m_value = 0;
};
int main()
{
Counter c;
auto increment = bind(&Counter::increment, &c);
auto decrement = bind(&Counter::decrement, &c);
thread t1(increment);
thread t2(decrement);
t1.join();
t2.join();
return 0;
}
参考
爱编程的大丙 https://subingwen.cn/
https://github.com/changkun/modern-cpp-tutorial/blob/master/book/zh-cn/07-thread.md