atomic原子编程
一、背景
- 背景:
在多核编程中,我们使用内核对象【如:事件对象(Event)、互斥量对象(Mutex,或互斥体对象)、信号量对象(Semaphore)等】来避免多个线程修改同一个数据时产生的竞争条件。基于内核对象的同步,会带来昂贵的上下文切换(用户态切换到内核态,占用1000个以上的cpu周期)。
如何在多核的场景下,对共享对象进行同步,又能避免进行上下文切换,就需要使用另一种方法 —— 原子指令。
- 技术定位:需要要一定的编程基础知识
- 目标群体:研发工程师
- 技术应用场景:多线程/进程
- 开发语言:C++
缩略语
CAS: Compare And Swap
TAS: Test And Swap
二、简述
支持std::atomic条件:
- C++ 11标准
- CPU支持这种指令操作。
仅靠原子技术实现不了对资源的访问控制,即使简单计数操作,看上去正确的代码也可能会crash。这里的关键在于编译器和cpu实施的重排指令导致了读写顺序的变化。只要没有依赖,代码中在后面的指令就可能跑到前面去,编译器和CPU都会这么做。PowerPC和ARM等弱排序cpu会进行指令重排(依赖内存栅栏指令);而Intel x86, x86-64强排序cpu,总能保证按顺序执行,遵从数据依赖顺序。
为了避免“伪共享(false sharing),提高性能;在多线程环境下可以使用attribute ((aligned(64)))来保证,这是 GCC 的编译器扩展语法,表示将该变量按照 64 字节对齐,这样就避免多个变量存储在一个缓存行(cache line)。例如std::atomic<int64_t> cursor __attribute__ ((aligned (64))) ;
所以标准库里面提供了memory order,让工程师可以在排序与性能上做好取舍。
通常对于atomic的变量大多数情况下,主要使用了bool和uint64_t, 一个是递增,一个是状态管理。甚至使用一个uint64_t就可以解决大多数共享数据同步的问题。
注1:单线程代码不需要关心乱序的问题。因为乱序至少要保证这一原则:不能改变单线程程序的执行行为
注2:内核对象多线程编程在设计的时候都阻止了它们调用点中的乱序(已经隐式包含memory barrier),不需要考虑乱序的问题。
注3:使用用户模式下的线程同步时,乱序的效果才会显露无疑。
2.1 Member function
atomic的成员函数,还有其他一些函数,这里列举了常用的一些API
原子指令(x为std::atomic类型) | 说明 |
x.load() | 读操作返回x的值 |
x.store(n) | 写操作把x设为n,什么都不返回 |
x.exchange(n) | 把x设为n,返回设定之前的值 |
x.fetch_add(n) | 原子地做x += n,返回修改之前的值 |
x.fetch_sub(n) | 原子地做x-= n,返回修改之前的值 |
x.compare_exchange_strong(expect, desired) | 原子地进行比较做替换操作,成功返回true,失败返回false |
x.compare_exchange_weak(expect, desired) | 原子地进行比较做替换操作,成功返回true,失败返回false |
strong与weak的区别:
- strong版本当expect与x相等时,肯定返回true。
- weak 版本当expect与x相等时,存在返回false的情况。
- x86平台两个版本都一样,没有差异。
- 存在差异的平台下,weak的性能优于strong。
- 为了跨平台性以及高性能,使用weak版本是个好的办法。
- 如果不清楚平台以及流程,使用strong版本是一个比较维稳的办法。
typedef struct NODE{ T data; NODE *next; }; std::atomic <NODE *> head; void push(T const& data) { NODE *new_node = new NODE; new_node->data = data; new_node->next = head.load(); /* head.store(new_node); */ while (!head.compare_exchange_weak(new_node->next, new_node)); }
2.2 Memory order
atomic提供了6种memory order,来在编程语言层面对编译器和cpu实施的重排指令行为进行控制
memory order | 作用 |
memory_order_relaxed | 无fencing作用,cpu和编译器可以重排指令 |
memory_order_consume | 后面依赖此原子变量的访存指令勿重排至此条指令之前注:性能比memory_order_acquire高 |
memory_order_acquire | 后面访存指令勿重排至此条指令之前 |
memory_order_release | 前面访存指令勿重排到此条指令之后 |
memory_order_acq_rel | acquare + release |
memory_order_seq_cst | acq_rel + 所有使用seq_cst的指令有严格的全序关系 |
默认情况下,std::atomic使用的是memory_order_seq_cst,即Sequentially-consistent ordering(最严格的同步模型)。在某些场景下,合理使用其它3种ordering,可以让编译器优化生成的代码,从而提高性能。
2.3 sample code
- Relaxed ordering
在这种模型下,std::atomic的load()和store()都要带上memory_order_relaxed参数。Relaxed ordering仅仅保证load()和store()是原子操作,除此之外,不提供任何跨线程的同步。
#include <cassert> #include <vector> #include <iostream> #include <thread> #include <atomic> std::atomic<int> cnt = {0}; void f() { for (int n = 0; n < 1000; ++n) { cnt.fetch_add(1, std::memory_order_relaxed); } } int main() { std::vector<std::thread> v; for (int n = 0; n < 10; ++n) { v.emplace_back(f); } for (auto& t : v) { t.join(); } assert(cnt == 10000); return 0; }
执行完上面的程序,可能出现r1 == r2 == 42。理解这一点并不难,因为编译器允许调整 C 和 D 的执行顺序。
如果程序的执行顺序是 D -> A -> B -> C,那么就会出现r1 == r2 == 42。
- Release-Acquire ordering
在这种模型下,store()使用memory_order_release,而load()使用memory_order_acquire。这种模型有两种效果,第一种是可以限制 CPU 指令的重排:
(1)在store()之前的所有读写操作,不允许被移动到这个store()的后面。 // write-release语义
(2)在load()之后的所有读写操作,不允许被移动到这个load()的前面。 // read-acquire语义
该模型可以保证:如果Thread-1的store()的那个值,成功被 Thread-2的load()到了,那么 Thread-1在store()之前对内存的所有写入操作,此时对 Thread-2 来说,都是可见的。
下面的例子阐述了这种模型的原理:
#include <thread> #include <atomic> #include <cassert> #include <string> std::atomic<bool> ready{ false }; int data = 0; void producer() { data = 100; // A ready.store(true, std::memory_order_release); // B } void consumer() { while (!ready.load(std::memory_order_acquire)); // C assert(data == 100); // never failed // D } int main() { std::thread t1(producer); std::thread t2(consumer); t1.join(); t2.join(); return 0; }
- Spinlock
① 对std::atomic_flag的操作具有原子性,保证了同一时间,只有一个线程能够lock成功,其余线程全部在while循环
② 使用了acquire内存屏障, 所以lock具有获取语义
③ 使用了release内存屏障, 所以unlock具有释放语义
#include <atomic> class simple_spin_lock { public: simple_spin_lock() = default; void lock() { while (flag.test_and_set(std::memory_order_acquire)) continue; } void unlock() { flag.clear(std::memory_order_release); } private: simple_spin_lock(const simple_spin_lock&) = delete; simple_spin_lock& operator =(const simple_spin_lock&) = delete; std::atomic_flag flag = ATOMIC_FLAG_INIT; };
三、总结
在多线程或进程中编程中
- 使用atomic可以减少对互斥量的使用,
- 使用atomic变量还可以避免CPU做上下文切换操作