垃圾回收机制(GC)对大部分开发者来说应该不生疏,特地是Java开发者或多或少都跟GC打过交道。 GC的长处是实现对堆上调配的内存动静回收,防止内存透露。然而GC的毛病是对性能有肯定影响,特地是stop the world问题, 而且GC什么时候回收内存是不确定的,开发者无奈通晓。

在无锁化编程场景下,用Java这种有GC的语言,肯定水平简化了对内存的治理,升高了无锁化编程的难度。 无锁化编程,顾名思义,就是不必锁。无锁化编程特指在多线程编程的时候,对线程间共享数据的并发批改不应用锁, 而采纳基于硬件提供的原子操作能力来批改共享数据,进而晋升性能,缩小应用锁带来的互斥开销。

最罕用的硬件提供的原子操作是Compare and Swap(简称CAS),编程语言基于硬件提供的原子操作能力封装出CAS库函数调用。 先看下C++里的CAS函数调用:

bool __sync_bool_compare_and_swap(    type *ptr, type oldval, type newval, ...);type __sync_val_compare_and_swap(    type *ptr, type oldval, type newval, ...);

这两个函数的性能一样,只是返回值不同。为了简化形容,略去了一些细节 (这个细节是对于内存程序,即程序指令在执行时的程序问题,这个问题是无锁化编程是否正确实现的关键问题之一,前面我再专门具体介绍)。 第一个CAS函数比拟指针ptr指向的变量,看是不是等于oldval,如果相等就把ptr指向的变量改为newval,并返回true,否则不做任何扭转并返回false。 第二个CAS函数也是比拟ptr指向的变量是否等于oldval,如果相等就把该变量改为newval,并返回oldval,如果不等就不做扭转,并返回ptr指向变量的以后值。 C++的CAS函数调用能够保障对ptr指向的变量的批改是原子的,要么更改实现,要么不做更改。

再看下硬件提供的原子操作。比方X86架构提供了一条CAS指令cmpxchg,这条指令在执行时是由硬件保障原子性,不会被打断。 其余硬件架构,比方ARM、PowerPC也提供相似的CAS原子操作,然而和X86的实现机制不一样,这里先不开展。 编程语言的编译器在X86架构下编译时,会负责把CAS库函数调用编译成基于cmpxchg的机器代码。 比方C++的编译器GCC在编译时如果碰到下面两个CAS函数调用,会生成蕴含cmpxchg指令的指标码。

无锁化编程示例:无锁化堆栈(Lock-Free Stack)的Java实现

先来看个简略的无锁化编程的例子,一个无锁化堆栈的Java实现(从网上找了一段现成的代码,没通过编译验证,仅做示例):

import java.util.concurrent.atomic.*;import net.jcip.annotations.*;/*** ConcurrentStack** @author Brian Goetz and Tim Peierls*/@ThreadSafepublic class ConcurrentStack <E> {    AtomicReference<Node<E>> top =        new AtomicReference<Node<E>>(); // 栈顶    public void push( E item ) {        Node<E> newNode = new Node<E>( item );        Node<E> curTop;        do {            curTop = top.get();            newNode.next = curTop;                  // CAS调用批改栈顶        } while ( !top.compareAndSet(                curTop, newNode ) );    }    public E pop() {        Node<E> curTop;        Node<E> newTop;        do {            curTop = top.get();            if ( curTop == null ) {                return null; // 堆栈为空            }            newTop = curTop.next;                    // CAS调用批改栈顶        } while ( !top.compareAndSet(                curTop, newTop ) );                        return curTop.item;    }    private static class Node <E> {        public final E item;        public Node<E> next;        public Node( E item ) {            this.item = item;        }    }}

从代码能够看出,这个栈的实现齐全没有用锁,栈顶top是以后堆栈顶端节点的原子援用AtomicReference, 每次出栈(pop)入栈(push)的时候,调用AtomicReference的compareAndSet办法来试图批改栈顶top。 因为有可能有多个线程竞争拜访这个无锁化堆栈,即有可能有多个线程同时对栈顶进行批改,或同时pop、或同时push,或同时pop和push, CAS的原子性保障了多个线程并发调用compareAndSet办法批改栈顶top时,仅有一个线程的调用能批改胜利,其余线程的调用不胜利, 所以pop和push操作里要用循环的形式反复调用compareAndSet办法试图批改栈顶top,直到胜利返回。

从这个例子能够看出无锁化编程的一个显著特点,对共享数据的批改要多次重试CAS操作。 尽管CAS调用基于硬件提供的原子能力,然而CAS调用的代价也不小。 比方在X86多处理器架构下运行下面的无锁化堆栈,每次CAS办法调用会运行cmpxchg指令,这个指令使得多个处理器缓存里的栈顶top生效, 导致多个处理器要从新从内存加载top。如果频繁调用CAS试图批改共享数据,将导致处理器缓存里的共享数据频繁生效,这个对性能的影响也不小。 所以千万不要滥用CAS调用。

无锁化编程示例:无锁化堆栈的C++实现

下面用Java实现无锁化堆栈,还是比较简单的,几十行代码就实现了。那用C++来实现无锁化堆栈会不会也很简略呢? 先看上面的无锁化堆栈C++实现片段(为了简化形容,还是省略了内存程序的细节,代码没通过编译验证,仅做示例):

#include <atomic>struct Node {    void* data;    std::atomic< Node * > next;};std::atomic<Node *> top; // 栈顶top.store( nullptr ); // 初始化栈顶为空指针bool push( Node* new_node ) {    Node * cur_top = top.load();    while ( true ) {        new_node->next.store( cur_top );                // CAS调用批改栈顶        if ( top.compare_exchange_weak(                cur_top, new_node )) {            return true;        }    }}Node * pop() {    while ( true ) {        Node * cur_top = top.load();        if ( cur_top == nullptr ) {            return nullptr; // 堆栈为空        }        Node * next = cur_top->next.load();                // CAS调用批改栈顶        if ( top.compare_exchange_weak(                cur_top, next )) {            return cur_top;        }    }}

看上去C++的无锁化堆栈实现跟Java版本简直统一,栈顶top也是原子类型,然而这个C++实现有问题。思考如下应用无锁化堆栈的C++代码片段:

Node * new_node = new Node();new_node.data = ...;push( new_node );...Node * pop_node = pop();if ( pop_node ) {    // 生产出栈节点    ...    // 不能保障没有其余线程在拜访pop_node,    // 此处不应该delete    delete pop_node;}

入栈的每个节点都是new进去的,所以可能感觉想当然出栈之后的每个节点在生产过过当前要被delete掉。 然而思考多线程并发拜访的场景,比方有两个线程同时调用出栈pop函数,假设这两个线程同时读取到以后栈顶cur_top = top.load();, 之后其中一个线程被抢占,另一个线程调用pop胜利取出栈顶节点,并且生产完之后delete掉, 这时之前被抢占的线程复原执行,判断cur_top不为空,而后读取cur_top的下一个节点next = cur_top->next.load();, 然而cur_top曾经被另一个线程delete掉了,cur_top曾经变成野指针了,一旦读取就会导致非法内存拜访使得程序解体。

那为什么Java版本的实现就没有问题呢?是因为GC的缘故。Java里没有显式的delete操作,所有的内存回收是GC主动实现的。 回到下面两个线程并发调用出栈pop函数的场景,如果用Java实现,当两个线程都读取到以后栈顶的援用,其中一个线程被抢占, 另一个线程出栈调用胜利,并实现生产出栈节点,此时Java的GC并不会回收出栈节点,因为GC发现出栈节点仍有被其余线程援用。 所以Java版本的无锁化编程是内存平安的。

对没有垃圾回收机制的语言做无锁化编程的思考

从下面无锁化堆栈的例子能够看出,Java的GC肯定水平简化了无锁化编程,因为不必思考内存回收的问题,Java的GC会平安的回收内存, 只是不能确定Java的GC什么时候回收内存。比照没有GC的语言,显然没有GC使无锁化编程变得复杂,因为实现的时候不得不自行思考内存平安回收的问题。 这个问题相当于是在用C++这类没有GC的语言做无锁化编程的时候,要自行实现一个GC,专门解决无锁化编程场景下的内存回收问题,并保障内存平安同时避免内存透露。

针对这个问题曾经有一些成型的算法,最简略的办法是Reference Counter(然而RC性能十分差,不实用,因而不做思考), 简单些的办法诸如Hazard pointer,Epoch-Based Reclamation或Quiescent State-Based Reclamation。 这些办法大都能够看做是一种确定型GC,更精确的说是绝对Java的GC而言,这些办法执行内存回收的时刻是确定的(只是不同算法的具体内存回收触发逻辑不同)。 这种确定型GC的思路是,仅针对无锁化编程这种特定场景实现GC,升高无锁化编程的难度,而不是作为通用型GC,这比起Java的GC来说就简略多了。 因而,确定型GC相比Java的GC,一方面缩小复杂度从而大大降低对程序性能的影响,另一方面因其内存回收的确定性所以避免stop the world问题呈现。 当然对开发者而言确定型GC也是是通明的,开发者无需关怀内存何时回收。限于篇幅,具体的细节这里先不开展,后续我再具体介绍这些确定型内存回收算法。

对没有GC的语言,比方C++,曾经有一些针对无锁化编程的内存回收算法的实现,比方libcds, 更进一步,libcds还实现了无锁化堆栈、无锁化队列、无锁化哈希表等等。 所以对于开发者而言,不须要反复造轮子,能够间接应用这些已有的实现来简化无锁化编程。

作者 | 王璞