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,总能保证按顺序执行,遵从数据依赖顺序。
 
所以标准库里面提供了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做上下文切换操作
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章