C++并发编程-11. C++ 原子操作和内存模型
简介
- 本文介绍C++ 内存模型相关知识,包含几种常见的内存访问策略。
改动序列
-
在一个C++程序中,每个对象都具有一个改动序列,它由所有线程在对象上的全部写操作构成,其中第一个写操作即为对象的初始化。大部分情况下,这个序列会随程序的多次运行而发生变化,但是在程序的任意一次运行过程中,所含的全部线程都必须形成相同的改动序列。
-
改动序列基本要求如下
- 1 只要某线程看到过某个对象,则该线程的后续读操作必须获得相对新近的值,并且,该线程就同一对象的后续写操作,必然出现在改动序列后方。
- 2 如果某线程先向一个对象写数据,过后再读取它,那么必须读取前面写的值。
- 3 若在改动序列中,上述读写操作之间还有别的写操作,则必须读取最后写的值。
- 4 在程序内部,对于同一个对象,全部线程都必须就其形成相同的改动序列,并且在所有对象上都要求如此.
- 5 多个对象上的改动序列只是相对关系,线程之间不必达成一致
原子类型
- 标准原子类型的定义位于头文件内。我们可以通过atomic<>定义一些原子类型的变量,如atomic,atomic 这些类型的操作全是原子化的。
- 从C++17开始,所有的原子类型都包含一个静态常量表达式成员变量,std::atomic::is_always_lock_free。这个成员变量的值表示在任意给定的目标硬件上,原子类型X是否始终以无锁结构形式实现。如果在所有支持该程序运行的硬件上,原子类型X都以无锁结构形式实现,那么这个成员变量的值就为true;否则为false。
- 只有一个原子类型不提供is_lock_free()成员函数:std::atomic_flag 。类型std::atomic_flag的对象在初始化时清零,随后即可通过成员函数test_and_set()查值并设置成立,或者由clear()清零。整个过程只有这两个操作。其他的atomic<>的原子类型都可以基于其实现。
- std::atomic_flag的test_and_set成员函数是一个原子操作,他会先检查std::atomic_flag当前的状态是否被设置过,
- 1 如果没被设置过(比如初始状态或者清除后),将std::atomic_flag当前的状态设置为true,并返回false。
- 2 如果被设置过则直接返回ture。
- 对于std::atomic类型的原子变量,还支持load()和store()、exchange()、compare_exchange_weak()和compare_exchange_strong()等操作。
- 需要注意的是,所有原子类型都不支持拷贝和赋值。因为该操作涉及了两个原子对象:要先从另外一个原子对象上读取值,然后再写入另外一个原子对象。而对于两个不同的原子对象上单一操作不可能是原子的。
免锁(Lock-Free):指原子操作不依赖传统的互斥锁(如 std::mutex),而是直接通过 CPU 的原子指令(如 x86 的 LOCK CMPXCHG)实现线程安全。功能:查询该原子类型的操作是否由硬件直接支持免锁。
内存次序
-
对于原子类型上的每一种操作,我们都可以提供额外的参数,从枚举类std::memory_order取值,用于设定所需的内存次序语义(memory-ordering semantics)。
-
枚举类std::memory_order具有6个可能的值,
-
存储(store)操作,可选用的内存次序有
std::memory_order_relaxed、std::memory_order_release或std::memory_order_seq_cst。 -
载入(load)操作,可选用的内存次序有
std::memory_order_relaxed、std::memory_order_consume、std::memory_order_acquire或std::memory_order_seq_cst。 -
“读-改-写”(read-modify-write)操作,可选用的内存次序有
std::memory_order_relaxed、std::memory_order_consume、std::memory_order_acquire、std::memory_order_release、std::memory_order_acq_rel或std::memory_order_seq_cst
原子操作默认使用的是std::memory_order_seq_cst次序。
- 这六种内存顺序相互组合可以实现三种顺序模型 (ordering model)
Sequencial consistent ordering. 实现同步, 且保证全局顺序一致 (single total order) 的模型. 是一致性最强的模型, 也是默认的顺序模型.
Acquire-release ordering. 实现同步, 但不保证保证全局顺序一致的模型.
Relaxed ordering. 不能实现同步, 只保证原子性的模型.
实现自旋锁
- 自旋锁是一种在多线程环境下保护共享资源的同步机制。它的基本思想是,当一个线程尝试获取锁时,如果锁已经被其他线程持有,那么该线程就会不断地循环检查锁的状态,直到成功获取到锁为止。
- 那我们用这个std:atomic_flag实现一个自旋锁。
#include <iostream>
#include <atomic>
#include <thread>// 自旋锁 begin
class SpinLock {
public:void lock() {//1 处while (flag.test_and_set(std::memory_order_acquire)); // 自旋等待,直到成功获取到锁}void unlock() {//2 处flag.clear(std::memory_order_release); // 释放锁}
private:std::atomic_flag flag = ATOMIC_FLAG_INIT;
};void TestSpinLock() {SpinLock spinlock;std::thread t1([&spinlock]() {spinlock.lock();for (int i = 0; i < 3; i++) {std::cout << "*";}std::cout << std::endl;spinlock.unlock();});std::thread t2([&spinlock]() {spinlock.lock();for (int i = 0; i < 3; i++) {std::cout << "?";}std::cout << std::endl;spinlock.unlock();});t1.join();t2.join();
}
// 自旋锁 end
int main()
{TestSpinLock();/****???*/return 0;
}
1 处 在多线程调用时,仅有一个线程在同一时刻进入test_and_set,因为atomic_flag初始状态为false,所以test_and_set将atomic_flag设置为true,并且返回false。
比如线程A调用了test_and_set返回false,这样lock函数返回,线程A继续执行加锁区域的逻辑。此时线程B调用test_and_set,test_and_set会返回true,导致线程B在while循环中循环等待,达到自旋检测标记的效果。当线程A直行至2处调用clear操作后,atomic_flag被设置为清空状态,线程B调用test_and_set会将状态设为成立并返回false,B线程执行加锁区域的逻辑。
我们看到在设置时使用memory_order_acquire内存次序,在清除时使用了memory_order_release内存次序。
宽松内存序
为了给大家介绍不同的字节序,我们先从最简单的字节序std::memory_order_relaxed(宽松字节序)介绍。
因为字节序是为了实现改动序列的,所以为了理解字节序还要结合改动序列讲起。
// 宽松内存次序 begin
std::atomic<bool> x, y;
std::atomic<int> z;
void write_x_then_y()
{//write_x_then_y负责将x和y存储为true。x.store(true, std::memory_order_relaxed); // 1y.store(true, std::memory_order_relaxed); // 2
}
void read_y_then_x()
{//read_y_then_x负责读取x和y的值。while (!y.load(std::memory_order_relaxed)) { // 3std::cout << "y load false" << std::endl;}if (x.load(std::memory_order_relaxed)) { //4++z;}
}
void TestOrderRelaxed() {std::thread t1(write_x_then_y);std::thread t2(read_y_then_x);t1.join();t2.join();assert(z.load() != 0); // 5
}
// 宽松内存次序 end
- 上面的代码assert断言z不为0,但有时运行到5处z会等于0触发断言。
void TestOderRelaxed2() {std::atomic<int> a{ 0 };std::vector<int> v3, v4;std::thread t1([&a]() {for (int i = 0; i < 10; i += 2) {a.store(i, std::memory_order_relaxed);}});std::thread t2([&a]() {for (int i = 1; i < 10; i += 2)a.store(i, std::memory_order_relaxed);});std::thread t3([&v3, &a]() {for (int i = 0; i < 10; ++i)v3.push_back(a.load(std::memory_order_relaxed));});std::thread t4([&v4, &a]() {for (int i = 0; i < 10; ++i)v4.push_back(a.load(std::memory_order_relaxed));});t1.join();t2.join();t3.join();t4.join();for (int i : v3) {std::cout << i << " ";}std::cout << std::endl;for (int i : v4) {std::cout << i << " ";}std::cout << std::endl;
}
术语总括
- sequenced-before是一种单线程上的关系,这是一个非对称,可传递的成对关系。
- happens-before关系是sequenced-before关系的扩展,因为它还包含了不同线程之间的关系。这是一个非对称,可传递的关系。(如果A happens-before B,则A的内存状态将在B操作执行之前就可见,这就为线程间的数据访问提供了保证。)
- synchronizes-with描述的是一种状态传播(propagate)关系。如果A synchronizes-with B,则就是保证操作A的状态在操作B执行之前是可见的。
先行(Happens-before)
顺序先行(sequence-before)
线程间先行
依赖关系
Happens-before不代表质量执行顺序
脑图
总结
本文介绍了3种内存模型,包括全局一致性模型,同步模型以及最宽松的原子模型,以及6种内存序,下一篇将介绍如何利用6中内存序达到三种模型的效果。
memory_order_relaxed(最宽松的内存序)
1. 作用于原子性
线程1操作变量m,不会被线程B干扰
2. 不具有 synchronizes-with
线程1对变量m进行写了,线程2可能读到写之前的也可能读到写之后的
3.对于同一个原子变量,在同一个线程中具有happens-before关系, 在同一线程中不同的原子变量不具有happens-before关系,可以乱序执行。
乱序执行:2可能跑到1前面
atmoic m,b;
m.store(1,memory_order_relaxed); 1
b.store(2,memory_order_relaxed); 2
4. 多线程情况下不具有happens-before关系。
-
A synchronizes-with B 同步 === A happens-before B A操作的结果对B可见
-
A happens-before B 先行 A操作的结果对B可见(不是执行的顺序关系)
-
顺序先行(sequenced-before):a操作先行于b 具有传递性
单线程先行
多线程先行
A synchronizes-with B == A sequenced-before B -
依赖关系
carries-dependency
单线程:a “sequenced-before” b, 且 b 依赖 a 的数据, 则 a “carries a dependency into” b. 称作 a 将依赖关系带给 b, 也理解为b依赖于a。dependency-oredered before
多线程情况:
线程1执行操作A(比如对i自增),线程2执行操作B(比如根据i访问字符串下表的元素), 如果线程1先于线程2执行,且操作A的结果对操作B可见,我们将这种叫做
A “dependency-ordered before” B.