深圳幻海软件技术有限公司 欢迎您!

关于多线程的一切:原子操作

2023-02-28

接上篇《​​关于多线程同步的一切:伪共享​​》原子,意味着不可切分的最小单元,程序中的原子操作指任务不可切分到更小的步骤。原子性(atomic)是一个可见性的概念:当我们称一个操作是atomic的,实际上隐含了一个对什么atomic的上下文。注意:我们说的是从线程视角观察不到完成一半的状态,而并非不

接上篇《​​关于多线程同步的一切:伪共享​​》

原子,意味着不可切分的最小单元,程序中的原子操作指任务不可切分到更小的步骤。

原子性(atomic)是一个可见性的概念:

当我们称一个操作是atomic的,实际上隐含了一个对什么atomic的上下文。

注意:我们说的是从线程视角观察不到完成一半的状态,而并非不存在物理上的进度状态,它取决于你的观察视角。

比如说一个线程中被互斥锁保护的区域,对另一个线程是atomic的,因为从另一个线程视角来看,它没法进入临界区读到数据中间状态,但是对kernel而言却不是atomic的。

从线程视角只能观察到未做和已做两种状态,观察不到完成一半的状态,任务执行不会被中断,也不会穿插进其他操作。

原子性对多线程操作是一个非常重要的属性,因为它不可切分,所以,一个线程没法在另一个线程执行原子操作的时候穿插进去。

比如一个线程原子的写入共享数据,那么其他线程没有办法读到“半修改的数据”;同样,如果一个线程原子读取共享数据,那么它读取的是共享变量在那个瞬间的值,因此原子的读和写没有数据竞争(Data Race)。

原子操作常用于与顺序无关的场景。

原子指令

原子指令指单一的不可再分的不可中断的被硬件直接执行的机器指令,原子指令是无锁编程的基石。

原子指令常被分成两类:

  • store/load
  • read-modify-write(RMW)

Store/Load指令

  • store:存储数据到内存,对应变量写(赋值)
  • load:从内存加载数据,对应变量读

通常,一条简单的store/load机器指令是原子的,比如数据复制指令(mov)可以把内存位置的数据读取到CPU寄存器,相当于Load数据。

x86架构读/写“按数据类型对齐要求对齐的长度不大于机器字长的数据”是原子的。

那什么是数据类型对齐要求呢?

比如在x86_64架构LLP64系统上(LLP64指long、long long和pointer类型是64位的),只要int32类型数据满足放置在起始地址除4为0,int64/long类型数据满足起始地址除8为0,则该数据就是满足类型对齐要求,那么对它的读和写,都是原子的。

一字节的数据读写一定是原子的。

其实,Intel新CPU架构确保读写放置在一个Cache Line的数据(不大于机器字长),跨Cache Line的数据访问无法保证原子性。

C/C++编程中,变量和结构体会自动满足对齐要求,比如:

int i;

void f() {
   long y;
}

struct Foo {
   int x;
   short s;
   void* ptr;
} foo;
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.

全局变量i会被放置在起始地址可以被4整除的内存位置,局部变量y会被放置在起始地址可以被8整除的内存位置,而结构体内的x成员会被放置在起始地址可以被4整除的内存位置。

为了把ptr安置在起始地址可以被8整除的内存位置,编译器会在s后加入填充,从而使得ptr也满足对齐要求。

通过C malloc()接口动态分配的内存,其返回值一般也会对齐到8/16字节,如果有更高的内存对齐要求,可以通过aligned_alloc(alignment, size)接口。C++中的alignas关键字用于设置结构或变量的对齐要求。

对一个满足对齐要求的不大于机器字长的类型变量赋值是原子的,不会出现半完成(即只完成一半字节的赋值),读的情况亦如此。

注意:对长度大于机器字长的数据读写,是不符合原子操作特征的,比如在x86_64系统上,对下面结构体变量的读写都是非原子的:

struct Foo {
   int a;
   int b;
   int c;
} foo1;

void set_foo1(const Foo& f) {
   foo1 = f;
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.

foo1包含3个int成员共12字节,大于机器字长8字节,所以对`foo1 = f`不是原子的。

基于以上知识,我们便知道,一些getter/setter接口,即使在多线程环境下,也可以不用加锁,比如:

struct Foo {
  size_t get_x() const { // OK
     return x;
  }
  
  void set_y(float y) { // OK
     this->y = y;
  }
  
  size_t x;
  float y;
};

int main() {
   char buf[8];
   Foo* f = (Foo*)buf;
   f->set(3.14); // dang
  }
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.
  • 18.

但是,如果你把一块buf,强转成Foo,然后调用它的getter/setter,则是危险的,有可能破坏前述的对齐要求。

如果你把一个int变量编码进一个buf,则最好使用memcpy,而不是强转+赋值。

Read-Modify-Write指令

但有时候,我们需要更复杂的操作指令,而不仅仅是单独的读或写,它需要把几个动作组合在一起完成某项任务。

比如语句`++count`对应到“读+修改+写”三个操作,但这3个操作不是一个原子操作。所以,多线程程序中使用`++count`,多个执行流会交错执行,会导致计数错误(通常结果比预期数值小)。

考虑另一个情况:读+判断,来我们看一下经典单件实现:

class Singleton {
   static Singleton* instance;
public:
   static Singleton* get_instance() {
       if (instance == nullptr) {
           instance = new Singleton;
       }
       return instance;
   }
};
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.

因为对instance的判断和`instance = new Singleton`不是原子的,所以,我们需要加锁:


class Singleton {
   static Singleton* instance;
   static std::mutex mutex;
public:
   static Singleton* get_instance() {
       mutex.lock();
       if (instance == nullptr)
           instance = new Singleton;
       mutex.unlock();
       return instance;
   }
};
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.

但为了性能,更好的方案是加双检,代码变成下面这样:

static Singleton* get_instance() {
   if (instance == nullptr) {
       mutex.lock();
       if (instance == nullptr) { // 双检
           instance = new Singleton;
       }
       mutex.unlock();
       return instance;
   }
   return instance;
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.

第一个检查,如果instance不为空,那么直接返回instance,大多数时候命中这个情况,因为instance一旦被创建,就不再为空。

如果instance为空,那么再加锁、然后第二次检查instance是否为空,为什么要双检呢?因为前面的检查通过后,有可能其他线程创建了实例,导致instance不再为空。

看起来一切都挺好的,高效又缜密。

但双检真的安全吗?这其实是一个非常经典的问题。它有2个风险:

  • 首先,编写者没有告诉编译器,必须假设instance可能被其他线程修改,所以,编译器可能认为2个if保留一个就可以了,当然,它也可能不做这个优化,取决于编译器的策略,因此,instance必须改为volatile,告诉编译器,两次读都必须从内存里加载,避免双检被优化掉。
  • 就是前面讲的原子性,instance指针不能保证一定在8字节对齐的地方保存,所以,需要用std::atomic<Singleton*>代替。

逻辑上,需要几个操作是一个密不可分的整体,现代CPU通常都直接提供这类原子指令的支持,这类RMW原子指令通常包括:

  • test-and-set(TAS),把1写入某个内存位置并返回旧值;如果原来内存位置是1,则返回1,否则原子的写入1并返回0;只能标识0和1两种情况
  • fetch_and_add,增加某个内存位置的值,并返回旧值;可用来做atomic的后自增
  • compare-and-swap(CAS),比较内存位置的值和指定的值,如果相等,则将新值写入内存位置,如果不等,什么也不做;比tas更强

以上所有操作都是在一个内存位置执行多个动作,但这些操作都是原子单步的,它不会被中断,也不会穿插进其他操作,这个重要属性使得RMW指令非常适合用来实现无锁编程。

虽然CPU在执行机器指令的时候,会把它分成更小粒度的微指令(micro-operations),但程序员应把关注点放在微指令上层的原子指令上。

原子操作

前面讲的原子指令是硬件层面,不同架构甚至不同型号CPU有不同的原子指令,它是CPU层面的东西,跨平台特性差,用它编写的代码不可移植,所以应该尽量避免直接使用原子指令。

回到软件层面,软件层面的原子操作包括三个层次:

(1) 操作系统层面,linux操作系统提供atomic这种原子类型,配合相关的编程接口使用,大多数它只是对原子指令的简单封装,但它屏蔽了硬件差异,比原子指令更易用​:

   atomic_read(atomic_t *v)
   atomic_set(atomic_t *v, int i)
   atomic_inc(atomic_t *v)
   atomic_dec(atomic_t *v)
   atomic_add(int i, atomic_t *v)
   atomic_sub(int i, atomic_t *v)
   atomic_inc_and_test(atomic_t *v)
   atomic_dec_and_test(atomic_t *v);
   atomic_sub_and_test(int i, atomic_t *v)
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.

(2) 编译器层面,gcc提供原子操作build-in函数,使用gcc编译c/c++代码,可以直接使用它们​:

   //其中type对应8/16/32/64位整数
   type __sync_fetch_and_add (type *ptr, type value, ...)
   type __sync_fetch_and_sub (type *ptr, type value, ...)
   type __sync_fetch_and_or (type *ptr, type value, ...)
   type __sync_fetch_and_and (type *ptr, type value, ...)
   type __sync_fetch_and_xor (type *ptr, type value, ...)
   type __sync_fetch_and_nand (type *ptr, type value, ...)
   type __sync_add_and_fetch (type *ptr, type value, ...)
   type __sync_sub_and_fetch (type *ptr, type value, ...)
   type __sync_or_and_fetch (type *ptr, type value, ...)
   type __sync_and_and_fetch (type *ptr, type value, ...)
   type __sync_xor_and_fetch (type *ptr, type value, ...)
   type __sync_nand_and_fetch (type *ptr, type value, ...)
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.

gcc在实现C++11之后,新的原子接口,以__atomic为前缀,推荐使用下面这些接口:

   type __atomic_add_fetch(type *ptr, type val, int memorder)
   type __atomic_sub_fetch(type *ptr, type val, int memorder)
   type __atomic_and_fetch(type *ptr, type val, int memorder)
   type __atomic_xor_fetch(type *ptr, type val, int memorder)
   type __atomic_or_fetch(type *ptr, type val, int memorder)
   type __atomic_nand_fetch(type *ptr, type val, int memorder)
   type __atomic_fetch_add(type *ptr, type val, int memorder)
   type __atomic_fetch_sub(type *ptr, type val, int memorder)
   type __atomic_fetch_and(type *ptr, type val, int memorder)
   type __atomic_fetch_xor(type *ptr, type val, int memorder)
   type __atomic_fetch_or(type *ptr, type val, int memorder)
   type __atomic_fetch_nand(type *ptr, type val, int memorder)
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.

(3) 编程语言层面,也通常提供原子操作类型和接口,这也是使用原子操作的推荐方式,它有良好的跨平台性和可移植性,程序员应优先使用它们:

  • C11新增了原子操作库,通过stdatomic.h头文件提供atomic_fetch_add/atomic_compare_exchange_weak等接口。
  • C++11也新增了原子操作库,提供一种类型为std::atomic<T>的类模板,它提供++/--/+=/-=/fetch_sub/fetch_add等原子操作接口。
  • 原子操作常用于与顺序无关的场景,比如计数错误的场景,用原子变量改写后,则会输出符合预期的值。
  • 原子操作是编写Lock-free多线程程序的基础,原子操作只保证原子性,不保证操作顺序。
  • 在Lock-free多线程程序中,光有原子操作是不够的,需要将原子操作和Memory Barrier结合起来,才能实现免锁。