写多线程的代码,无锁编程(lock-free programming)是个坎。大概一年前,我一直在看线程池有关的东西,都是用锁、信号量这些在做同步。当时听说过无锁编程,不过看各种博客也没搞明白是什么。后来看了本书,叫《C++ Concurrency in action》,有关的部分讲的比较清楚,就记下来一篇博客,整理一下。
这篇博客主要记C++的内存模型和原子类型,也有一些无锁数据结构的描述。
C++11 Memory Model
C++11标准中,在语言层面定义了内存模型。内存模型有两个方面,1)结构,即数据是如何在内存中排布的;2)并发,即多线程读写同一块内存时顺序是如何确定的。
内存模型的结构方面很好理解,举个结构体bit field的例子,
一个结构体S占据5个字节,a占据第1个字节,b占据第2个字节的前5个比特,c占据第2个字节的后3个比特以及第3个字节,d占据第4个字节,ee占据第5个字节。这就是S这个数据类型的内存模型结构方面。
内存模型的并发方面涉及到多线程。两个线程读取同一个内存地址时,不会出现问题;但是当两个线程同时向一个内存地址写入数据的时候,就会出现冲突;同样的,一个线程读取,另一线程写入同一内存地址时也会导致冲突。为了避免这种冲突,需要指定两个线程操作这块内存的顺序。
可以通过两种方式指定这种操作顺序。一是通过锁,例如C++11标准库中提供的mutex,加锁保证了在某一时刻,只有一个线程可以操作被保护的内存地址;二是通过原子操作,来保证操作同一个内存地址时,多个线程间的同步。
这里主要关注使用原子操作来指定多线程的同步顺序。
Atomic Type
原子类型用来保证操作的原子性。原子操作是指不可分割的操作,即不可能观察到该操作的中间状态。举个例子,如果读取一个对象A的操作是原子的,而且所有修改对象A的操作也是原子的,那么,读取对象A的结果,只可能是对象A的初始值,或者其他线程对对象A的修改值。相反,对于非原子性的操作,读取对象A的结果,可能既不是初始值,也不是任何一个修改值,而是处于某种中间状态。
C++11中,在<atomic>头文件里提供了atomic类,所有对atomic类的操作都是原子的。
atomic是一个模版类,可以生成各种原子类型,例如atomic<int>,atomic<bool>。在原子类型的实现中,同步操作可以依靠锁来实现,也可以依靠原子指令(atomic instructions)来实现,依靠原子指令实现的原子类型,被称为无锁的。
原子指令从指令的层面保证操作的原子性,例如fetch_and_add
指令原子性地加载一块内存地址到寄存器,然后做加法,不会出现只加载到寄存器,没有做加法的情况。由于指令的操作数存在大小限制,所以原子指令不能保证复杂数据的操作原子性。
在标准库中,atomic类型有一个is_lock_free()
的成员函数,用来表述对应的原子类型是用原子指令实现的,还是用锁来实现的。这个函数的返回值与模版参数有关,也与编译器对atomic类的实现有关。
atomic类型支持load()
,store()
,exchange()
,compare_exchange_weak()
和compare_exchange_strong()
操作。每一个对于原子类型的操作都有一个可选的内存序(memory order)的参数,用来指定内存模型的并发方面。原子操作分为三种:
-
写入(store),对应的内存序可以是
memory_order_relaxed
,memory_order_release
,memory_order_seq_cst
。 -
读取(load),对应的内存序可以是
memory_order_relaxed
,memory_order_consume
,memory_order_acquire
,memory_order_seq_cst
。 -
读写(read-modify-write),对应的内存序可以是
memory_order_relaxed
,memory_order_consume
,memory_order_acquire
,memory_order_release
,memory_order_acq_rel
,memory_order_seq_cst
。
如果没有指定的参数,所有的操作默认使用memory_order_seq_cst
内存序。
Memory Order
在介绍内存序之前,首先给出两种关系:
- happen-before:
如果动作A发生时,动作B产生的效果已经可以被动作A观测到,则称动作B发生在动作A之前,也就是存在happen before关系。在同一个线程执行的源代码中,可以理解为,前面的语句happen-before后面的语句。
- synchronized-with:
只有通过原子类型,才可以获得synchronized-with关系。如果一个线程A写入了一个数据,然后线程B读取到线程A写入的数据,这里线程A的写入操作和线程B的读取操作就是synchronized-with关系。
接下来详细描述各个内存序。
给个例子,
reader_thread函数和writer_thread函数分别运行在两个线程中。
简单分析一下。在writer_thread中,写入data变量happen-before写入data_ready变量;在reader_thread中,读取data_ready变量happen-before读取data变量;同时,由于reader_thread中读取到writer_thread中写入的true之后,循环才会被跳过,所以,写入data_ready变量的操作synchronized-with读取data_ready变量。事实上,synchronized-with关系引入了跨线程的happen-before关系,所以可以得到,写入data变量happen-before读取data变量。
用形式化一点的语言表述为:
-
store data happen-before store data_ready
-
load data_ready happen-before load data
-
store data_ready synchronized-with load data_ready
用以上三个条件,可以得到:
store data happen-before load data
这种表述会在接下来讲内存序的部分中经常用到。
SEQUENTIALLY CONSISTENT ORDERING
原子类型默认的内存序。这种内存序在所有的线程中建立统一的修改顺序,也就是,不论在哪一个线程中,观测到的原子数据的修改顺序都是一样的。
可能这个表述让人迷惑,来看一段代码:
这段代码中,assert永远不会终止程序,即z.load() != 0是永远成立的。如上所说,因为所有的原子操作都指定为memory_order_seq_cst,所以在四个线程中,看到的x,y的修改顺序都是一样的。这里只有两种情况,1)write_x happen-before write_y,那么对于read_y_then_x来说,既然y已经被写入了,那么x也必定被写入,即thread d会执行z++,2)write_y happen-before write_x,对于read_x_then_y,x已经被写入,那么y也必定被写入,即thread c会执行z++。当然这里的thread c和thread d可能都执行z++。无论哪种情况,z.load() != 0都是成立的。这个例子可以看出,使用memory_order_seq_cst这一内存序,会保证所有线程见到的修改顺序都是一样的。
memory_order_seq_cst是一个强有力的保证内存顺序的参数,与此同时,会带来一些不必要的性能损耗。保证所有线程见到的修改顺序一样,需要使用一些机制,来同步在各个处理器中执行的线程以及对应处理器上的缓存。可以通过放松限制,来获取更高的性能。
如何放松限制?请看接下来的内存序类型。
RELAXED ORDERING
以relaxed ordering为内存序的原子操作,不构成synchronized-with关系。在同一个线程中执行的原子操作仍然保持着happen-before的关系,但是跨线程的原子操作不构成任何相对序列关系。相对于sequencially consistent内存序,使用relaxed ordering可能使不同的线程看到不同的变量修改顺序。
看代码,
在如上代码中,assert可能会终止程序,也就是z.load() == 0可能成立。
由于原子操作都是指定memory_order_relaxed内存序,所以跨线程没有变量修改顺序限制关系。在线程a中,store x happen-before store y,并不意味着在线程b里store x happen-before store y。也就是,在线程a中的变量修改顺序,与在线程b中见到的不一定一致。说个简单的可能,在线程a执行的处理器中,x先被写入L1缓存,然后y再被写入L1缓存,在写回内存时,却是y先被写回。这导致在线程b执行的处理器中,加载到被线程a写入的y,以及没有被写入的x。这种情况就会导致z++没有执行。
简单说,由于relaxed ordering没有引入同步,所以在执行上没有很多限制,这会导致不同线程看到变量的修改顺序不一致,同时性能会好一些。
讲完了约束最强的内存序和约束最弱的内存序,接下来讲约束介于两者之间的内存序。
ACQUIRE-RELEASE ORDERING
Acquire-Release内存序引入了synchronized-with关系,但不是全局的同步。在这个内存序模型下,原子读取是acquire操作(memory_order_acquire),原子写入是release操作(memory_order_release),原子读写操作可以是acquire、release或者acq_rel。acquire的读操作和release的写操作之间存在同步,也就是在这个内存序下,同步关系是成对的。
A release operation synchronizes-with an acquire operation that reads the value written.
看代码,
上述代码中,assert永远都不会终止程序,也就是z.load() != 0永远成立。
-
store x happen-before store y
-
store y synchronized-with load y
-
load y happen-before load x
可以推导出,
store x happen-before load x
所以,++z的操作一定会被执行。这里的synchronized-with关系是由acquire-release对引入的。同时注意,这里load y时使用了while循环,保证了只有在线程b读取到了线程a中的写入时才会退出循环。也正如上文所述,只有acquire的读操作读取到了release的写操作写入的数据,才会形成同步关系。
CONSUME
这里不多讲,consume内存序用在读操作上,与release操作匹配使用可以形成依赖同步关系。这里的依赖同步关系比acquire-release形成的同步关系要弱一些,只有与consume读或release写产生依赖的数据才会形成同步关系,无依赖的数据则没有同步关系。
这里不讲内存栅栏(memory fence),可以找找别的资料看,也是一种做多线程之间同步的工具。
至此,C++11的内存模型已经讲完了。
无锁编程
其实理解了上面讲的,无锁编程就很好理解了,就是不用锁来写多线程的代码。一般是利用atomic类型以及内存栅栏来做同步。无锁编程是一项非常耗脑力的工作,很容易出问题,一般不建议非专家的人来写。
这里贴一段代码,简单示范一个无锁的数据结构,
不做解释,大概看看就可以了。这段代码其实会存在内存泄漏的问题,如果要处理这个内存泄漏会需要用一些其他的算法来处理,最后写出来的无锁栈的代码非常冗长。
Lots of the complexity in lock-free code comes from managing memory.
一般不写,能看懂就行。
这篇博客到此为止。