首页 > 编程语言 >《C++并发编程实战》读书笔记(4):原子变量

《C++并发编程实战》读书笔记(4):原子变量

时间:2023-09-05 22:13:22浏览次数:42  
标签:std 读书笔记 编程 C++ 原子 flag atomic memory order

1、标准原子类型

标准原子类型的定义位于头文件<atomic>内。原子操作的关键用途是取代需要互斥的同步方式,但假设原子操作本身也在内部使用了互斥,就很可能无法达到期望的性能提升。有三种方法来判断一个原子类型是否属于无锁数据结构:

  • 所有标准原子类型(std::atomic_flag除外,因为它必须采取无锁操作)都具有成员函数is_lock_free(),若它返回true则表示给定类型上的操作是能由原子指令直接实现的,若返回false则表示需要借助编译器和程序库的内部锁来实现。
  • C++程序库提供了一组宏:ATOMIC_BOOL_LOCK_FREEATOMIC_CHAR_LOCK_FREEATOMIC_CHAR16_T_LOCK_FREEATOMIC_CHAR32_T_LOCK_FREEATOMIC_WCHAR_T_LOCK_FREEATOMIC_SHORT_LOCK_FREEATOMIC_INT_LOCK_FREEATOMIC_LONG_LOCK_FREEATOMIC_LLONG_LOCK_FREEATOMIC_POINTER_LOCK_FREE。宏取值为0表示对应的std::atomic<>特化类型从来都不属于无锁结构,取值为1表示运行时才能确定是否属于无锁结构,取值为2表示它一直属于无锁结构。
  • 从C++17开始,全部原子类型都含有一个静态常量表达式成员变量X::is_always_lock_free,功能与上述那些宏相同,用于在编译期判定一个原子类型是否属于无锁结构。当且仅当在所有支持运行该程序的硬件上,原子类型X全都以无锁结构形式实现,该成员变量的值才为true

除了std::atomic_flag,其余原子类型都是通过模板std::atomic<>特化得到的。由内建类型特化得到的原子类型,其接口反映出自身性质,例如C++标准没有为普通指针定义位运算(如&=),所以不存在专为原子化指针而定义的位运算。一些内建类型的std::atomic<>特化如下表:

原子类型的别名 对应的特化
atomic_bool std::atomic<bool>
atomic_char std::atomic<char>
atomic_schar std::atomic<signed char>
atomic_uchar std::atomic<unsigned char>
atomic_int std::atomic<int>
atomic_uint std::atomic<unsigned>
atomic_short std::atomic<short>
atomic_ushort std::atomic<unsigned short>
atomic_long std::atomic<long>
atomic_ulong std::atomic<unsigned long>
atomic_llong std::atomic<long long>
atomic_ullong std::atomic<unsigned long long>
atomic_char16_t std::atomic<char16_t>
atomic_char32_t std::atomic<char32_t>
atomic_wchar_t std::atomic<wchar_t>

原子类型对象无法复制,也无法赋值,但可以接受内建类型赋值,也支持隐式地转换成内建类型。需要注意的是:按照C++惯例,赋值操作符通常返回一个引用,指向接受赋值的目标对象;而原子类型的赋值操作符不返回引用,而是按值返回(该值属于对应的非原子类型)。

2、原子操作

各种原子类型上可以执行的操作如下表所示:

操作 atomic_flag atomic<bool> atomic<T*> 整数原子类型 其它原子类型
test_and_set Y
clear Y
is_lock_free Y Y Y Y
load Y Y Y Y
store Y Y Y Y
exchange Y Y Y Y
compare_exchange_weak, compare_exchange_strong Y Y Y Y
fetch_add, += Y Y
fetch_sub, -= Y Y
fetch_or, |= Y
fetch_and, &= Y
fetch_xor, ^= Y
++, -- Y Y

2.1、操作std::atomic_flag

std::atomic_flag是最简单的标准原子类型,表示一个布尔标志,它只有两种状态:成立或置零。std::atomic_flag对象必须由宏ATOMIC_FLAG_INIT初始化,它把标志初始化为置零状态,例如:std::atomic_flag f = ATOMIC_FLAG_INIT;。如果不进行初始化,则std::atomic_flag对象的状态是未指定的。std::atomic_flag有两个成员函数:

  • clear():将标志清零。
  • test_and_set():获取旧值并设置标志成立。

使用std::atomic_flag实现一个自旋锁的示例如下:

class spinlock_mutex
{
    std::atomic_flag flag;
public:
    spinlock_mutex() : flag(ATOMIC_FLAG_INIT) {}
    void lock()
    {
        while (flag.test_and_set());
    }
    void unlock()
    {
        flag.clear();
    }
};

2.2、操作std::atomic<bool>

相比于std::atomic_flagstd::atomic<bool>是一个功能更齐全的布尔标志。尽管它也无法拷贝构造或拷贝赋值,但还是能依据非原子布尔量创建其对象,也能接受非原子布尔量的赋值:

std::atomic<bool> b(true);
b = false;

store()是存储操作,可以向原子对象写入值。load()是载入操作,可以读取原子对象的值。exchange()是“读-改-写”操作,它获取原有的值,然后用自行选定的新值作为替换。

std::atomic<bool> b;
bool x = b.load();
b.store(true);
x = b.exchange(false);

compare_exchange_weak()compare_exchange_strong()被称为“比较-交换”操作,它们的作用是:使用者给定一个期望值,原子变量将它和自身的值进行比较,如果相等,就存入另一既定的值;否则,更新期望值所属的变量,向它赋予原子变量的值。“比较-交换”操作返回布尔类型,如果完成了保存动作(前提是两值相等),则返回true,否则返回false。对于compare_exchange_weak(),即使原子变量的值等于期望值,保存动作还是有可能失败,在这种情形下,原子变量维持原值不变,函数返回false。原子化的“比较-交换”必须由一条指令单独完成,而某些处理器没有这种指令,无从保证该操作按原子化方式完成。要实现“比较-交换”,负责的线程则须改为连续运行一系列指令,但在这些计算机上,只要出现线程数量多于处理器数量的情形,线程就有可能执行到中途因系统调度而切出,导致操作失败。这种败因不是变量值本身存在问题,而是函数执行时机不对,所以compare_exchange_weak()往往必须配合循环使用。

bool expected = false;
extern atomic<bool> b;
while(!b.compare_exchange_weak(expected,true) && !expected);

2.3、操作std::atomic<T*>

除了std::atomic<bool>所支持的操作外,std::atomic<T*>还支持算术形式的指针运算。fetch_add()fetch_sub()分别就对象中存储的地址进行原子化加减,然后返回原来的地址。另外,该原子类型还具有包装成重载运算符的+=-=,以及++--的前后缀版本,这些运算符作用在原子类型之上,效果与作用在内建类型上一样。

class Foo {};
Foo some_array[5];
std::atomic<Foo*> p(some_array);
Foo* x = p.fetch_add(2);
assert(x == some_array);
assert(p.load() == &some_array[2]);
x = (p -= 1);
assert(x == &some_array[1]);
assert(p.load() == &some_array[1]);

2.4、操作标准整数原子类型

std::atomic<int>这样的整数原子类型上,除了std::atomic<T*>所支持的操作外,还支持fetch_and()fetch_or()fetch_xor()操作,也支持对应的&=|=^=复合赋值形式。

2.5、泛化的std::atomic<>类模板

除了前文的标准原子类型,使用者还能利用泛化模板,依据自定义类型创建其它原子类型。然而,对于某个自定义的类型UDT,必须要满足一定条件才能具现化出std::atomic<UDT>

  • 必须具有平实拷贝赋值运算符(平直、简单的原始内存赋值及其等效操作)。若自定义类型具有基类或非静态数据成员,则它们同样必须具备平实拷贝赋值运算符。
  • 不得含有虚函数,也不可以从虚基类派生得出。
  • 必须由编译器代其隐式生成拷贝赋值运算符。

由于以上限制,赋值操作不涉及任何用户编写的代码,因此编译器可以借用memcpy()或采取与之等效的行为完成它。另外值得注意的是,“比较-交换”操作采取的是逐位比较运算,效果等同于直接使用memcmp()函数。

3、内存顺序

编译器优化代码时可能会进行指令重排,而且CPU执行指令时也可能会乱序执行,所以代码的执行顺序不一定和书写顺序一致。例如下面的代码可能会按照如表所示的顺序执行,从而引发断言错误。可以看出,指令重排在单线程环境下不会造成逻辑错误,但在多线程环境下可能会造成逻辑错误。

int a = 0;
bool flag = false;
void func1()
{
    a = 1;
    flag = true;
}
void func2()
{
    if (flag)
    {
        assert(a == 1);
    }
}
std::thread t1(func1);
std::thread t2(func2);
t1.join();
t2.join();
step 线程t1 线程t2
1 flag = true
2 if (flag)
3 assert(a == 1)
4 a = 1

内存顺序的作用,本质上是要限制单个线程中的指令顺序,从而解决多线程环境下可能出现的问题。原子类型上的操作服从6种内存顺序,在不同的CPU架构上,这几种内存模型也许会有不同的运行开销。

enum memory_order {
    memory_order_relaxed,
    memory_order_consume,
    memory_order_acquire,
    memory_order_release,
    memory_order_acq_rel,
    memory_order_seq_cst
};
  • memory_order_seq_cst

    这是所有原子操作的内存顺序参数的默认值,语义上要求底层提供顺序一致性模型,不存在任何重排,可以解决一切问题,但是效率最低。

  • memory_order_release / memory_order_acquire / memory_order_consume

    release操作可以阻止这个调用之前的读写操作被重排到后面去;acquire操作则可以保证这个调用之后的读写操作不会重排到前面去;consume操作比acquire操作宽松一些,它只保证这个调用之后的对原子变量有依赖的操作不会被重排到前面去。release与acquire/consume操作需要在同一个原子对象上配对使用,例如:

    std::atomic<int> a;
    std::atomic<bool> flag;
    void func1()
    {
        a = 1;
        flag.store(true, memory_order_release);
    }
    void func2()
    {
        if (flag.load(memory_order_acquire))
        {
            assert(a == 1);
        }
    }
    
  • memory_order_acq_rel

    兼具acquire和release的特性。

  • memory_order_relaxed

    只保证原子类型的成员函数操作本身是不可分割的,但是对于顺序性不做任何保证。

三类操作支持的内存顺序如下表所示:

存储(store)操作 载入(load)操作 “读-改-写”(read-modify-write)操作
memory_order_seq_cst Y Y Y
memory_order_release Y Y
memory_order_acquire Y Y
memory_order_consume Y Y
memory_order_acq_rel Y
memory_order_relaxed Y Y Y

标签:std,读书笔记,编程,C++,原子,flag,atomic,memory,order
From: https://www.cnblogs.com/moonzzz/p/17668404.html

相关文章

  • C++学习笔记
    C++:C的编译器叫gcc,C++的编译器叫g++c++最容易发生的问题是内存泄漏问题,例如释放p所指的空间时,应该是free(p);p=NULL;很多人忘记将p归零,这样下次不小心调用了p就会出现内存泄漏问题,如果要把释放内存写成函数,示例如下(两层指针)voidfree_mem(int**pp){if(pp==NULL......
  • C++中虚继承时的构造函数
    在虚继承中,虚基类是由最终的派生类初始化的,换句话说,最终派生类的构造函数必须要调用虚基类的构造函数。对最终的派生类来说,虚基类是间接基类,而不是直接基类。这跟普通继承不同,在普通继承中,派生类构造函数中只能调用直接基类的构造函数,不能调用间接基类的。下面我们以菱形继承为例来......
  • C++语言学习09
    STL标准模版库STL是StandardTemplateLibrary的缩写,中文名标准模版库,由惠普实验室提供(使用C++模板语言封装的常用的数据结构与算法)STL中有六大组件:算法:以函数模板形式实现的常用算法,例如:swap\max\min\find\sort容器:以类模板的形式实现的常用的数据结构,例如:vector\list\arra......
  • 1 C++基础问题总结
    C++基础1C和C++有什么区别?C++是面向对象,C面向过程C++引入new/delete运算符,取代了C中的malloc/free库函数;C++有引用的概念,C没有C++有类的概念,C没有C++有函数重载,C没有2a和&a有什么区别?比如inta[10];int(*p)[10]=&aa是数组名,是数组首元素地址,+1表示地址值加上一......
  • C++中模块(DLL)对外暴露接口的几种方式
    函数导出:通过在函数前面加上导出修饰符(如__declspec(dllexport))来导出函数。优点是简单易用,缺点是无法避免函数名冲突,且需要手动导出每个函数。.def文件:通过定义一个.def文件,在其中指定要导出的函数名和入口点。优点是可以一次性导出多个函数,缺点是需要额外的.def文件,且与代码分......
  • c++/c中关于头文件的探索
    //Fin.h#ifndefFIN_H#defineFIN_Hintadd(inta,intb);#endif//Fin.cpp#include"Fin.h"intadd(inta,intb){returna+b;}//Test1.cpp#include<iostream>#include"Fin.h"//包含Fin.h来调用函数intmain(){......
  • 《Java编程思想第四版》学习笔记23
    在Inning中,可以看到无论构建器还是event()方法都指出自己会“掷”出一个违例,但它们实际上没有那样做。这是合法的,因为它允许我们强迫用户捕获可能在覆盖过的event()版本里添加的任何违例。同样的道理也适用于abstract方法,就象在atBat()里展示的那样。“interfaceStorm”非......
  • c++11新特性
    这篇文章基本上涵盖了c++11的所有新特性,并有详细代码介绍其用法,对关键知识点做了深入分析,对重要的知识点我单独写了相关文章并附上了相关链接,我还准备了完整版c++新特性脑图,有需要可以去我的gz号回复“新特性”下载,见文章最后。auto&decltype关于C++11新特性,最先提到的肯定......
  • C++14新特性
    这篇文章介绍下C++14的新特性。 函数返回值类型推导 C++14对函数返回类型推导规则做了优化,先看一段代码: #include<iostream>usingnamespacestd;autofunc(inti){returni;}intmain(){cout<<func(4)<<endl;return0;} 使用C++11编......
  • C++学习笔记
    字符串的比较字符串比较一般不用关系运算符比较,“hello”=="hello"底层是存储地址的比较逻辑运算符&&||!&&短路与运算,如果第一个条件为真就判断下一个条件,为假结果为假;如果第一个为假,就直接返回假,不判断第二个条件||短路或运算:碰到条件为真就停止运算,返回真值!非逻辑运算符:条件......