【27】非阻塞算法


非阻塞算法是一种在并发情况下,允许线程以非阻塞的方式访问共享状态(或者其他数据交互)的算法。 一般来说,当某个线程暂停时,其他线程不会因此也暂停。如果一个算法能够保障这一点,就可以称之为非阻塞算法。

为了能够更好的说明阻塞算法与非阻塞算法的区别,所以,接下来先来看看阻塞算法。

阻塞并发算法

阻塞并发算法的核心概念,如下:

  • A: 执行线程请求的相关操作时
  • B: 阻塞线程,直到相关操作可以被安全执行时

有基于阻塞的算法以及数据结构。 比如,java.util.concurrent.BlockingQueue接口的多种实现。 如果一个线程尝试着向阻塞队列(BlockingQueue)中插入元素,恰巧此时,队列没有足够的空间,那么,执行插入的线程将会被阻塞(挂起),直到阻塞队列(BlockingQueue)腾出足够的空间时,才允许线程继续操作。

下图,展示了使用阻塞算法来操作同一个共享数据结构的情况: image

非阻塞并发算法

非阻塞并发算法的核心概念,如下:

  • A: 执行线程请求的相关操作时
  • B: 告知执行线程当前不允许操作(而不是直接挂起执行线程)

Java中也有几个非阻塞的数据结构。 如:AtomicBoolean,AtomicBoolean,AtomicBoolean,AtomicReference等。

下图,展示了使用非阻塞算法来操作同一个共享数据结构的情况: image

非阻塞与阻塞算法的对比

其实,两者的主要区别在于上述的第二点(B)。 就是说,两者的主要不同的是在请求不可执行时的处理方式:阻塞算法在请求不允许执行时,让操作线程阻塞(挂起);而非阻塞算法,在此时则告知执行线程当前不允许执行相关操作。

对于阻塞算法来说,阻塞住执行线程。大多数情况,被阻塞的线程是需要等待其他线程的某些操作之后才能继续执行的。 这种情况下,如果恰好期盼中的那个线程也被阻塞在其他地方了,那就会导致之前苦苦等待的那个线程继续等待下去,甚至再也没机会继续执行了。

比如,如果一个线程试着向一个空间已经满了的阻塞队列中插入元素,就会导致线程自身被阻塞(挂起),它需要等待着其他线程从阻塞队列中取走某个元素后才能插入。 而如果因为某些其他的原因,让取走元素的线程在其他地方阻塞住了,那么,等着插入元素的线程就只能继续等待,并且只能寄希望于取元素的线程能够尽早结束等待。

非阻塞并发数据结构

在多线程系统中,线程通常需要通过某种数据结构来通讯。 根据需要,可以使用简单的变量,也可以使用更高级的数据结构,如:队列/栈/散列表(Map)等等。 在并发情况下,这些数据结构必须使用某些并发算法来保障数据安全性。 也是由于使用了并发安全的算法,才使得这些数据结构可以称之为并发数据结构。

如果算法是通过阻塞来保障数据安全性的话,就称之为阻塞算法。 那么对应的数据结构也就被称之为阻塞并发数据结构。

而如果使用非阻塞来保障数据安全性的话,就称之为非阻塞算法。 那么对应的数据结构也就被称之为非阻塞并发数据结构。

以上无论哪种数据结构,都会设计一个方法用于数据通讯。 具体使用哪一个,取决于不同的使用场景。 下文也会介绍一些非阻塞并发数据结构,并且也会介绍这些数据结构适用于哪些场景。 这些说明性的介绍,对于实际工作中实现一个非阻塞的并发数据结构也会很有帮助。

Volatile 变量

之前在《Java中的volatile关键字》中,已经介绍过,volatile变量总是会直接操作主存。 当对volatile变量写操作时,会立即写入主存。 当要读取volatile变量时,每次都会直接从主存中读取,而不是从CPU cache中获取。 这个特性就可以保障volatile变量总是能被其他线程观察到最新的值。

Volatile变量就是一种非阻塞的数据结构。 对其执行写操作会以原子化操作的方式来执行。 这个操作过程不会被打断。 但是,要注意,如果要执行先读-再更新-最后写回(read-update-write)这样的连续操作时,就不是原子的了。 所以,下面这样的代码,在并发情况下仍然会导致竞态条件的发生:

volatile myVar = 0;

...
int temp = myVar;
temp++;
myVar = temp;

首先,读取volatile变量:myVar,这个读操作会直接从主存中读取。 然后,通过temp变量进行一次加1操作。 接着,将temp赋值给volatile变量myVar,这个写操作会直接写回主存。

当有两个线程同时执行上述代码逻辑时, 加一然后写回主存,就会导致结果不会像预期中那样为2,而可能会是1。

即便,我们不通过中间变量temp来完成加1的动作,直接操作volatile变量,也是一样的。 比如:

myVar++;

这样其实对于CPU来说,仍然是一个先读取myVar到CPU寄存器或者CPU cache中;然后再这个值上进行加一,接着再把加1之后的新值写回主存。 这样仍然是不安全的。

单个写操作的场景

某些情况下,只存在一个线程会对共享变量写操作,同时有多个线程会读取这个变量。 一写多读的情况下,是不会导致竟态条件发生的。 因此,当遇到这类场景时,可以使用volatile变量来实现。

如果存在多个线程对共享变量进行某些复合操作(read-update-write)时,就会导致竟态条件。 而如果只有一个线程对共享变量执行复合写操作(read-update-write),其他线程只是读操作的话,也不会导致竟态条件的发生。

下面这个例子就是一个一写多读的场景,虽然没有使用同步机制,但仍然是并发安全的:

public class SingleWriterCounter {

    private volatile long count = 0;

    /**
     * Only one thread may ever call this method,
     * or it will lead to race conditions.
     */
    public void inc() {
        this.count++;
    }


    /**
     * Many reading threads may call this method
     * @return
     */
    public long count() {
        return this.count;
    }
}

上面这个例子中,多个线程都可以访问同一个实例中的计数器,但是只有一个线程会去操作inc()进行自增。 注意,不是说同一时间只有一个线程执行inc(),而是指从始至终只有某一个特定的线程来专门负责调用inc()。也只有这种情况,才是线程安全的。

下图展示了,多个线程是如何访问count变量的: image

更多基于volatile变量的高级数据结构

在上一节中介绍过,volatile适用于一写多读的场景。基于这个特性,可以通过组合使用volatile变量,来创造一些更高级的数据结构。 通过不同的volatile变量,小心的区分读写线程,这样多个线程就可以以一种非阻塞的方式进行通讯。 下面这个例子,就展示了一种简单的实现:

public class DoubleWriterCounter {

    private volatile long countA = 0;
    private volatile long countB = 0;

    /**
     * Only one (and the same from thereon) thread may ever call this method,
     * or it will lead to race conditions.
     */
    public void incA() { this.countA++;  }


    /**
     * Only one (and the same from thereon) thread may ever call this method,
     * or it will lead to race conditions.
     */
    public void incB() { this.countB++;  }


    /**
     * Many reading threads may call this method
     */
    public long countA() { return this.countA; }
    

    /**
     * Many reading threads may call this method
     */
    public long countB() { return this.countB; }
}

上例中的DoubleWriterCounter类中,含有两个volatile变量,对外提供了相对应的自增方法和读取方法。 incA()incB()各自只会有一个专用的线程调用。 这样两个读取方法,就可以同时允许多个线程访问,并且也是线程安全的。

DoubleWriterCounter可以用于两个线程之间的通讯。 比如:两个计数器可以分别表示生产者和消费者的数量。 下图展示了这个逻辑: image

仔细想想,其实也可以通过使用两个SingleWriterCounter实例来达到DoubleWriterCounter同样的效果。 是的,而且需要的话,可以使用更多的线程和SingleWriterCounter来实现更复杂的计数器。

基于比较并交换的乐观锁

如果真的需要多个线程同时写一个共享变量的话,volatile变量是不足的。 这种情况下,需要保障写操作是排他性的。 这时候,通常都会想到使用Java同步块(锁)来实现。如:

public class SynchronizedCounter {
    long count = 0;

    public void inc() {
        synchronized(this) {
            count++;
        }
    }

    public long count() {
        synchronized(this) {
            return this.count;
        }
    }
}

可以看到,inc()count()方法都含有一个同步代码块。 使用synchronized代码块,也就避免了手工调用wait(),notify()方法。

还可以通过Java的原子变量来改写这个程序。 接下来,我们用AtomicLong来替代synchronized的实现:

import java.util.concurrent.atomic.AtomicLong;

public class AtomicCounter {
    private AtomicLong count = new AtomicLong(0);

    public void inc() {
        boolean updated = false;
        while(!updated){
            long prevCount = this.count.get();
            updated = this.count.compareAndSet(prevCount, prevCount + 1);
        }
    }

    public long count() {
        return this.count.get();
    }
}

这个版本也是一个线程安全的实现。 和上一个版本比较,主要的区别在于inc()方法,让其能够在保障线程安全的同时,避免使用synchronized

boolean updated = false;
while(!updated){
    long prevCount = this.count.get();
    updated = this.count.compareAndSet(prevCount, prevCount + 1);
}

注意,这些代码并不是一个原子操作。这就是说,可能存在两个不同的线程同时调用inc()方法,并且都执行到long prevCount = this.count.get(),两个线程都在同一个值上进行加1操作。 但是,这块代码没有并发问题。

可以注意到,上述代码其实是一个while循环结构。 同时注意,compareAndSet()方法是一个原子操作。 比较内部的AtomicLong变量,如果符合预期值,就进行更新。 compareAndSet()是一个典型的利用CPU比较并交换指令(compare-and-swap,CAS)的方法。 因此,不在需要同步处理了,这样也就不会又线程会为此而挂起(阻塞等待)了。 这样也就可以避免由线程挂起而带来的额外性能开销了。

想象一下,假设AtomicLong当前是20。 然后,两个线程同时读取了这个值,各自都调用了compareAndSet(20, 20 + 1)。 而compareAndSet()是原子操作,所以,两个线程会顺序的执行这个方法(也即是,不会同时执行)。

第一个线程,会比较AtomicLong的值是不是20,如果相等,就会更新为20+1。 通过updated作为循环条件,在不等的时候,进行重试。

所以,第二个线程如果也调用了compareAndSet(20, 20 + 1)。就会发现AtomicLong不再是20了,因此这个方法会失败。也就是说,21不会更新。接着,updated变量会更新为false,这样,第二个线程,会继续执行循环体。这一次,会发现AtomicLong已经是20了,于是会更新为22。 如果,此时没有其他线程干扰,那么更新22的动作就会顺利完成。如有还存在并发,则再次进入循环体,再次重试,直至成功。最终,会完成增加1的操作。

为什么称之为乐观锁

上面例子中的方法,也被称为:乐观锁。 乐观锁/悲观锁不同于一般的锁,更多意义上来说更像是一种使用锁的方式,而不是一个具体的锁。 常规的锁一般是指,通过同步块或者某种锁来阻塞住对共享变量的某些操作。 而同步块或者锁,其结果也就会导致线程被挂起。

乐观锁,则允许所有的线程从主存中复制一份共享变量的值,此过程不许要阻塞; 这些线程获得副本后,可以对各自副本进行修改; 修改后的新值再通过某些方法安全的写回主存。 通常都是以比较并交换(CAS)的方式写回主存。 如果遇到冲突(旧值不符合预期),则进行重试,直至成功。

乐观锁下每个执行线程总是假设当前没有(很少)其他线程会与自己冲突,这也是正是“乐观”这个名字的由来。 如果这个假设成立,则各个线程都可以在不加锁的情况下更新共享变量。 而一旦出现冲突,则需要重试,但仍然不需要加锁。

乐观锁适用于共享资源争用比较低的场景。 如果争用非常激烈,线程都被浪费重试操作中(CPU不停的复制旧值,比较失败,而后再次重试)。 不过,当你真的遇到了很高的争用时,应该重新思考和重新设计你的代码来降低争用。

乐观锁是非阻塞的

这里说得乐观锁是非阻塞的。 这一点使得,线程从主存中获取共享变量的副本后,无论其自身会不会发生阻塞等待,都不会影响其他线程。

而如果是一般的锁,当线程持有某个锁,那对于其他线程来说只能等着持有者释放。 如果持有者(线程)执行的比较慢,或者又在其他地方阻塞等待了,那就可能导致其他线程无限等待。

不可交换的数据结构

简单的比较并交换,就可以让乐观锁正常工作。 但这是有一个条件的,那就是共享数据结构是可以用单条CAS指令,执行整体交换的(覆盖)。 而这种整体替换的动作,在有些时候并不能适用。

想象一下,假设有一个共享的队列。 多个线程都试图执行放入或者取出元素的操作,那队列可能就需要把队列完整的复制一份,并对副本进行相应的修改。 这时可以适用AtomicReference。 对引用进行复制,然后通过这个引用的副本进行比较并交换操作,然后将AtomicReference指向新的队列(完成修改的副本)。

然而,要对这么大的数据结构进行复制,必然会浪费很多的内存和CPU资源。 这将是的应用占用大量的内存,由于会有更多的拷贝动作,所以需要更多的等待时间。 这些问题将会严重的影响应用程序的性能,特别是在数据高争用的情况时。 而且,还需要考虑多线程交叉写的情况,如,正在拷贝时,其他线程修改数据。 这将进一步的影响性能。

接下来的内容,将讨论如何实现并发情况下非阻塞更新数据等问题。

共享式数据修改

上一节讨论过,如果针对全部数据进行拷贝修改会有诸多问题,那现在就来讨论一下能不能对数据只做一些特定的局部更新。 对于想要修改数据的线程,其处理逻辑需要修改一下:

  1. 检查其他线程是否已经提交了一个修改
  2. 如果没有其他线程提交修改,则创建一个新的修改动作(可以用一个对象来封装这个动作)
    • 提交修改(CAS操作)
  3. 完成修改
  4. 移出本次修改动作,向其他线程发出信号

在上述的第二步时,会阻塞住其他线程提交已完成的修改。 因此,需要在第二步进行锁控制,以保障并发时的数据安全。 如果一个线程成功提交一个修改,那其他线程就不能提交修改,只能等待前面提交的修改动作执行完成。

如果线程提交修改后就阻塞,那数据必然是被锁定的。(实际会是一个原子操作,类似乐观锁,所以线程有机会去执行其他逻辑) 但是注意,数据本身并没有被锁定,所以并不会阻塞其他线程使用数据。 其他线程如果发现自己无法提交修改动作时,就可以做一点其他的事情。

协助式数据修改

为了避免修改动作带来的加锁,就需要让提交修改对象必须包含足够的信息,让其他线程也可以完成此次修改。 因此,如果线程提交的修改动作不能完成,那其他线程可以协助完成这次修改,这样就可以保障数据对于其他线程的可用。

下图展示了这个非阻塞算法的逻辑: image

修改总是会通过几个CAS操作才能完成。 因此,如果有两个线程试图去执行修改,则只会有一个线程能够完成CAS操作。 一旦CAS操作完成,后续的CAS操作就会失败。从而就可以发现是否前面还有一个未完成的修改动作,进而可以决定是否协助完成前面未完成的修改动作。

其实,这里可以结合具体的数据结构,让CAS操作失败时,采用代价更小的操作重试

如果是并发数据结构,一旦提交修改动作后,发现提交失败,自身线程就可以协助去完成之前的修改动作。这种思路也体现在Java8的ConcurrentHashMap的扩容逻辑

相比,分段锁;或者上一节提到的乐观锁重试,在高并发时,数据争用非常激烈时,吞吐依然会被重试循环,锁竞争拖累。

A-B-A问题

上面图中的算法,可能会带来A-B-A的问题。 就是说,数据从A修改为B,然后又再被修改回A。 那么这种情况下,对于其他线程会感知不到数据发生了变化。

如果,线程A正在:检查是否有正在执行的线程;拷贝数据;线程被挂起等等情况时,线程B都可能会在这些时间点访问数据。 而如果,线程B打算执行:全量更新数据;删除等操作时,线程A只是刚刚拷贝完数据副本,还没有来得及执行修改。 那这种情况,数据就可能先被线程B修改了。但是线程A,仍然会对自己的数据副本完成它的修改动作。 这样,当线程A最终完成修改后,就会导致线程B所做的修改没有任何效果。

下图展示了A-B-A问题的逻辑: image

原作者这张图有个错误,上图其实是:Thread A 和 Thread B

A-B-A解决方案

通常解决A-B-A问题的方案,就是在交换时(CAS)不仅仅是交换修改对象,还会带上一个计数器。 也就是在CAS操作的交换处理时,是一个引用+计数器来作为交换数据。 这样,在发生A-B-A时,其他线程也能从这个计数器上发现数据其实已经发生过变化。(计数器会递增)

C/C++等语言可以直接将指针加上计数值。 而Java是不可以让引用直接加上其他数值的。 不过Java提供了一个AtomicStampedReference,这个类就可以在交换引用时同时带上一个版本标记。

常规非阻塞算法模板

下面介绍一个实现非阻塞算法的代码模板。 这个模板逻辑主要也是基于本系列文章的有关内容。

**注意:**作者并不保障这个模板绝对可行,这里只是仅供参考。如果,在实际工作中最好还是系统的学习非阻塞算法后再动手。

import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicStampedReference;

public class NonblockingTemplate {

    public static class IntendedModification {
        public AtomicBoolean completed =
                new AtomicBoolean(false);
    }

    private AtomicStampedReference<IntendedModification>
        ongoingMod =
            new AtomicStampedReference<IntendedModification>(null, 0);

    //declare the state of the data structure here.


    public void modify() {
        while(!attemptModifyASR());
    }

    public boolean attemptModifyASR(){

        boolean modified = false;
    
        IntendedModification currentlyOngoingMod =
        ongoingMod.getReference();
        int stamp = ongoingMod.getStamp();
    
        if(currentlyOngoingMod == null){
            //copy data structure state - for use
            //in intended modification
        
            //prepare intended modification
            IntendedModification newMod =
            new IntendedModification();
        
            boolean modSubmitted = 
                ongoingMod.compareAndSet(null, newMod, stamp, stamp + 1);
        
            if(modSubmitted){
            
                //complete modification via a series of compare-and-swap operations.
                //note: other threads may assist in completing the compare-and-swap
                // operations, so some CAS may fail
            
                modified = true;
            }
    
        } else {
            //attempt to complete ongoing modification, so the data structure is freed up
            //to allow access from this thread.
        
            modified = false;
        }
    
        return modified;
    }
}

实现非阻塞算法是很有难度的

通常来说要想正确设计并实现一个非阻塞算法,是有难度的。 在你开始动手之前,需要确认一下是否已经有足够的准备。

Java中,已经有很多已经实现好了的非阻塞容器。(例如:ConcurrentLinkedQueue

除了Java中自带的非阻塞数据结构外。也可以实用一些第三方类库。 如:LMAX Disrupter;Cliff Click的非阻塞HashMap。

非阻塞算法的好处

下面说几个非阻塞算法带来的好处:

选择权

非阻塞算法带来的第一个好处就是:选择权。 就是当线程不能继续执行时,有了除等待之外的选择。 有的时候线程确实没有其他逻辑需要处理了,那它可以选择阻塞,或者在自身上进行等待,这样至少可以释放更多的CPU资源。 不论怎么说,这都给线程更多的选择权。

在单个CPU(内核)的系统上,挂起不能继续执行的线程是有意义的,这样可以让其他线程能够获得CPU。 但即便是单CPU系统,阻塞算法也可能会引发诸如死锁/饿死等并发问题。

没有死锁

第二个好处就是,在非阻塞算法中,挂起某个线程不会导致其他线程也被挂起。 这就意味着不存在死锁问题。 两个线程不会互相等待对方的资源(锁)。 因此,线程也不会彼此阻塞等待。 在非阻塞算法中,即便两个线程不断的尝试操作时最多也就是活锁,但这不会导致数据安全问题。

没有线程会被挂起

挂起和恢复线程执行的代价是非常昂贵的。(上下文切换/内核态) 虽然,随着操作系统和硬件的发展,这个代价可能会越来越小。但是,相对于其他操作,挂起和恢复线程始终是需要更大更大的代价。

当线程被阻塞时,就会被挂起。因此,阻塞也就会带来挂起的性能开销。 而非阻塞算法是没有阻塞的,所以也就不会发生挂起的情况。 这就意味着,CPU更多的时间是用于处理业务逻辑,而不是忙于上下文切换。

在一个多核CPU系统中,阻塞算法会带来更大的影响。 某个运行在CPU A上的线程阻塞等待的可能是运行在CPU B上的某个线程。 这就影响了系统的并行度。当然,CPU A可以调度去执行其他线程,但是,上下文切换仍然是昂贵的。 所以记住:越少线程挂起,性能越好。

更低的线程延迟

这里所指的延迟,是指请求线程准备执行到真正开始执行的时间。 由于非阻塞算法没有了阻塞,也就不会再需要花费额外的代价来唤醒一个线程来执行。 这就意味着,当执行的条件满足时,线程可以更快的做出应答,因此也就减少了回应的延迟时间。

非阻塞算法通常对于“忙等待”时会有更低的延迟。 但是如果,非阻塞数据结构的争用非常非常的激烈。那么,CPU不得不花费更多的周期来处理“忙等待”(如:自旋/重试)。 因此,一定要记住这一点。 如果在非常激烈争用情况下,非阻塞算法不是首选。 当然,通常还可以通过重新设计应用程序,来减少线程竞争。

猜你喜欢

转载自my.oschina.net/roccn/blog/1633636
今日推荐