[2.6] C++11 原子变量 | CAS操作 | 内存顺序

代码:https://github.com/leoda1/the-notes-of-cuda-programming/tree/main/code/CPU

1. 原子变量(Atomic Variable)

1.1 定义

c++11中提供的原子类型std::atomic<T>,内部变量通过这个原子类型管理后就变为原子变量。原子类型模板参数<T>可以指定bool、char、int、long、指针等类型(不支持浮点类型和复合类型)。

原子指一系列不可被CPU上下文交换的机器指令,这些指令组合在一起就形成了原子操作。在多核CPU下,当某个CPU核心开始运行原子操作时,会先暂停其它CPU内核对内存的操作,以保证原子操作不会被其它CPU内核所干扰。

由于原子操作是通过指令提供的支持,因此它的性能相比锁和消息传递会好很多。相比较于锁而言,原子类型不需要开发者处理加锁和释放锁的问题,同时支持修改,读取等操作,还具备较高的并发性能,几乎所有的语言都支持原子类型。

可以看出原子类型是无锁类型,但是无锁不代表无需等待,因为原子类型内部使用了CAS循环,当大量的冲突发生时,该等待还是得等待!但是总归比锁要好
c++11内置了int形的原子变量,用于更方便的使用它。多线程操作中,原子变量使用后不需要使用互斥量保护该变量。因为对原子变量的操作只能是原子操作(不会被线程调度机制打断的操作,一旦开始就一直运行到结束,中间没有上下文切换)。同时,多线程访问的共享资源造成的数据混乱(比如一个线程在修改这个数据,另一个线程也在修改相同的数据),此时使用原子变量可以很好的解决。通过使用原子操作指令,如lock cmpxchgfetch_add等来确保数据操作不可被其他线程打断。例如:

// Atomic_Op_exp1.cpp
#include <iostream>
#include <thread>

using namespace std;

int main () {
    int sum = 0;
    auto f = [&sum] () {
        for (int i = 0; i < 12345678; i ++) {
            sum += 1;
        }
    };

    thread td1(f);
    thread td2(f);
    td1.join();
    td2.join();
    cout << "sum: " << sum << endl;

    return 0;
}
/******************************************************************
sum: 16333875    test 1
sum: 12906647    test 2
sum: 14225737    test 3
*******************************************************************/

这个代码当中存在td1td2两个线程执行任务f,任务f中的lamba表达式是对sum做累加,理论上得到的结果应该是2 * 12345678,但是每次都不对,就是因为多个线程同时对 sum 进行读取、修改、写入的操作,会引发数据竞争(data race),导致结果不确定。

在多线程环境下,如果两个线程同时进行上述操作,就可能出现以下情况:

  1. 线程 A 读取 sum 的值为 12345678。
  2. 线程 B 读取 sum 的值也为 12345678。
  3. 线程 A 将值加 1 并写回,sum 的值变为 12345679。
  4. 线程 B 将值加 1 并写回,sum 的值也变为 12345679。

这样,两个线程各执行了一次加 1 操作,但 sum 的值只增加了 1。这就是数据竞争导致的错误。

1.2 atomic的缓存机制

atomic变量不会存储到缓存中而是运算完后直接给内存,所以先谈一下缓存机制:

普通变量的缓存机制atomic变量的缓存机制
1. 先把数据加载到CPU缓存(L1/L2/L3 cache) 2. 在cache中修改数据,合适时间再 写回主内存变量在修改后,会直接让 所有 CPU 核心立即看到最新的值,不会延迟同步。

在这里插入图片描述

所以,当涉及到复合类型的数据(class 或者 struct)的时候,atomic无能为力。复杂数据结构的修改通常需要多个步骤(如修改多个字段),而 std::atomic 只能保证单个变量的 不可中断性
atomic 指针本身的修改(++/--)是原子的,但指针指向的对象并不是原子的,需要额外的同步机制(如 mutexstd::shared_ptr)。

1.3 atomic类

atomic类定义:

// 定义于头文件 <atomic>
template< class T > // so, 在使用这个模板类的时候,一定要指定模板类型。
struct atomic;

atomic的构造函数是:

// 1 默认无参构造函数。 
atomic() noexcept = default;
// 2 使用 desired 初始化原子变量的值。
constexpr atomic( T desired ) noexcept;
// 3 使用=delete显示删除拷贝构造函数, 不允许进行对象之间的拷贝
atomic( const atomic& ) = delete;

//  exp1
std::atomic<int> x;
x.store(10);
//  exp2
std::atomic<int> x(5);
//  exp3
std::atomic<int>& x

atomic的公共成员函数:

原子类型在类内部重载了=操作符,并且不允许在类的外部使用 =进行对象的拷贝。

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 的值影响内存。直接访问原子对象也可以得到原子变量的当前值。

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;

范例:

// Atomic_Op_exp2.cpp
void test01()
{
    atomic_char cc('b');
    cc = 'd';
    cout << cc << endl;
    cc.store('a');
    cout << cc << endl;

    char ccc = cc.exchange('e');//返回之前的旧值
    cout << cc.load() << endl;
    cout << ccc << endl;
}
/******************************************************************
d
a
e
a
*******************************************************************/

1.4 特化成员函数(特化:为某些特定类型提供不同的实现)

对于整数类型的 std::atomic<T>,以下运算符提供 原子修改 变量的功能:

操作符重载函数等价的 fetch_* 方法适用于整数类型适用于指针
+=atomic::operator+=atomic::fetch_add✅ 是✅ 是
-=atomic::operator-=atomic::fetch_sub✅ 是✅ 是
&=atomic::operator&=atomic::fetch_and✅ 是❌ 否
^=atomic::operator^=atomic::fetch_xor✅ 是❌ 否

对于指针类型 std::atomic<T*>,仅支持 +=-= 操作,用于 原子地移动指针

操作符重载函数等价的 fetch_* 方法适用于整数类型适用于指针
+=atomic::operator+=atomic::fetch_add✅ 是✅ 是
-=atomic::operator-=atomic::fetch_sub✅ 是✅ 是
  • operator+= (ptrdiff_t val): 指针向前移动 val 个元素。
  • operator-= (ptrdiff_t val): 指针向后移动 val 个元素。

2 CAS操作(compare and swap,比较并交换)

CAS(Compare And Swap,比较并交换)无锁编程(Lock-Free Programming) 中的一种 原子操作,用于 实现多线程安全的数据更新。它允许多个线程 安全地竞争更新同一个变量,而不需要使用互斥锁(mutex)

没有交换操作,CAS 就不完整。交换操作可通过 exchange() 成员函数实现(前面的Atomic_Op_exp2.cpp中已经用过了)。调用 exchange() 会将原子的当前值与所需值互换,并返回原来的值。所有操作均以原子方式完成。

CAS 主要由三个值组成:

  • 原子变量(x):要修改的变量,通常是 std::atomic<T> 类型。
  • 预期值(expected):期望 x 当前的值。
  • 目标值(desired):希望 x 更新成的新值。

工作原理

  • 检查 x 是否等于 expected(即该变量在读取之后是否仍未被其他线程修改)。
  • 如果相等,说明没有其他线程修改 x,那么就 x 更新为 desired,并返回 true
  • 如果不相等,说明 x 在此期间被其他线程修改过,此时 expected 更新为 x 的最新值,并返回 false,通常需要重新尝试(重试循环)。

CAS 在swap前增加了一个附加条件,可通过 compare_exchange_strong()compare_exchange_weak()这两个函数使用。它将一个期望值与原子变量进行比较。如果它们相等,原子变量将被交换,并返回 true。否则,原子变量的当前值将被加载到expected变量,并返回 false。这种机制允许我们创建一个重试循环,而无需再次显式地读取原子值。例如:

std::atomic<int> x(0);
int expected = x.load();
int desired = 42;
// If x == expected, x = desired and return true
// Otherwise, expected = x and return false
while(!x.compare_exchange_strong(expected, desired));

CAS 会失败的原因是与其他线程的争用。在我们对原子进行初始读取以获得预期值后,我们必须确保其他线程在我们上次读取原子后没有对其进行更改。假设多个线程都在尝试 CAS,那么重试循环就会一直运行,直到我们的 CAS 比其他线程更快地完成更改。

2.1 在需要严格保证操作成功或失败时使用 compare_exchange_strong

// Atomic_Op_exp3.cpp
#include <iostream>
#include <thread>
#include <atomic>

using namespace std;

atomic<int> atomicInt(0);
void updateValue() {
    int expected = 0;
    int desired = 1;
    if (atomicInt.compare_exchange_strong(expected, desired)) {
        cout << "Value changed to " << desired << endl;
    } else {
        cout << "Expected " << expected << " but found " << atomicInt.load() << endl;
    }
}
int main() {
    thread t(updateValue);
    t.join();
    return 0;
}
/******************************************************************
Value changed to 1
*******************************************************************/

2.2 使用 compare_exchange_weak 的场景

compare_exchange_weak() 可能在某些 CPU 架构(如 ARM)在 LL/SC 机制下,出现伪失败。即使当前值等于预期值,也返回false。设计出来是为了优化在某些架构上的性能,适用于需要反复尝试的场景,如实现自旋锁或无锁数据结构。
示例1:自旋锁的代码实现:

// Atomic_Op_exp5.cpp
#include <iostream>
#include <atomic>
#include <thread>
#include <vector>
using namespace std;

int counter;
atomic<bool> lockFlag(false);

void spinlock_get() {
    bool expected = false;
    while (!lockFlag.compare_exchange_weak(expected, true)) {
        expected = false;
    }
}

void spinlock_free() {
    lockFlag.store(false);
}

void increment() {
    for (int i = 0; i < 123456; i ++) {
        spinlock_get();
        ++counter;
        spinlock_free();
    }
}

int main () {
    const int numThreads = 100;
    vector<thread> threads;
    for (int i = 0; i < numThreads; i ++) {
        threads.push_back(thread(increment));
    }

    for (auto& t : threads) {
        t.join();
    }
    cout << "Final counter value: " << counter << endl;
    return 0;
}
/******************************************************************
Final counter value: 12345600

//如果没有用自旋锁 输出的是:Final counter value: 7059450
*******************************************************************/

这段代码的lockFlag 作为 自旋锁(spinlock),初始值为 false(表示没有线程持有锁)。10个线程完成increment函数(累加1000次1),这个函数每次累加的时候会获取自旋锁和释放自旋锁。

自旋锁的实现:如果 lockFlag == expected(false),说明 锁是空闲的,于是将 lockFlag 设为 true(成功加锁)。如果 lockFlag != expected(说明其他线程已经持有锁),CAS 失败,返回 false,并 更新 expected = lockFlag(但这里 expected 直接重新设为 false,以便下一次尝试)。不断循环,直到成功获取锁
示例2:无锁队列的实现

#include <iostream>
#include <atomic>
#include <thread>
#include <vector>

class Node {
public:
    int value;
    Node* next;

    Node(int val) : value(val), next(nullptr) {}
};

std::atomic<Node*> head(nullptr);

void push(int value) {
    Node* newNode = new Node(value);
    Node* oldHead;
    do {
        oldHead = head.load(std::memory_order_relaxed);
        newNode->next = oldHead;
    } while (!head.compare_exchange_weak(oldHead, newNode, std::memory_order_release, std::memory_order_relaxed));
}

int main() {
    std::vector<std::thread> threads;
    for (int i = 0; i < 10; ++i) {
        threads.push_back(std::thread(push, i));
    }

    for (auto& t : threads) {
        t.join();
    }

    Node* current = head.load();
    while (current) {
        std::cout << current->value << " ";
        Node* tmp = current;
        current = current->next;
        delete tmp;
    }
    std::cout << std::endl;

    return 0;
}

2.3 Strong vs Weak

compare_exchange_strong() 只有在原子的当前值由于争用而不再是我们所期望的值时,才会失败。

另一方面,compare_exchange_weak()允许虚假失败。这意味着,即使预期值等于当前值,它仍可能返回 false。

但为什么我们希望即使当前值等于预期值,CAS 也会失败呢?这是因为在某些平台上,获取独占访问可能代价非常大,但读取原子值却往往代价很小。

因此,弱 CAS 不会要求独占访问,而是执行一定时间的尝试来获取锁(硬件),这可能会超时–类似于网络套接字。

步骤强 (compare_exchange_strong)弱 (compare_exchange_weak)
1. 线程读取原子变量并将其与预期值进行比较。如果匹配,进行强制尝试以获得独占访问,即等待直到成功。 如果不匹配,将当前值存储在预期变量中,并立即返回 false。如果匹配,它将进行一次有时限的尝试以获得独占访问。如果不匹配,将当前值存储在预期变量中,并立即返回 false
2. 获取独占访问等待直到我们获得独占访问。如果我们的有时限的尝试超时,我们返回 false——即使预期值与当前值匹配。
3. 已获得独占访问一旦获得独占访问,我们再次将当前值与预期值进行比较,以防它发生了变化。如果匹配,执行交换并返回 true。否则,将当前值存储在预期变量中,并返回 false一旦获得独占访问,我们再次将当前值与预期值进行比较,以防它发生了变化。如果匹配,执行交换并返回 true。否则,将当前值存储在预期变量中,并返回 false

3 内存顺序

编译器和 CPU 能够对程序指令进行重新排序,通常彼此独立。也就是说,编译器可以重新排序指令,CPU 可以再次重新排序指令。但是,只有当编译器在两组指令之间没有建立任何依赖关系时,才允许这样做。

例如,下面的代码可以重新排序,因为对 x 的赋值和对 y 的赋值之间没有关系。也就是说,编译器或 CPU 可能先分配 y,然后分配 x。但是,这不会改变程序的语义含义。

int x = 10;
int y = 5;

另一方面,下面的代码示例不能重新排序,因为编译器建立了 x 和 y 之间的关系。这里很容易看出,因为 y 取决于 x 的值。

int x = 10;
int y = x + 1;

我们先从一个简单的例子开始,一步步的引出内存顺序的概念:

/******************************************************************
 * Author      : Da Liu
 * Date        : 2025-03-07
 * File Name   : Atomic_Op_exp6.cpp
 * Description : 内存顺序(注释掉的代码取消注释就可以实现正确的数据
 *               访问了,当前的代码会出现assert)
 *****************************************************************/
#include <cassert>
#include <iostream>
#include <thread>
#include <atomic>

using namespace std;

int data_test = 0;
// atomic<bool> ready(false); 

void product() {
    data_test = 100;
    // ready.store(true); // Set flag
}

void consumer() {
    // while (!ready.load()) {
    //     this_thread::sleep_for(chrono::milliseconds(1));
    // }
    assert(data_test == 100);
}

int main() {
    thread t1(product);
    thread t2(consumer);
    t1.join();
    t2.join();
    return 0;
}

/******************************************************************
Atomic_Op_exp6: Atomic_Op_exp6.cpp:19: void consumer(): Assertion `
data_test == 100' failed.
Aborted (core dumped)
*******************************************************************/

这里的CPU 或编译器可能会对 (1) 和 (2) 进行重排序,导致:

store(ready, true)  // (2) 先执行
write(data_test , 100)    // (1) 后执行

那么,线程 2 可能看到:

while (!ready.load());  // (3) 读取到 true,退出循环
std::cout << data;      // (4) 读取 data,此时 data 可能仍然是 0!

导致 线程 2 读取到了错误的数据(脏读)

内存屏障(Memory Barrier)一条特殊的 CPU 指令,它告诉 CPU 在它之前的所有内存操作必须执行完成后,才能执行它之后的内存操作,从而防止指令重排序
修改代码为:

ready.store(true, std::memory_order_seq_cst);   //product函数内的
while (!ready.load(std::memory_order_seq_cst)); //consumer函数内的
  • memory_order_seq_cst最强的同步顺序,它确保:
    • 所有线程 都以 相同的全局顺序 观察所有 seq_cst 操作。
    • 不能被重排序,确保 (1) data = 100; 一定会在 (2) ready.store(true); 之前执行。
  • 内存屏障 保证:
    • 线程 1: data = 100; 必须在 ready.store(true); 之前完成。
    • 线程 2: ready.load(true); 一旦读取为 true,就保证它能看到 data = 100

3.1 内存顺序的类型

现在总结上面的如下:
在这里插入图片描述

Memory Order是否禁止重排序?是否保证跨线程可见性?适用场景
memory_order_relaxed可能重排序不保证跨线程可见性适用于 无数据依赖 的原子计数、自增等操作
memory_order_acquire禁止后面的指令重排序能看到 memory_order_release 之前的修改适用于 消费者(读取者)同步,如 load()
memory_order_release禁止前面的指令重排序确保修改能被 memory_order_acquire 线程看到适用于 生产者(写入者)同步,如 store()
memory_order_acq_rel禁止前后指令的重排序用于同步 load()store() 操作适用于 读-改-写 原子操作,如 fetch_add()
memory_order_seq_cst完全禁止重排序所有线程按相同顺序观察所有操作最严格的同步,适用于 线程间通信

3.2 原子变量和std::mutex之间的效率对比

#include <atomic>
#include <chrono>
#include <iostream>
#include <mutex>
#include <thread>
#include <vector>

const int NUM_THREADS = 8;
const int NUM_ITERATIONS = 1000000;

void atomicIncrement(std::atomic<int>& counter) {
    for (int i = 0; i < NUM_ITERATIONS; ++i) {
        ++counter;
    }
}

void mutexIncrement(int& counter, std::mutex& mtx) {
    for (int i = 0; i < NUM_ITERATIONS; ++i) {
        std::lock_guard<std::mutex> lock(mtx);
        ++counter;
    }
}

int main() {
    // 使用原子变量
    std::atomic<int> atomicCounter(0);
    auto atomicStart = std::chrono::high_resolution_clock::now();

    std::vector<std::thread> atomicThreads;
    for (int i = 0; i < NUM_THREADS; ++i) {
        atomicThreads.emplace_back(atomicIncrement, std::ref(atomicCounter));
    }

    for (auto& t : atomicThreads) {
        t.join();
    }

    auto atomicEnd = std::chrono::high_resolution_clock::now();
    std::chrono::duration<double> atomicDuration = atomicEnd - atomicStart;

    std::cout << "Atomic counter final value: " << atomicCounter << std::endl;
    std::cout << "Atomic duration: " << atomicDuration.count() << " seconds" << std::endl;

    // 使用互斥锁
    int mutexCounter = 0;
    std::mutex mtx;
    auto mutexStart = std::chrono::high_resolution_clock::now();

    std::vector<std::thread> mutexThreads;
    for (int i = 0; i < NUM_THREADS; ++i) {
        mutexThreads.emplace_back(mutexIncrement, std::ref(mutexCounter), std::ref(mtx));
    }

    for (auto& t : mutexThreads) {
        t.join();
    }

    auto mutexEnd = std::chrono::high_resolution_clock::now();
    std::chrono::duration<double> mutexDuration = mutexEnd - mutexStart;

    std::cout << "Mutex counter final value: " << mutexCounter << std::endl;
    std::cout << "Mutex duration: " << mutexDuration.count() << " seconds" << std::endl;

    return 0;
}

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

小马敲马

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值