最近试图在gem5里做一个数据导出的工作,即在运行过程中记录每次内存访问的时间、位置和数据。
本来是打算当一个单线程来写的,但是考虑到本来跑一个程序用时就长,把关键路径做得尽可能短对工作效率比较友好,就开始考虑把io相关的工作单独提取到一个新线程完成,结果不知不觉地把这个问题演变成一个多线程问题了。
由于涉及到并发编程,原子操作和锁是绕不开的两个概念,一轻一重两大门神守护着多线程环境下的数据安全性,而两者在C++下分别对应有最为常用的头文件,<atomic>
和<mutex>
。由于锁的基本使用较为简单,此文主要记录原子类型变量的基本使用方法。
原子类型所要完成的任务是,确保对该类型的指定操作以原子方式完成。至于为什么一开始不把类型设计成原子操作的,这里涉及硬件设计与软件语义之间的抽象层次差异,换句话说,以硬件成本来看,不值得。数据类型难以有个规范,要是为每种数据类型单独设计一条指令,那光是原子操作指令都能上天。VCISC,超复杂指令集的奠基者
于是乎,现有计算机系统采用了折中方案,即硬件提供一些最基本的指令,再使用一些软件手段,以时间/空间为代价,来拓宽原子操作特性的可用范围。以此为契机,出现了<atomic>
这种向用户提供软(也可能是硬)原子操作的语言基础库。
原子操作最常见的用法是使用类模板定义对应类型的变量:
std::atomic<int> atomic_int{0}; // use (0) instead of {0} is okay
上面的代码定义了一个原子操作的int
类型变量,并且赋初值为0。注意这里用的是括号初始化,而不是等号赋值。这是因为在定义变量时,operator =
并不是将值赋予该变量,而是执行对象复制操作,而翻阅文档,有如下定义:
atomic& operator=( const atomic& ) = delete;
也就是说,语言禁止了atomic对象的复制,只能通过值初始化的方式在变量声明时设置初始值。
介绍完初始化操作之后,就是读写操作了。std::atomic
提供了两种对原子变量的读写方式,一是像普通变量一样正常使用,二是load
/store
接口:
std::atomic<int> val{0};
int tmp = val.load(); // equivalent to `int tmp = val;`
val.store(1); // equivalent to `val = 1;`
虽然说像普通变量一样写很方便,但是真到读代码的时候原子变量和普通变量混在一起还是挺麻烦的。个人还是觉得用显式的load
/store
比较好。
然后就是大名鼎鼎的复杂原子操作了。复杂原子操作具有共同的特征:RMW(Read-Modify-Write,读-改-写),而CAS(Compare-And-Swap,比较并交换)则是其中最为基础的RMW操作,所有的复杂RMW操作都可以基于CAS操作完成。对于CAS,<atomic>
头文件提供了两种相似的实现方案,compare_exchange_weak
和compare_exchange_strong
。cplusplus对于这两种方法的区别介绍的非常隐晦,只是说strong方法用起来符合直觉,运行结果是标准的CAS;而weak方法用起来性能更好,但是可能出现假阴性的情况(即使compare结果是允许swap的,但还是判定为不允许,走失败逻辑)。
先是compare_exchange_strong
的使用样例。
std::atomic<int> val{0};
int prev = val.load();
val.compare_exchange_strong(prev, newval);
这里取原始值为prev
,以该值作为CAS的对比值,如果执行compare_exchange_strong
的时候读取到的val
仍然是该值的话,则执行CAS操作,将val
值设置为newval
,并给出返回值true
;否则不执行CAS操作,反而将prev
值设置为最新读取到的val
值,并返回false
。
其次是compare_exchange_weak
。由于这里可能出现假阴性的逻辑错误,需要额外对代码做一点修改,来保证代码仍然能完成正确的逻辑。
std::atomic<int> val{0};
int prev = val.load();
while(!val.compare_exchange_weak(prev, newval));
可以看到weak的用法与strong略有差别,这里多套了一层while
循环。如前面所说,使用while
的目的是对付compare_exchange_weak
中即使满足CAS条件,但仍然执行失败流程的情况。这种方式并不适用于顺序敏感型的应用,比如乘加操作,不同线程的操作顺序会导致产生截然不同的输出结果;但是对只需要将数据加入指定结构而不要求顺序的类链表操作的话,这个方法还是具有更高的效率的(因为有神秘的性能优化)。
对于部分常见基础类型,std::atomic
预先提供了常见运算方式的原子操作实现,比如原子加fetch_add
、原子减fetch_sub
,对于char
/int
及其各种衍生基础类,还提供有原子位或fetch_or
/位与fetch_or
/位异或fetch_xor
等原子逻辑操作的实现。