非阻塞 算法_非阻塞算法简介

当多个线程访问一个可变变量时,所有线程必须使用同步,否则可能会发生一些非常糟糕的事情。 Java语言中同步的主要方式是synchronized关键字(也称为“ 内在锁定” ),该关键字强制相互排斥,并确保一个执行synchronized块的线程的行为对其他线程可见,而其他线程随后又执行受保护的synchronized块。相同的锁。 如果使用得当,内在锁定可以使我们的程序具有线程安全性,但是当线程经常争用锁定时,当用于保护短代码路径时,锁定可以是一项相对繁重的操作。

“走向原子 ”中,我们介绍了原子变量 ,它们提供了原子的读-修改-写操作,可以安全地更新不带锁的共享变量。 原子变量的内存语义类似于易失变量,但是由于它们也可以原子地进行修改,因此它们可以用作无锁并发算法的基础。

畅通无阻的柜台

清单1中的Counter是线程安全的,但是由于涉及到的性能成本,使用锁的需求使某些开发人员感到恼火。 但是需要锁,因为尽管增量看起来像是一个操作,但它是三个独立操作的简写:取值,将值加一个,然后写出值。 (在getValue方法上也需要进行同步,以确保调用getValue线程看到最新的值。简单地忽略对锁的需求并不是一个好策略,尽管许多开发人员似乎不愿意说服自己相信这种方法是可以接受。)

当多个线程同时请求相同的锁时,一个将获胜并获得该锁,而另一个则阻塞。 JVM通常通过挂起被阻塞的线程并稍后对其进行重新调度来实现阻塞。 相对于受锁保护的少数指令,结果上下文切换会导致明显的延迟。

清单1.使用同步的线程安全计数器
public final class Counter {
    private long value = 0;

    public synchronized long getValue() {
        return value;
    }

    public synchronized long increment() {
        return ++value;
    }
}

清单2中的NonblockingCounter显示了一种最简单的非阻塞算法:一个使用AtomicIntegercompareAndSet() (CAS)方法的计数器。 compareAndSet()方法说:“将此变量更新为该新值,但是如果自从我上次查看以来,其他线程更改了该值,则失败。” (有关原子变量和比较设置的更多信息,请参见“原子化” 。)

清单2.使用CAS的非阻塞计数器
public class NonblockingCounter {
    private AtomicInteger value;

    public int getValue() {
        return value.get();
    }

    public int increment() {
        int v;
        do {
            v = value.get();
        }
         while (!value.compareAndSet(v, v + 1));
        return v + 1;
    }
}

原子变量类叫做原子因为它们提供了对数字和对象引用的细粒度的原子更新,但他们也是在这个意义上,他们是非阻塞算法的基本构造块原子。 20多年来,无阻塞算法一直是许多研究的主题,但自Java 5.0起,只有在Java语言中才成为可能。

现代处理器为原子更新共享数据提供了特殊指令,这些指令可以检测来自其他线程的干扰,并且compareAndSet()使用这些指令代替锁定。 (如果我们想做的只是增加计数器, AtomicInteger提供了增加方法,但它们基于compareAndSet() ,就像NonblockingCounter.increment() 。)

与基于锁的版本相比,非阻塞版本具有一些性能优势。 它使用硬件原语而不是JVM锁定代码路径在更细粒度的级别(单个内存位置)上进行同步,丢失的线程可以立即重试,而不必挂起和重新安排。 更好的粒度减少了争用的机会,而无需重新调度即可重试的能力减少了争用的成本。 即使有几次CAS操作失败,由于锁争用,此方法仍可能比重新计划要快。

NonblockingCounter可能是一个简单的示例,但它说明了所有非阻塞算法的基本特征-一些算法步骤是通过推测执行的,并且要知道如果CAS不成功,则必须重做。 非阻塞算法通常被称为乐观算法,因为它们会假设不会有干扰。 如果检测到干扰,它们将退出并重试。 在计数器的情况下,推测步骤是增量-它获取并向旧值添加一个,以希望该值在计算更新时不会改变。 如果错误,则必须再次获取该值并重做增量计算。

无阻塞堆栈

一个非阻塞算法的一个不太简单的例子是ConcurrentStack清单3.在push()pop()在操作ConcurrentStack都是结构上类似于increment()NonblockingCounter ,投机性做了一些工作,希望这个基本假设尚未失效是时候“承诺”这项工作了。 push()方法观察当前的顶级节点,构造一个要推入堆栈的新节点,然后,如果自初次观察以来最顶层的节点未发生变化,则安装新节点。 如果CAS失败,则意味着另一个线程已修改了堆栈,因此该过程再次开始。

清单3.使用Treiber算法的非阻塞堆栈
public class ConcurrentStack<E> {
    AtomicReference<Node<E>> head = new AtomicReference<Node<E>>();

    public void push(E item) {
        Node<E> newHead = new Node<E>(item);
        Node<E> oldHead;
        do {
            oldHead = head.get();
            newHead.next = oldHead;
        } while (!head.compareAndSet(oldHead, newHead));
    }

    public E pop() {
        Node<E> oldHead;
        Node<E> newHead;
        do {
            oldHead = head.get();
            if (oldHead == null) 
                return null;
            newHead = oldHead.next;
        } while (!head.compareAndSet(oldHead,newHead));
        return oldHead.item;
    }

    static class Node<E> {
        final E item;
        Node<E> next;

        public Node(E item) { this.item = item; }
    }
}

性能考量

在轻度到中度竞争下,非阻塞算法往往会胜过阻塞算法,因为在大多数情况下,CAS在首次尝试时都会成功,并且争用发生时的代价不涉及线程挂起和上下文切换,而只是进行了几次迭代循环。 无竞争的CAS比无竞争的锁获取便宜(此语句必须为真,因为无竞争的锁获取涉及CAS加其他处理),并且有竞争的CAS延迟比竞争的锁获取短。

在高争用情况下-当多个线程在单个内存位置上运行时-基于锁的算法开始提供比非阻塞算法更好的吞吐量,因为当线程阻塞时,它停止运行并耐心等待,避免了进一步争用。 但是,如此之高的争用级别并不常见,因为大多数时候线程将线程本地计算与竞争共享数据的操作交织在一起,从而为其他线程提供了共享数据的机会。 (如此之高的竞争水平也表明,应着眼于减少共享数据,重新检查算法。) “ Going atomic”中的图表在这方面有些令人困惑,因为所测量的程序是如此不切实际地争用密集,以至于它似乎锁是即使少量线程也能取胜的方法。

非阻塞链表

到目前为止的示例-计数器和堆栈-是非常简单的非阻塞算法,一旦您掌握了在循环中使用CAS的方式,就很容易理解它们。 对于更复杂的数据结构,非阻塞算法比这些简单示例要复杂得多,因为修改链表,树或哈希表可能涉及更新多个指针。 CAS在单个指针上启用原子条件更新,而不是两个。 因此,要构造一个无阻塞的链表,树或哈希表,我们需要找到一种方法来用CAS更新多个指针,而又不会使数据结构处于不一致的状态。

在链接列表的末尾插入元素通常涉及更新两个指针:始终指向列表中最后一个元素的“ tail”指针和从先前的最后一个元素到新插入的元素的“ next”指针。 因为需要更新两个指针,所以需要两个CASes。 在单独的CAS操作中更新两个指针会引入两个潜在的问题,需要加以考虑:如果第一个CAS成功但第二个CAS失败,会发生什么,以及如果另一个线程尝试访问第一个CAS与第二个CAS之间的列表,会发生什么。

为非平凡的数据结构构建非阻塞算法的“技巧”是确保数据结构始终处于一致状态,即使在线程开始修改数据结构的时间与完成时间之间也是如此,并确保其他线程不仅可以判断第一个线程是否已完成其更新还是仍在更新中,而且还可以判断如果第一个线程进行了AWOL,则需要执行哪些操作才能完成更新。 如果线程在更新过程中到达现场以查找数据结构,则可以通过完成对线程的更新来“帮助”已经执行更新的线程,然后继续进行自己的操作。 当第一个线程尝试完成自己的更新时,它将意识到该工作不再需要,而只是返回,因为CAS会检测到来自帮助线程的干扰(在这种情况下为构造性干扰)。

需要此“帮助您的邻居”要求以使数据结构能够抵抗单个线程的故障。 如果一个线程在另一个线程的更新过程中到达中间以查找数据结构,并且只是等到该线程完成更新,则如果另一个线程在其操作过程中发生故障,它将永远等待。 即使在没有故障的情况下,该方法也将提供较差的性能,因为新到达的线程将不得不让处理器屈服,产生上下文切换或等待其范围到期,这甚至更糟。

清单4中的LinkedQueue显示了Michael-Scott非阻塞队列算法的插入操作,该算法由ConcurrentLinkedQueue实现:

清单4.插入Michael-Scott非阻塞队列算法
public class LinkedQueue <E> {
    private static class Node <E> {
        final E item;
        final AtomicReference<Node<E>> next;

        Node(E item, Node<E> next) {
            this.item = item;
            this.next = new AtomicReference<Node<E>>(next);
        }
    }

    private AtomicReference<Node<E>> head
        = new AtomicReference<Node<E>>(new Node<E>(null, null));
    private AtomicReference<Node<E>> tail = head;

    public boolean put(E item) {
        Node<E> newNode = new Node<E>(item, null);
        while (true) {
            Node<E> curTail = tail.get();
            Node<E> residue = curTail.next.get();
            if (curTail == tail.get()) {
                if (residue == null) /* A */ {
                    if (curTail.next.compareAndSet(null, newNode)) /* C */ {
                        tail.compareAndSet(curTail, newNode) /* D */ ;
                        return true;
                    }
                } else {
                    tail.compareAndSet(curTail, residue) /* B */;
                }
            }
        }
    }
}

像许多队列算法一样,空队列由单个虚拟节点组成。 头指针始终指向虚拟节点。 尾指针始终指向最后一个节点或倒数第二个节点。 图1说明了在正常情况下具有两个元素的队列:

队列中有两个处于静止状态的元素

清单4所示,插入一个元素涉及两个指针更新,这两个更新都是通过CAS完成的:将新节点从队列上的当前最后一个节点链接到(C),并摆动尾指针以指向新的最后一个节点(D )。 如果第一个失败,则队列状态不变,并且插入线程重试直到成功。 一旦该操作成功,就认为插入已生效,其他线程可以看到修改。 仍然需要摆动尾部指针以指向新节点,但是可以将此任务视为“清理”,因为到达现场的任何线程都可以告知是否需要这种清理并知道如何进行清理。

队列始终处于以下两种状态之一:正常或静态( 图1图3 )或中间状态(图2)。 在插入操作之前和第二个CAS(D)成功之后,队列处于静止状态。 在第一个CAS(C)成功之后,它处于中间状态。 在静止状态下,尾部指向的链接节点的下一个字段始终为空; 在中间状态下,它始终为非null。 任何线程都可以通过将tail.next与null进行比较来判断队列处于哪个状态,这是使线程能够帮助其他线程“完成”其操作的关键。

在插入期间,在添加新元素之后但在更新尾指针之前,处于中间状态的队列

插入操作首先尝试检查队列是否处于中间状态,然后再尝试插入新元素(A),如清单4所示。 如果是这样,则在步骤(C)和(D)之间,某个其他线程必须已经在插入元素的中间。 当前线程不必等待另一个线程完成,而是可以通过向前移动尾指针(B)完成对它的操作来“帮助”它。 它会不断检查尾指针,并在必要时前进,直到队列处于静止状态为止,此时队列可以开始自己的插入。

第一个CAS(C)可能会失败,因为有两个线程正在争用对队列中当前最后一个元素的访问; 在这种情况下,更改没有生效,丢失CAS的所有线程都会重新加载尾指针,然后重试。 如果第二个CAS(D)失败,则无需重试插入线程-因为另一个线程已在步骤(B)中完成了对它的操作!

尾指针更新后再次处于静止状态

引擎盖下的非阻塞算法

如果您深入研究JVM和OS,那么到处都会发现无阻塞算法。 垃圾收集器使用它们来加速并发和并行垃圾收集; 调度程序使用它们来高效地调度线程和进程并实现内部锁定。 在Mustang(Java 6.0)中,基于锁的SynchronousQueue算法被替换为新的非阻塞版本。 很少有开发人员直接使用SynchronousQueue ,但是它用作用Executors.newCachedThreadPool()工厂构造的线程池的工作队列。 比较缓存线程池性能的基准测试表明,新的非阻塞同步队列实现的速度是当前实现的近三倍。 并计划在代号为Dolphin的Mustang之后的版本中进行进一步的改进。

摘要

非阻塞算法往往比基于锁的算法复杂得多。 开发无阻塞算法是一门相当专业的学科,很难证明其正确性。 但是,跨Java版本在并发性能方面的许多进步都来自使用非阻塞算法,并且随着并发性能变得越来越重要,可以期望在Java平台的未来版本中使用更多的非阻塞算法。


翻译自: https://www.ibm.com/developerworks/java/library/j-jtp04186/index.html

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值