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

发布时间 2023-09-05 21:59:33作者: MoonZZZ

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