原子类型和原子操作

667 阅读6分钟

原子类型与C++11原子操作

原子操作, 就是最小的且不可并行化的操作, 对一个共享资源的操作是原子操作, 意味着多个线程访问该资源时, 有且仅有唯一的资源对该资源进行操作

原子操作是通过互斥的访问来保证的, 如果只是实现粗粒度的互斥, POSIX标准的pthread库中得互斥锁就可以做到

C++11对原子操作概念的抽象遵从了面向对象思想 , C++11标准定义的都是所谓的原子类型, 原子操作则抽象为针对这些原子类型的操作(原子类型的成员函数)

在C++11中, 原子类型只能从其模板参数类型中进行构造, 标准不允许原子类型执行拷贝构造, 移动构造和赋值运算

// 允许
std::atomic<float> a(1.0f);
// 无法通过编译  
std::atomic<float> b(a);

atomic模板类的拷贝构造, 移动构造和赋值运算符默认是删除的
从atomic类型的变量构造其模板类型T的变量是可以的, 编译器隐式的完成原子类型到其对应类型的转换:

std::atomic<float> a(1.0f);
float b = a;

C++11中, 标准将原子操作定义为atmoic模板类的成员函数, 对于内置类型, 主要通过重载一些内置运算符完成

大多数原子类型都可以执行读(load), 写(store), 交换(exchange), 比较交换(compare_and_exchange_weak/compare_and_exchange_strong)等操作

std::atomic<int > a(1);
// 相当于执行 int b = a.load();
int b = a;

std::atomic<int> a;
// 相当于执行a.store(1);
a = 1;

指针和整型还定义了算数运算符和逻辑运算符

比较特殊的布尔型的atomic类型, std::atomic_flag, 相比于其他类型, atomic_flag是无锁(lock free)的, 线程对其访问不需要加锁, 因此也不需要load和store函数对其读写, 通过其test_and_set和clear成员函数可以实现一个自旋锁(spin lock)

test_and_set写入新值true并放回旧址false
clear将值设为false
ATOMIC_FLAG_INIT 将atomic_flag变量设为false

内存模型, 顺序一致性与memory_order

线程间进行数据同步原子类型已经提供了一些同步的保障, 不过这建立在所谓的顺序一致性的内存模型的假设上

通常情况下编译器甚至是处理器认为语句的执行顺序对输出结果没有任何影响, 则可以依情况进行指令重排以提高性能

原子变量在线程中总是保证顺序执行的特性(非原子类型不需要, 因为不需要线程间同步)称为顺序一致性, 顺序一致性意味着最低效的同步方式

多线程情况下是共享代码的, 强顺序意味着多个线程其看到的指令执行顺序是一致的, 对于共享内存的处理器而言, 内存中数据被改变的顺序和机器指令中得一致, 而如果多个线程看到的内存数据被改变的顺序和机器指令不一致则是弱顺序的

x86平台被看作是采用强顺序内存模型的平台, 对于任何一个线程而言, 其看到的原子操作(数据的读写)都是顺序的

采用弱顺序的平台(PowerPC, ArmV7), 要保证指令的执行顺序, 需要在汇编指令中加入一条内存栅栏(memory barrier)指令, 比如PowerPC平台, 有一条名为sync的内存栅栏指令, 迫使已进入流水线的指令都执行完后才能执行sync指令以后的指令

弱顺序的内存模型可以进一步发掘指令中得并行性, 指令执行性能更高

为什么只关心读写操作的执行顺序
处理器总是从内存中读取数据进行运算, 再将运行结果返回内存, 内存中得数据是准绳, 寄存器的内容则是临时量, 多核心处理器上, 核心有全套的寄存器存储临时量, 寄存器中得运算不会被多处理器关注, 处理器只关心读写等原子操作指令的顺序

要保证顺序的一致性要做到下面几点:

  1. 编译器保证原子操作单指令间顺序不变, 产生的读写原子变量的机器指令与代码编写者看到的是一致的
  2. 处理器对原子操作的指令执行顺序不变, 对于X86这样的强顺序体系结构而言没有任何问题, 而对于PowerPC这样的弱顺序体系结构, 则要求编译器在每次原子操作后加入内存栅栏

C++11可以为原子操作指定内存顺序
C++11 memory order枚举值:

// 不对执行顺序做任何保证
memory_order_relaxed    
// 本线程中, 所有后续的读操作必须在本条原子操作完成后执行
memory_order_acquire
// 本线程中, 所有之前的写操作完成后才能执行本条原子操作  
memory_order_release
// 同时包含acquire和release标记
memory_order_acq_rel
// 本线程中, 所有后续有关本原子类型的操作, 必须在本条原子操作完成后执行
memory_order_consume
// 全部存取都按顺序执行
memory_order_seq_cst

memory_order_seq_cst 表示顺序必须是一致的, 不带memory_order参数的原子操作默认是这个类型
memory_order_relaxed 表示原子操作是松散的, 可以被任意重排

并非每种memory_order都可以被atomic成员使用, 我们将atomic成员函数可以使用的memory_order分为以下3组:

  1. 原子存储操作(store) 可以使用memory_order_relaxed, memory_order_release, memory_order_seq_cst
  2. 原子读取操作(load) 可以使用 memory_order_relaxed, memory_order_consume, memory_order_acquire, memory_order_seq_cst
  3. RMW操作(read - modify - write) 同时读写的操作 可以使用所有的内存序

'operator =' 'operator +=' 都是memory_order_seq_cst作为memory_order参数的原子操作的简单封装, 如果要指定内存顺序, 应采用laod, fetch_add这样的版本

memory_order_relese和memory_order_acquire常结合使用, 这种内存顺序称为release-acquire内存顺序
memory_order_release和memory_order_consume的配合会建立原子类型的生产者-消费者同步顺序, 这种内存顺序称为release-consume内存顺序

顺序一致性, 松散, release-acquire, release-consume是最为典型的4种内存顺序, 其它的诸如memory_order_acq_rel则是用于实现CAS的基本同步元语, 对应到atomic原子操作compare_exchange_strong成员函数

线程局部存储

线程局部存储实际上是全局/静态变量应用到多线程程序中被线程共享而来, 线程拥有自己的栈空间, 但是堆空间和静态数据区(从可执行文件角度, 静态数据区对应可执行文件的data, bss段的数据, C/C++层面则是全局/静态变量)则是共享的

在全局或者静态变量的声明前加入关键字:

// g++/clang/xlc++的语法
__thread int errCode;
// c++11的语法
int thread_local errCode;

即可将变量声明为TLS变量, 每个线程将拥有独立的errCode拷贝, 线程对errCode的修改不会影响另外的线程中的errCode
其值在线程开始时被初始化, 线程结束时该值也不再有效, 对该值取地址获取的是当前线程的TLS变量地址值