无锁(Lock-Free)编程简介及漫谈

一、引言

现代计算机,即使很小的智能机亦或者平板电脑,都是一个多核(多CPU)处理设备,如何充分利用多核CPU资源,以达到单机性能的极大化成为软件开发的痛点和难点。在多核服务器中,采用多进程或多线程来并行处理任务,俨然成为了大家性能调优的标准解决方案。多进程(多线程)的并行编程方式,必然要面对共享数据的访问问题,如何并发、高效、安全地访问共享数据资源,成为并行编程的一个重点和难点。


传统的共享数据访问方式是采用同步原语(临界区、锁、条件变量等)来达到共享数据的安全访问,然而,同步恰恰和并行编程是对立的,很容易成为并行程序中的瓶颈。一方面,有些同步原语是操作系统的内核对象,调用该原语会带来昂贵的上下文切换(用户态切换到内核态)代价,同时,内核对象是一个比较有限的资源。另一方面,同步杜绝了并行操作,一个线程在访问共享数据的时候,其他的多个线程必须在排队空闲等待,同时,同步可扩展性很弱,随着并行线程的增加,很容易成为程序的一个瓶颈,甚至出现,服务性能吞吐量并没随CPU核数增加或并发线程的增加呈现线性增长,相反出现下降的情况。

在使用多线程进行编程时,我们应该尽可能遵从如下约定俗成的惯例:

1、对于CPU开销大的场景,能利用多核,就尽量利用多核(常常自以为某需求的运算量不大,且CPU足够快,就偷懒写个单线程,结果效率很低)

2、使用多线程的时候,默认是加锁的。在加锁保证业务正常的条件下,再考虑优化互斥锁带来的性能损耗。  互斥锁 < 读写锁 < 自旋锁 < 无锁(原子操作)

3 减少线程之间的相关性。线程间共享变量 < 线程内变量 < 函数式编程(没有变量)

4 尽量减少锁的粒度。 a. 减少加锁的代码段(减少加锁的时间)       b. 分成多个锁,减少竞争(使用细粒度的锁,如MyISAM和InnoDB)

而常规基于锁机制的线程同步机制常常会涉及到如下这些编程痛点:

1. 因为忘记使用锁而导致条件竞争(race condition)
2. 因为不正确的加锁顺序而导致死锁(deadlock)
3. 因为未被捕捉的异常而造成程序崩溃(corruption)
4. 因为错误地忽略了通知,造成线程无法正常唤醒(lost wakeup)


于是,人们开始研究对共享数据进行并发访问的数据结构和算法,通常有以下几方面:

1. Transactional memory --- 事务性内存
2. Fine-grained algorithms --- 细粒度(锁)算法
3. Lock-free data structures --- 无锁数据结构

(1) 事务内存(Transactional memory)TM是一个软件技术,简化了并发程序的编写。 TM借鉴了在数据库社区中首先建立和发展起来的概念, 基本的想法是要申明一个代码区域作为一个事务。一个事务(transaction ) 执行并原子地提交所有结果到内存(如果事务成功),或中止并取消所有的结果(如果事务失败)。但是,目前还没有嵌入式的事务内存,比较难和传统代码集成,需要软件做出比较大的变化,同时,软件TM性能开销极大,2-10倍的速度下降是常见的,这也限制了软件TM的广泛使用。

(2) 细粒度(锁)算法是一种基于另类的同步方法的算法,它通常基于“轻量级的”原子性原语(比如自旋锁),而不是基于系统提供的昂贵消耗的同步原语。细粒度(锁)算法适用于任何锁持有时间少于将一个线程阻塞和唤醒所需要的时间的场合,由于锁粒度极小,在此类原语之上构建的数据结构,可以并行读取,甚至并发写入。Linux 4.4以前的内核就是采用_spin_lock自旋锁这种细粒度锁算法来安全访问共享的listen socket,在并发连接相对轻量的情况下,其性能和无锁性能相媲美。然而,在高并发连接的场景下,细粒度(锁)算法就会成为并发程序的瓶颈所在。

(3) 无锁数据结构,为解决在高并发场景下,细粒度锁无法避免的性能瓶颈,将共享数据放入无锁的数据结构中,采用原子修改的方式来访问共享数据。
目前,常见的无锁数据结构主要有:无锁队列(lock free queue)、无锁容器(b+tree、list、hashmap等)。

二  无锁编程

针对无锁编程我们主要会涉及到如下几个方面:

1、原子性、原子性原语、原子操作

2、CAS解法与ABA问题

3、seqlock(顺序锁)

4、内存屏障(Memory Barriers)

2.1  原子性、原子性原语、原子操作

我们知道无论是何种情况,只要有共享的地方,就离不开同步,也就是concurrency。对共享资源的安全访问,在不使用锁、同步原语的情况下,只能依赖于硬件支持的原子性操作,离开原子操作的保证,无锁编程(lock-free programming)将变得不可能。

原子性操作可以简单划分为两部分:

1. 原子性读写(atomic read and write):原子load(读)、原子store(写)。
2. 原子性交换(Atomic Read-Modify-Write -- RMW)。

什么是原子操作

原子操作可以保证指令以原子的方式执行——执行过程不被打断,原子操作是多数无锁编程的基本前提。

原子操作要保证要么全部发生,要么全部没发生,这样原子操作绝对不是一个廉价的消耗低的指令,相反,原子操作是一个较为昂贵的指令。那么在无锁编程中,我们要避免滥用原子操作,那么什么情况下,我们需要对共享变量的操作采用原子操作呢?对变量的普通的读取赋值操作是原子的吗?
通常情况下,我们有一个对共享变量必须使用原子操作的规则:

任何时刻,只要存在两个或多个线程并发地对同一个共享变量进行操作,并且这些操作中的其中一个是执行了写操作,那么所有的线程都必须使用原子操作。

原子操作基本原理

在x86平台上,CPU提供了在指令执行期间对总线加锁的手段。CPU芯片上有一条引线#HLOCK pin,如果汇编语言的程序中在一条指令前面加上前缀"LOCK",经过汇编以后的机器代码就使CPU在执行这条指令的时候把#HLOCK pin的电位拉低,持续到这条指令结束时放开,从而把总线锁住,这样同一总线上别的CPU就暂时不能通过总线访问内存了,保证了这条指令在多处理器环境中的原子性。

LOCK是一个指令的描述符,表示后续的指令在执行的时候,在内存总线上加锁。总线锁会导致其他几个核在一定时钟周期内无法访问内存。虽然总线锁会影响其他核的性能,但比起操作系统级别的锁,已经轻量太多了。

#lock是锁FSB(前端串行总线,front serial bus),FSB是处理器和RAM之间的总线,锁住了它,就能阻止其他处理器或core从RAM获取数据。

操作系统内核提供atomic_*系列原子操作

声明和定义:

void atomic_set(atomic_t *v, int i);
atomic_t v = ATOMIC_INIT(0);

读写操作:

int atomic_read(atomic_t *v);
void atomic_add(int i, atomic_t *v);
void atomic_sub(int i, atomic_t *v);

加一减一:

void atomic_inc(atomic_t *v);
void atomic_dec(atomic_t *v);

执行操作并且测试结果:执行操作之后,如果v是0,那么返回1,否则返回0

int atomic_inc_and_test(atomic_t *v);
int atomic_dec_and_test(atomic_t *v);
int atomic_sub_and_test(int i, atomic_t *v);
int atomic_add_negative(int i, atomic_t *v);
int atomic_add_return(int i, atomic_t *v);
int atomic_sub_return(int i, atomic_t *v);
int atomic_inc_return(atomic_t *v);
int atomic_dec_return(atomic_t *v);

编译器层面:gcc内置__sync_*系列built-in函数

gcc内置的__sync_*函数提供了加减和逻辑运算的原子操作,__sync_fetch_and_add系列一共有十二个函数,有加/减/与/或/异或/等函数的原子性操作函数,__sync_fetch_and_add,顾名思义,先fetch,然后自加,返回的是自加以前的值。以count = 4为例,调用__sync_fetch_and_add(&count,1),之后,返回值是4,然后,count变成了5. 有__sync_fetch_and_add,自然也就有__sync_add_and_fetch,先自加,再返回。这两个的关系与i++和++i的关系是一样的。

type可以是1,2,4或8字节长度的int类型,即: 
int8_t / uint8_t
 int16_t / uint16_t
 int32_t / uint32_t
 int64_t / uint64_t
type __sync_fetch_and_add (type *ptr, typevalue);
 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, typevalue);
 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);

下面是使用 __sync_fetch_and_add 和常规的 LInux 中 pthread 中的同步接口互斥锁 mutex 的一个程序性能对比。

代码讲解1:使用__sync_fetch_and_add操作全局变量

#include <stdio.h>
#include <pthread.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/time.h>
#include <stdint.h>

int count = 0;

void *test_func(void *arg)
{
	int i=0;
	for(i=0;i<2000000;++i)
	{
		__sync_fetch_and_add(&count,1);
	}
	return NULL;
}

int main(int argc, const char *argv[])
{
	pthread_t id[20];
	int i = 0;

	uint64_t usetime;
	struct timeval start;
	struct timeval end;
	
	gettimeofday(&start,NULL);
	
	for(i=0;i<20;++i)
	{
		pthread_create(&id[i],NULL,test_func,NULL);
	}

	for(i=0;i<20;++i)
	{
		pthread_join(id[i],NULL);
	}
	
	gettimeofday(&end,NULL);

	usetime = (end.tv_sec-start.tv_sec)*1000000+(end.tv_usec-start.tv_usec);
	printf("count = %d, usetime = %lu usecs\n", count, usetime);
	return 0;
}

代码讲解2:使用互斥锁mutex操作全局变量

#include <stdio.h>
#include <pthread.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/time.h>
#include <stdint.h>

int count = 0;
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;

void *test_func(void *arg)
{
	int i=0;
	for(i=0;i<2000000;++i)
	{
		pthread_mutex_lock(&mutex);
		++count;
		pthread_mutex_unlock(&mutex);
	}
	return NULL;
}

int main(int argc, const char *argv[])
{
	pthread_t id[20];
	int i = 0;

	uint64_t usetime;
	struct timeval start;
	struct timeval end;
	
	gettimeofday(&start,NULL);
	
	for(i=0;i<20;++i)
	{
		pthread_create(&id[i],NULL,test_func,NULL);
	}

	for(i=0;i<20;++i)
	{
		pthread_join(id[i],NULL);
	}
	
	gettimeofday(&end,NULL);

	usetime = (end.tv_sec-start.tv_sec)*1000000+(end.tv_usec-start.tv_usec);
	printf("count = %d, usetime = %lu usecs\n", count, usetime);
	return 0;
}

结果说明:

[root@blake lock-free]#./atom_add_gcc_buildin

count = 40000000, usetime = 756694 usecs

[root@blake lock-free]# ./atom_add_mutex

count = 40000000, usetime = 3247131 usecs

可以看到,使用原子操作是使用互斥锁性能的5倍左右,随着冲突数量的增加,性能差距会进一步拉开。Alexander Sandler实测,原子操作性能大概是互斥锁的6-7倍左右。

有兴趣的朋友可以参考文章:http://www.alexonlinux.com/multithreaded-simple-data-type-access-and-atomic-variables 

在语言层面: C++ atomic libraryvolatile

在C/C++中,所有的内存操作都被假定为非原子性的,即使是普通的32位整形赋值,除非编译器或硬件厂商有特殊说明这个赋值操作是原子的。在所有的现代x86,x64,Itanium,SPARC,ARM和PowerPC处理器中,普通的32位整形,只要内存地址是对齐的,那么赋值操作就是原子操作,这个保证是特定平台下编译器和处理器做出的保证。由于C/C++语言标准并没对整型赋值是原子操作做出保证,于是,要想写出真正可移植的C和C++代码时,我们只能使用C++11提供的原子库( C++11 atomic library)来保证对变量的load(读)和store(写)是原子的。

不能不说的关键字:volatile

通过上面我们知道,在现代处理器中,对于一个对齐的整形类型(整形或指针),其读写操作是原子的,而对于现代编译器,用volatile修饰的基本类型正确对齐的保障,并且限制了编译器对其优化。这样通过对int变量加上volatile修饰,我们就能对该变量进行原子性读写。

volatile int i=10;//用volatile修饰变量i
......//something happened 
int b = i;//atomic read


由于volatile 在某种程度上限制了编译器的优化,而很多时候,对于同一个变量,我们在某些地方有原子性读写的需求,在某些地方我们又不需要原子性读写,这个时候希望编译器该优化的时候就优化。然而,不加volatile修饰,那么就做不到前面一点。加了volatile,后面这一方面就无从谈起,怎么办?其实,这里有个小技巧可以达到这个目的:

int i = 2; //变量i还是不用加volatile修饰
#define ACCESS_ONCE(x) (*(volatile typeof(x) *)&(x))
#define READ_ONCE(x) ACCESS_ONCE(x)
#define WRITE_ONCE(x, val) ({ ACCESS_ONCE(x) = (val); })
a = READ_ONCE(i);
WRITE_ONCE(i, 2);


通过上面我们知道,用volatile修饰的int在现代处理器中,能够做到原子性的读写,并且限制编译器的优化,每次都是从内存中读取最新的值,很多同学就误以为volatile能够保证原子性并且具有Memery Barrier的作用。其实vloatile既不能保证原子性,也不会有任何的Memery Barrier(内存栅栏)的保证。上面例子中,volatile仅仅是保证int的地址对齐,而对齐后的整形在现代处理器中,是能够做到原子性读写的。在C++中volatile具有以下特性:

1. 易变性:所谓的易变性,在汇编层面反映出来,就是两条语句,下一条语句不会直接使用上一条语句对应的volatile变量的寄存器内容,而是重新从内存中读取。
2. "不可优化"性:volatile告诉编译器,不要对我这个变量进行各种激进的优化,甚至将变量直接消除,保证程序员写在代码中的指令,一定会被执行。
3. "顺序性":能够保证Volatile变量间的顺序性,编译器不会进行乱序优化。非Volatile变量的顺序,编译器不保证顺序,可能会进行乱序优化。

2.2  CAS解法与ABA问题

2.2.1 CAS解法

一般采用原子级的read-modify-write原语来实现Lock-Free算法,而 compare-and-swap (CAS)被认为是最基础的一种原子性RMW操作,其伪代码如下:

bool CAS( int * pAddr, int nExpected, int nNew )
atomically {
    if ( *pAddr == nExpected ) {
         *pAddr = nNew ;
         return true ;
    }
    else
        return false ;
}


上面的CAS返回bool告知原子性交换是否成功,然而在有些应用场景中,我们希望CAS 失败后,能够返回内存单元中的当前值,于是就有一个称为 valued CAS的变种,伪代码如下:

int CAS( int * pAddr, int nExpected, int nNew )
atomically {
      if ( *pAddr == nExpected ) {
           *pAddr = nNew ;
           return nExpected ;
       }
       else
            return *pAddr;
}


CAS作为最基础的RMW操作,其他所有RMW操作都可以通过CAS来实现,例如 fetch-and-add(FAA),伪代码如下:

int FAA( int * pAddr, int nIncr )
{
     int ncur = *pAddr;
     do {} while ( !compare_exchange( pAddr, ncur, ncur + nIncr ) ;//compare_exchange失败会返回当前值于ncur
     return ncur ;
}


在C++11的原子lib中,主要有以下RMW操作:

std::atomic<>::fetch_add()
std::atomic<>::fetch_sub()
std::atomic<>::fetch_and()
std::atomic<>::fetch_or()
std::atomic<>::fetch_xor()
std::atomic<>::exchange()
std::atomic<>::compare_exchange_strong()
std::atomic<>::compare_exchange_weak()


其中compare_exchange_weak()就是最基础的CAS,使用compare_exchange_weak()我们可以实现其他所有的RMW操作,C++11 atomic library中的原子RMW操作有点少,不能满足我们实际需求,我们可以自己动手实现自己需要的原子RMW操作。

例如:我们需要一个原子对内存中值执行乘法,也就是 atomic fetch_multiply,实现伪代码如下:

uint32_t fetch_multiply(std::atomic<uint32_t>& shared, uint32_t multiplier)
{
    uint32_t oldValue = shared.load();
    while (!shared.compare_exchange_weak(oldValue, oldValue * multiplier))
    {
    }
    return oldValue;
}


以上的原子RMW操作都是只能对一个integer变量进行原子修改操作,如果我们想同时对两个integer变量进行原子操作,怎么实现呢?我们知道C++11的原子库std::atomic<>是一个模版,这样我们可以用一个结构体来包含两个integer变量,来对结构体进行原子修改。

C++11的原子库std::atomic<> template可以是任何类型(int、bool等buil-in type,或user-defined type),但并不是所有的类型的原子操作是lock-free的。C++11 标准库 std::atomic 提供了针对整形(integral)和指针类型的特化实现,其中 integal 代表了如下类型char, signed char, unsigned char, short, unsigned short, int, unsigned int, long, unsigned long, long long, unsigned long long, char16_t, char32_t, wchar_t,这些特化实现,都包含了一个is_lock_free()成员来用于判断该原子类型是原子操作是否是lock-free的。

现代处理器架构对CAS的实现分成两大阵营:(1)实现了原子性CAS原语 -- X86、Intel Itanium、Sparc等处理器架构,最早实现于IBM System 370。(2)实现LL/SC对(load-linked/store-conditional) --  PowerPC, MIPS, Alpha, ARM 等处理器架构,最早实现于DEC,通过LL/SC对可以实现原子性CAS,但在一些情况下它并不具有原子性。为什么会存在LL/SC对的使用,而不直接实现CAS原语呢?要说明LL/SC对存在的原因,不得不说一下无锁编程中的一个棘手问题:ABA问题。

2.2.2  ABA问题

一般的CAS在决定是否要修改某个变量时,会判断一下当前值跟旧值是否相等。如果相等,则认为变量未被其他线程修改,可以改。 但是,“相等”并不真的意味着“未被修改”。另一个线程可能会把变量的值从A改成B,又从B改回成A。这就是ABA问题。 很多情况下,ABA问题不会影响你的业务逻辑因此可以忽略。但有时不能忽略,这时要解决这个问题,一般的做法是给变量关联一个只能递增、不能递减的版本号。在compare时不但compare变量值,还要再compare一下版本号。 Java里的AtomicStampedReference类就是干这个的。

2.3、seqlock(顺序锁)

用于能够区分读与写的场合,并且是读操作很多、写操作很少,写操作的优先权大于读操作。 seqlock的实现思路是,用一个递增的整型数表示sequence。写操作进入临界区时,sequence++;退出临界区时,sequence再++。写操作还需要获得一个锁(比如mutex),这个锁仅用于写写互斥,以保证同一时间最多只有一个正在进行的写操作。 当sequence为奇数时,表示有写操作正在进行,这时读操作要进入临界区需要等待,直到sequence变为偶数。读操作进入临界区时,需要记录下当前sequence的值,等它退出临界区的时候用记录的sequence与当前sequence做比较,不相等则表示在读操作进入临界区期间发生了写操作,这时候读操作读到的东西是无效的,需要返回重试。 seqlock写写是必须要互斥的。但是seqlock的应用场景本身就是读多写少的情况,写冲突的概率是很低的。所以这里的写写互斥基本上不会有什么性能损失。 而读写操作是不需要互斥的。seqlock的应用场景是写操作优先于读操作,对于写操作来说,几乎是没有阻塞的(除非发生写写冲突这一小概率事件),只需要做sequence++这一附加动作。而读操作也不需要阻塞,只是当发现读写冲突时需要retry。 seqlock的一个典型应用是时钟的更新,系统中每1毫秒会有一个时钟中断,相应的中断处理程序会更新时钟(写操作)。而用户程序可以调用gettimeofday之类的系统调用来获取当前时间(读操作)。在这种情况下,使用seqlock可以避免过多的gettimeofday系统调用把中断处理程序给阻塞了(如果使用读写锁,而不用seqlock的话就会这样)。中断处理程序总是优先的,而如果gettimeofday系统调用与之冲突了,那用户程序多等等也无妨。 seqlock的实现非常简单: 写操作进入临界区时:

 void write_seqlock(seqlock_t *sl)
 {
     spin_lock(&sl->lock); // 上写写互斥锁
     ++sl->sequence; // sequence++
 }
写操作退出临界区时:
 void write_sequnlock(seqlock_t *sl)
 {
     sl->sequence++; // sequence再++
     spin_unlock(&sl->lock); // 释放写写互斥锁
 }
 
 读操作进入临界区时:
 unsigned read_seqbegin(const seqlock_t *sl)
 {
     unsigned ret;
     repeat:
         ret = sl->sequence; // 读sequence值
         if (unlikely(ret & 1)) { // 如果sequence为奇数自旋等待
             goto repeat;
         }
     return ret;
 }

读操作尝试退出临界区时:

 int read_seqretry(const seqlock_t *sl, unsigned start)
 {
     return (sl->sequence != start); //看看sequence与进入临界区时是否发生过改变
 }

而读操作一般会这样进行:

 do {
     seq = read_seqbegin(&seq_lock);// 进入临界区
     do_something();
 } while (read_seqretry(&seq_lock, seq)); // 尝试退出临界区,存在冲突则重试

2.4、内存屏障(Memory Barriers)

2.4.1 What Memory Barriers?


内存屏障,也称内存栅栏,内存栅障,屏障指令等,是一类同步屏障指令,是CPU或编译器在对内存随机访问的操作中的一个同步点,使得此点之前的所有读写操作都执行后才可以开始执行此点之后的操作。大多数现代计算机为了提高性能而采取乱序执行,这使得内存屏障成为必须。语义上,内存屏障之前的所有写操作都要写入内存;内存屏障之后的读操作都可以获得同步屏障之前的写操作的结果。因此,对于敏感的程序块,写操作之后、读操作之前可以插入内存屏障。


通常情况下,我们希望我们所编写的程序代码能"所见即所得",即程序逻辑满足程序的顺序性(满足program order),然而,很遗憾,我们的程序逻辑("所见")和最后的执行结果("所得")隔着:

编译器、CPU取指执行

1. 编译器将符合人类思考的逻辑(程序代码)翻译成了符合CPU运算规则的汇编指令,编译器了解底层CPU的思维模式,因此,它可以在将程序翻译成汇编的时候进行优化(例如内存访问指令的重新排序),让产出的汇编指令在CPU上运行的时候更快。然而,这种优化产出的结果未必符合程序员原始的逻辑,因此,作为程序员,必须有能力了解编译器的行为,并在通过内嵌在程序代码中的memory barrier来指导编译器的优化行为(这种memory barrier又叫做优化屏障,Optimization barrier),让编译器产出即高效,又逻辑正确的代码。


2. CPU的核心思想就是取指执行,对于in-order的单核CPU,并且没有cache,汇编指令的取指和执行是严格按照顺序进行的,也就是说,汇编指令就是所见即所得的,汇编指令的逻辑严格的被CPU执行。然而,随着计算机系统越来越复杂(多核、cache、superscalar、out-of-order),使用汇编指令这样贴近处理器的语言也无法保证其被CPU执行的结果的一致性,从而需要程序员告知CPU如何保证逻辑正确。

综上所述,memory barrier是一种保证内存访问顺序的一种方法,让系统中的HW block(各个cpu、DMA controler、device等)对内存有一致性的视角。

通过上面介绍,我们知道我们所编写的代码会根据一定规则在与内存的交互过程中发生乱序。内存执行顺序的变化在编译器(编译期间)和cpu(运行期间)中都会发生,其目的都是为了让代码运行的更快。就算是为了性能而乱序,但是乱序总有个度吧(总不能将指针的初始化的代码乱序在使用指针的代码之后吧,这样谁还敢写代码)。编译器开发者和cpu厂商都遵守着内存乱序的基本原则,简单归纳如下:

不能改变单线程程序的执行行为 -- 但线程程序总是满足Program Order(所见即所得)

在此原则指导下,写单线程代码的程序员不需要关心内存乱序的问题。在多线程编程中,由于使用互斥量,信号量和事件都在设计的时候都阻止了它们调用点中的内存乱序(已经隐式包含各种memery barrier),内存乱序的问题同样不需要考虑了。只有当使用无锁(lock-free)技术时–内存在线程间共享而没有任何的互斥量,内存乱序的效果才会显露无疑,这样我们才需要考虑在合适的地方加入合适的memery barrier。


通过上面,我们知道存在两种类型的Memory Barriers:编译器的Memory Barrier、处理器的Memory Barrier。对于编译器的Memory Barrier比较好理解,就是防止编译器为了优化而将代码执行调整乱序。而处理器的Memory Barrier是防止CPU怎样的乱序呢?CPU的内存乱序是怎么来的?
乱序会有问题本质上是读到了老的数据,或者是一部分读到新的一部分读到老的数据,从而引起问题。这种数据不一致怎么来的呢?相信这个时候大家脑海里已经浮现出一个词了:Cache。即现代处理器分层的 cache架构。

现代处理器为了弥补内存速度低下的缺陷,引入Cache来提高处理器访问程序和数据的速度,Cache作为连接内核和内存的桥梁,极大提升了程序的运行速度。为什么处理器内部加一个速度快,容量小的cache就能提速呢?这里基于程序的两个特性:时间的局部性(Temporal locality)和空间的局部性(Spatial)

[1] 时间的局部性(Temporal locality):如果某个数据被访问了,那么不久的将来它很有可能被再次访问到。典型的例子就是循环,循环的代码被处理器重复执行,将循环代码放在Cache中,那么只是在第一次的时候需要耗时较长去内存取,以后这些代码都能被内核从cache中快速访问到。
[2] 空间的局部性(Spatial):如果某个数据被访问了,那么它相临的数据很可能很快被访问到。典型的例子就是数组,数组中的元素常常安装顺序依次被程序访问。

现代处理器一般是多个核心Core,每个Core在并发执行不同的代码和访问不同的数据,为了隔离影响,每个core都会有自己私有的cache(如图的L1和L2),同时也在容量和存储速度上进行一个平衡(容量也大存储速度越慢,速度:L1>L2>L3, 容量:L3>L2>L1),于是就出现图中的层次化管理。Cache的层次化必然带来一个cache一致性的问题。而针对这问题的解决方案便是: cache一致性协议MESI。在这里就不详细阐述了,可自行学习。

2.4.2  How Memory Barriers?

memory barrier的语义在不同CPU上是不同的,因此,想要实现一个可移植的memory barrier的代码需要对形形色色的CPU上的memory barrier进行总结。幸运的是,无论哪一种cpu都遵守下面的规则:

[1]、从CPU自己的视角看,它自己的memory order是服从program order的
[2]、从包含所有cpu的sharebility domain的角度看,所有cpu对一个共享变量的访问应该服从若干个全局存储顺序
[3]、memory barrier需要成对使用
[4]、memory barrier的操作是构建互斥锁原语的基石

2.4.3 memory barrier内存屏障类型

显式内存屏障
内存屏障smp_mb()指的是一种全功能内存屏障(General memory barrier),然而全功能的内存屏障对性能的杀伤较大,某些情况下我们可以使用一些弱一点的内存屏障。

隐式内存屏障

有些操作可以隐含memory barrier的功能,主要有两种类型的操作:一是加锁操作,另外一个是释放锁的操作。

[1] LOCK operations -- 加锁操作
[2] UNLOCK operations -- 释放锁操作

(1) 加锁操作被认为是一种half memory barrier,加锁操作之前的内存访问可以任意渗透过加锁操作,在其他执行,但是,另外一个方向绝对是不允许的:即加锁操作之后的内存访问操作,必须在加锁操作之后完成。
(2) 和lock操作一样,unlock也是half memory barrier。它确保在unlock操作之前的内存操作先于unlock操作完成,也就是说unlock之前的操作绝对不能越过unlock这个篱笆,在其后执行。当然,另外一个方向是OK的,也就是说,unlock之后的内存操作可以在unlock操作之前完成。

 2.4.4 C++11 memory order


要编写出正确的lock free多线程程序,我们需要在正确的位置上插入合适的memory barrier代码,然而不同CPU架构对于的memory barrier指令千差万别,要写出可移植的C++程序,我们需要一个语言层面的Memory Order规范,以便编译器可以根据不同CPU架构插入不同的memory barrier指令,或者并不需要插入额外的memory barrier指令。


有了这个Memory Order规范,我们可以在high level language层面实现对在多处理器中多线程共享内存交互的次序控制,而不用考虑compiler,CPU arch的不同对多线程编程的影响了。

C++11提供6种可以应用于原子变量的内存顺序:

[1] memory_order_relaxed
[2] memory_order_consume
[3] memory_order_acquire
[4] memory_order_release
[5] memory_order_acq_rel
[6] memory_order_seq_cst

上面6种内存顺序描述了三种内存模型(memory model):

[1] sequential consistent(memory_order_seq_cst)
[2] relaxed(momory_order_relaxed)
[3] acquire release(memory_order_consume, memory_order_acquire, memory_order_release, memory_order_acq_rel)

内存屏障是很重要的,使用什么样的memory barrier,什么时候使用memory barrier又是其中的重中之重。memory barrier不容易理解,要想正确高效地使用memory barrier就更难了,通常情况下,能不直接用memory barrier原语就不用,最好使用锁(互斥量)等互斥原语这样的隐含了memory barrier功能的原语。锁在在很长一段时间都被误解了,认为锁是慢的,由于锁的引入,给性能带来巨大的瓶颈是很常见的。但这并不意味着所有的锁都是缓慢的,当我们使用轻量级锁并控制好锁竞争的时候,锁依然有非常出色的性能表现,锁不慢,锁竞争慢。

 2.5 参考文献

http://www.wowotech.net/kernel_synchronization/Why-Memory-Barriers.html

http://www.wowotech.net/kernel_synchronization/why-memory-barrier-2.html

https://cloud.tencent.com/developer/article/1021128

https://kukuruku.co/post/lock-free-data-structures-basics-atomicity-and-atomic-primitives/

https://kukuruku.co/post/lock-free-data-structures-the-inside-memory-management-schemes/

http://chonghw.github.io/blog/2016/08/11/memoryreorder/

猜你喜欢

转载自blog.csdn.net/smilejiasmile/article/details/114188217