一、C++ 标准库 std::atomic原子操作 和 互斥量(Mutex)关系
原子操作是多线程当中对资源进行保护的一种手段,主要作用是和互斥量(Mutex)一样,避免对资源的并发访问、修改。
互斥量的粒度衡量是作用域(哪怕作用域内只有一个变量),而原子的粒度衡量则是以一个变量或对象为单位。因此,原子相对于互斥量更加高效,但并非替代关系。
互斥量的主要作用:是保护作用域内的资源,而原子的作用:是保护一个变量或对象。
因此,当你需要保护的资源仅仅是某个变量或对象时,应首先考虑使用原子。
参考:【Example】C++ 标准库 std::atomic 及 std::memory_order-腾讯云开发者社区-腾讯云
二、 什么时候该用std::atomic?
在以下情况下应该使用std::atomic:
-
在多线程环境下对共享数据进行原子操作:当你的程序需要在多线程环境中对共享变量进行操作时,使用
std::atomic
可以确保这些操作是原子的,即不会被其他线程中断,从而保证数据的一致性和正确性。 -
需要保证特定操作的原子性:如果你需要进行一些特定的操作,如递增、递减、交换等,并且需要确保这些操作的原子性,即这些操作要么全部执行,要么全部不执行,不能部分执行,那么
std::atomic
是一个合适的选择。 -
需要避免使用锁的情况下进行线程同步:在某些情况下,你可能想要避免使用锁机制来进行线程同步,以减少死锁和性能问题。在这种情况下,
std::atomic
提供了一种无锁的同步机制,通过内部使用硬件支持的原子指令或操作系统提供的原子操作接口来保证操作的原子性。 -
对于大型的数据结构或频繁访问的变量:对于大型的数据结构或频繁访问的变量,直接使用
std::atomic
可能带来较大的内存开销。然而,C++20中引入的std::atomic_ref
允许直接对现有非原子变量进行原子操作,无需创建副本,从而避免了这部分内存开销12。 -
对于任意可拷贝的数据类型:
std::atomic
是一个模板类,可以用于任意可拷贝的数据类型,包括基本数据类型(如整型、浮点型)、指针类型以及用户自定义的类型。这使得std::atomic
在处理复杂数据结构时也具有很高的灵活性4。 -
提供多种内存顺序选项:
std::atomic
提供了多种内存顺序(memory order)选项,用于指定原子操作的内存访问顺序。这些不同的内存顺序可以在性能和内存一致性之间进行权衡,满足不同的应用需求4。
综上所述,std::atomic
适用于需要在多线程环境中对共享数据进行原子操作、需要保证特定操作的原子性、避免使用锁的情况下进行线程同步等场景。同时,它还提供了对多种数据类型的支持以及灵活的内存顺序选项,使得它在多线程编程中扮演着重要的角色。
三、原子引用 std::atomic_ref
为什么要引入原子引用
在C++ 11中,std::atomic提供了一种线程安全的内存访问方式。但它要求将变量声明为原子类型,这意味着,必须为原子对象分配额外的内存。对于大型的数据结构,或者频繁访问的变量,这种开销可能是不可接受的。C++ 20中新引入的原子引用std::atomic_ref允许直接对现有非原子变量进行原子操作,无需创建副本,从而避免了这部分内存开销。
什么是原子引用
原子引用std::atomic_ref是C++ 20中新引入的一个模板类,它允许我们以原子方式访问和修改非原子类型的对象。与传统的原子类型std::atomic不同,原子引用并不直接存储数据,而是引用了一个已存在的非原子类型对象。这使得可以在不改变对象类型的情况下,为其提供原子访问能力。
在很多项目中,程序的数据结构已经定义完成。出于性能或兼容性考虑,直接修改数据结构以增加原子性可能有点不切实际,甚至代价特别高昂。std::atomic_ref提供了一种灵活的解决方案,能够在不改变原有数据结构的基础上,增加原子操作的能力,从而极大增强了代码的可维护性和向前兼容性。
另外,某些特定的并发算法或数据结构可能只需要在特定时刻对特定变量进行原子操作。std::atomic_ref使得我们能够按需使用原子性,在不需要原子操作时,依旧可以使用常规的非原子访问方式。这有利于细粒度优化并发控制,减少不必要的同步开销。
std::atomic_ref的使用
要使用原子引用,需要包含头文件<atomic>。我们先来看一个最简单的使用场景:原子地对一个普通整数进行递增操作。在下面的示例代码中,s_nCounter是一个静态的全局整数变量。通过std::atomic_ref<int>,我们创建了一个指向s_nCounter的原子引用。两个线程t1和t2各自执行了1000次递增操作,由于使用了atomic_ref,这些操作是线程安全的。最后的输出结果为2000,证明了原子递增的正确性。
#include <iostream>
#include <thread>
#include <atomic>
using namespace std;
static int s_nCounter = 0;
void IncrementCounter()
{
atomic_ref<int> refCounter(s_nCounter);
for(int i = 0; i < 1000; ++i)
{
++refCounter;
}
}
int main()
{
thread t1(IncrementCounter);
thread t2(IncrementCounter);
t1.join();
t2.join();
// 输出:2000
cout << s_nCounter << endl;
return 0;
}
下面我们来看一个更复杂的使用场景:多个线程同时对vector中的元素进行递增操作。在下面的示例代码中,我们声明了两个函数。一个是直接递增函数pIncDirectly,它接受一个引用到Data类型的参数data。使用范围for循环遍历data中的每个元素,并直接对其进行递增操作。这种方式在多线程环境下可能导致数据竞争和未定义行为,因为多个线程可能同时修改同一个元素。另一个是原子递增函数pIncAtomically,同样接受一个引用到Data类型的参数data。对于data中的每一个元素,首先通过std::atomic_ref<DataItemType>创建一个原子引用xx,然后对这个原子引用执行递增操作。这样即使在多线程环境中,对每个元素的递增也是原子的,保证了操作的线程安全性。
直接递增在多线程环境下很可能出现数据竞争,导致最终累加的结果小于预期,输出可能为39999968,这是因为多个线程同时修改数据时发生了冲突。通过atomic_ref,我们保证了递增操作的原子性。即使在多线程环境下也能正确无误地递增每个元素,确保了结果的准确性,输出为预期的40000000。
#include <atomic>
#include <iostream>
#include <numeric>
#include <thread>
#include <vector>
using namespace std;
int main()
{
using Data = vector<char>;
using DataItemType = Data::value_type;
auto pIncDirectly = [](Data& data)
{
for (DataItemType& x : data)
{
++x;
}
};
auto pIncAtomically = [](Data& data)
{
for (DataItemType& x : data)
{
auto xx = atomic_ref<DataItemType>(x);
++xx;
}
};
auto Increment = [](const auto Fun)
{
Data data(10'000'000);
{
jthread j1{Fun, ref(data)};
jthread j2{Fun, ref(data)};
jthread j3{Fun, ref(data)};
jthread j4{Fun, ref(data)};
}
cout << accumulate(cbegin(data), cend(data), 0) << endl;
};
// 输出可能为:39999968
Increment(pIncDirectly);
// 输出为:40000000
Increment(pIncAtomically);
return 0;
}
原文链接:https://blog.csdn.net/hope_wisdom/article/details/139759228