Java AQS论文翻译

描述

  • 大部分在jdk1.5并发包(java.util.concurrent)中的同步器(锁,屏障等)都是使用一个小型框架基于类AbstractQueuedSynchronizer构建的;这个框架对原子性的管理同步状态,阻塞和非阻塞线程,队列提供了一种通用的机制;这篇文章描述了这个框架的基本原理,设计,实现,使用和优化;

介绍

  • Java在1.5的发布引入了java.util.concurrent,一个通过了JCP(国际组织) JSR-166(Java规范请求)的中间层的并发支持类的集合;在这些组件中有一组同步器 - 抽象数据类型(ADT)用于维护内部的同步状态,比如状态是否锁定或者解锁,操作装填的更新和检查,如果状态满足需求至少会导致一个线程调用某个方法,允许其他线程改变同步状态来恢复自己;例子包括各种形式的互斥锁,读写锁,信号灯,屏障,异步计算的结果,事件指标,切换队列;
  • 众所周知(参见例如[2])几乎任何同步器都可以用来实现几乎任何其他。例如可以从重入锁建立信号量,反之亦然;然而做这些意味着复杂,开销和缺乏灵活性,是二流工程师的选择;此外它在概念上没有吸引力;如果这些结构中没有一个本质上比其他结构更原始,那么开发人员不应该被迫随意选择其中一个作为构建其他结构的基础。相反,JSR-166建立了一个以AbstractQueuedSynchronizer为中心的小框架,它提供了包中大多数提供的同步器使用的常用机制,以及用户可能自己定义的其他类。本文的其余部分讨论了该框架的要求,其设计和实现背后的主要思想,样本使用以及一些显示其性能特征的测量。

要求

功能

  • 同步器具有两种方法:至少一个获取操作可以阻塞调用线程直到同步状态允许它继续;至少一个释放操作可以以某种方式改变同步状态从而允许一个或者多个阻塞线程解锁;
  • java.util.concurrent包没有为同步器定义单个统一API;一些定义通过通用的接口(如Lock),但是其他接口仅包含专用版本.因此获取和释放操作在不同的类中存在一系列的名称和形式;例如,方法Lock.lockSemaphore.acquireCountDownLatch.awaitFutureTask.get都映射到框架中获取操作。但是,该包确实在各个类之间保持一致的约定,以支持一系列常见的使用选项。 对于富有意义的语义,每个同步器都支持:
    • 非阻塞同步尝试(例如:tryLock)以及阻塞版本;
    • 可选的超时,因此应用程序可以放弃等待;
    • 通过中断实现可取消性,通常分为一个可取消和一个不可取消的获取版本;
  • 同步器可以根据它们是否只管理独占状态(一次只有一个线程可以继续经过可能的阻塞点)与可能的共享状态(其中多个线程至少有时可以同时进行);常规锁类当然只保留独占状态,但是,例如:计数信号量可以被许多线程获得用于计数;为了得到广泛的使用,框架必须支持这两种操作模式;
  • java.util.concurrent也定义了Condition接口,用于支持监听式的等待/信号的操作,可能与独占锁相关联;并且其实现本质上与Lock类相关;

性能目标

  • Java内置锁(使用synchronized方法和块)长期以来一直有性能问题,关于他们的解释有大量的文献(如[1],[3]);但是此类工作的重点是最小化空间开销(因为任何Java对象都可以充当锁),并且在使用单处理器的单线程上下文时,使用的最小化时间开销;这些对于同步器来说都不是重要的问题,程序员只有在需要时才构造同步器,因此不需要压缩原本会被浪费的空间,并且同步器几乎只用于多线程设计(通常在多处理器上),至少偶尔的资源争抢是可以预料的;因此通常的JVM锁优化策略主要针对于零争用情况;其他不可预测的slow paths,对于典型的多线程服务应用程序严重依赖java.util.concurrnet不是一个好的策略;
  • 相反,这里的主要性能目标是可伸缩性:甚至可预测的保持效率,尤其是在同步器争用时;理想情况下,无论多少线程尝试这样做,通过同步点所需的开销应该是恒定的; 主要目标之一是最小化允许某些线程通过同步点但尚未完成的总时间;但是必须根据资源进行平衡:包括总CUP时间要求,内存交互,线程调度开销;比如,自旋锁通常提供比阻塞锁更短的获取时间;但通常会花费循环并产生内存争用;因此通常不太适用.
  • 这些目标带来两种一般的使用方式;大多数应用应该最大化总吞吐量,最好可以容忍缺乏饥饿的概率保证;但在资源控制等应用中,保持跨线程访问的公平性更为重要;容忍较差的总吞吐量;没有框架可以代表用户在这些互相冲突的目标之间做出决定;相反,必须适应不同的公平策略;
  • 无论内部如何精心设计,同步器都会在某些应用程序中产生性能瓶颈。因此,框架必须能监听和检查基本操作,以允许用户发现和缓解瓶颈;这种最低限度(也是最有用的)需要提供一种方法来确定阻塞了多少线程;

设计和实现

  • 同步器背后的基本思想非常简单。获得操作的过程如下:
while (同步状态不允许获取) {
如果尚未排队,则将当前线程排入队列;
可能阻塞当前线程;
}
如果当前线程在排队则将其取消;
  • 释放操作:
更新同步状态;
if (状态允许阻塞线程获取)
解锁一个或者多个已经入队的线程;
  • 支持这些操作需要三个基本组件的协作:
    • 以原子方式管理的同步状态
    • 阻塞或非阻塞线程
    • 维护队列
  • 有可能创建一个框架,允许这三个组件中的每一个独立变化;但这既不是非常有效,也不可用;例如:队列节点中保存的信息与非阻塞所需的信息匹配,导出方法的签名取决于同步状态的性质;

  • 同步器框架中的中心设计决策是选择这三个组件中的每一个的具体实现,同时仍然允许在如何使用它们方面有多种选择。这有意限制了适用范围,但提供了足够有效的支持,实际上从来没有理由不在它适用的情况下使用框架(而是从头开始构建同步器)。

同步状态

  • AbstractQueuedSynchronizer仅使用单个32位的int维护同步状态,getState获取,setStatecompareAndSetState操作来访问和更新此状态;这些方法依赖于java.util.concurrent.atomic的支持,在读取和写入时提供符合JSR-133(Java内存模型)的易失性语义,访问本地的compare-and-swap或者loadlinked/store条件指令以实现compare-AndSetState,只有当它保持给定的期望值时才会自动将状态设置为给定的新值;

  • 将同步状态限制为32位的int是一个务实的决定.虽然JSR-166还在64位长度上提供了原子操作,但任然必须在特定的平台使用内部锁来模拟这些操作,导致同步器不能很好的执行;在将来,或许会添加专门用于64位状态(长控制参数)的第二个基类;但是现在没有令人信服的理由将其纳入包中;目前,32位足以满足大多数应用的需求,只有一个java.util.concurrent同步器类CyclicBarrier需要更多的位来维护状态;所以改为使用锁;

阻塞

  • 在JSR166之前,没有Java API可用来阻塞和解除阻塞线程,以创建不基于内置监视器的同步器。唯一的候选者Thread.suspendThread.resume无法解决遇到的线程竞争问题:如果一个未阻塞的线程在阻塞线程执行挂起之前调用了resume,那么resume操作将不会起作用;java.util.concurrent.lock包包含一个LockSup-port类,它的方法可以解决这个问题;LockSupport.park阻塞当前线程,除非或者直到有一个LockSupport.unpark被调用;(虚假唤醒是被允许的;) 对于unpark的呼叫不会被“计算”,因此在park之前的多个park仅取消阻止单个park。 此外,这适用于每个线程,而不是每个同步器。在新同步器上调用park的线程可能会立即返回,因为之前的使用中存在“剩余”的unpark。但是,如果没有unpark,它的下一次调用将会阻塞。虽然可以明确地清除这种状态,但是不值得这样做。在恰好需要时多次调用park更有效。

  • 这种简单的机制类似于在某种程度上在Solaris-9线程库[11],WIN32“可消费的事件”和Linux NPTL线程库中使用的机制,因此Java可以最有效地映射到这些机制中的每一个常见平台上。(但是,SolarisLinux上的当前Sun Hotspot JVM参考实现实际上使用了pthread condvar以适应现有的运行时设计。)park方法还支持可选的相对和绝对超时,并与JVM的Thread.interrupt支持集成-中断线程unpark

队列

  • 框架的核心是维护阻塞线程队列,这些队列限制为FIFO先进先出队列;因此该框架不支持基于优先级的同步;
  • 目前,几乎没有争议的是,同步队列最适合选择是非阻塞数据结构,它们本身不需要使用较低级别的锁构造;其中两个主要候选者:Mellor-Crummey和Scott(MCS锁的变种[9],以及Craig,Landin和Hagersten(CLH锁的变体[5] [8] [10]。从历史上看,CLH锁仅用于自旋锁,但是,它们似乎比MCS更适合用于同步器框架,因为它们更容易适应处理取消超时,因此被选为基础。 最终的设计远离原始CLH结构,需要解释。
  • CLH队列不是非常类似队列.因为它的入队和出队操作与其作为锁的用途密切相关;它是通过两个可自由更新的字段(头部和尾部)访问的链接队列,最初都指向虚拟节点。
    CLH_queue.png

  • 一个新的节点node,入队使用原子操作:
do { 
    pred = tail;
} while(!tail.compareAndSet(pred, node));
  • 每个节点的释放状态保留在其前驱节点之中,所以自旋锁spinlock的旋转看起来像下面这样:
while (pred.status != RELEASED) ; // spin
  • 这次旋转之后的出队操作只需要设置头字段到刚刚获得锁定的节点:
head = node;
  • CLH锁的优点之一是入队和出队是快速,无锁和无障碍的(即使在争用中,一个线程将总是赢得插入竞赛,因此将取得进展); 检测是否有任何线程正在等待也很快(只检查head是否与tail相同); 并且该释放状态是分散的,避免了一些内存争用。
  • 在原始版本的CLH锁中,甚至没有连接节点的链接。 在自旋锁中,pred变量可以保持为局部变量。 然而,Scott和Scherer [10]表明,通过显式维护节点中的前任字段,CLH锁可以处理超时和其他形式的取消:如果前驱节点取消,节点可以向上滑动以使用前一节点的状态字段。
  • 使用CLH队列阻塞同步器所需的主要附加修改是为一个节点提供定位其后继的有效方法。 在自旋锁中,一个节点只需要改变它的状态,它的继承者将在下一次旋转时注意到它,因此链接是不必要的。 但是在阻塞同步器中,节点需要明确唤醒weak up(取消 unpark)其后继者。
  • AbstractQueuedSynchronizer队列节点包含其后继的下一个链接。 但是因为没有适用于使用compareAndSet进行双链表节点无锁原子插入的技术,所以此链接不是作为插入的一部分原子设置的; 它被简单地分配:
pred.next = node;
  • 插入后,这反映在所有用法中。 下一个链接仅被视为优化路径。 如果节点的后继者通过其下一个字段似乎不存在(或似乎被取消),则始终可以从列表的尾部开始并使用pred字段向后遍历以准确检查是否确实存在。
  • 第二组修改是使用保持在某个节点的状态字段来控制阻塞而不是旋转。 在同步器框架中,如果排队的线程通过了在具体子类中定义的tryAcquire方法,则它只能从获取操作返回; 单个“已释放”位不足。 但是仍然需要控制来确保活动线程只允许在队列头部时调用tryAcquire; 在这种情况下,它可能无法获取和(重新)阻塞。这不需要每节点状态标志,因为可以通过检查当前节点的前驱是头部来确定权限。 与自旋锁的情况不同,没有足够的内存争用读取头来保证重复。 但是,取消状态仍必须存在于状态字段中。
  • 队列节点状态字段还用于避免不必要的调用topark和unpark。 虽然这些方法相对较快,因为它们在Java和JVM运行时和/或OS之间的边界交叉中遇到了可避免的开销。在调用park之前,线程设置“发信号给我”位,然后再次检查同步和节点状态在至少一次调用park之前。 释放线程清除状态。 这种不必要的尝试经常阻塞,尤其是锁定类,其中等待下一个符合条件的线程获取锁的时间损失会强调其他意图效果。 这也避免了要求释放线程确定其后继者,除非后继者设置了信号位,这反过来消除了必须遍历多个节点以应对明显无效的下一个字段的情况,除非信号与取消一起发生。
  • 也许在同步器框架中使用的CLH锁的变体与在其他语言中使用的变量之间的主要区别在于垃圾收集依赖于管理节点的存储回收,这避免了复杂性和开销。 但是,依赖于GC仍然需要在确定永远不需要链接字段时将其置零。 这通常可以在出列时完成。 否则,未使用的节点仍然可以访问,导致它们无法收集。
  • 在J2S1.5版本的源代码文档中描述了一些进一步的小调整,包括在第一次争用时CLH队列所需的初始虚节点的延迟初始化。
  • 省略这些细节,基本获取操作的实现的一般形式(仅限独占,不可中断,不定时的情况)是:
f (!tryAcquire(arg)) {
    node = 创建和入栈一个新的节点;
    pred = 节点有效的前置节点;
    while (pred前置节点不是head节点 || !tryAcquire(arg)) {
        if (设置pred前置节点的信号位)
        park();
    else
        compareAndSet,设置前置节点信号位为true;
        pred = 节点有效的前置节点;
    }
    head = node;
}
  • 释放操作是:
f (tryRelease(arg) && head节点设置了信号位) {
    compareAndSet,设置head节点信号位为false;
    unpark head的后置节点,如果存在的话
}
  • 主要获取循环的迭代次数取决于tryAcquire的性质。 否则,在取消的情况下,获取和释放的每个组件都是恒定时间O(1)操作,跨线程分摊开销,忽略在调用park内发生的任何OS线程调度。
  • 取消支持主要包括在获取循环内每次从park返回时检查中断ortimeout。 由于超时或中断而导致的已取消的线程会设置其节点状态并取消其后续进程,因此可能会重置链接。 通过取消,确定前驱和后继以及重置状态可能包括O(n)遍历(其中n是队列的长度)。因为线程永远不会再次阻止取消操作,链接和状态字段往往会快速重新稳定。

条件队列

  • 同步器框架提供了一个ConditionObject类,供同步器使用,该同步器保持独占同步并符合Lock接口。 可以将任意数量的条件对象附加到锁定对象,从而提供经典的监视器样式await,signal和signalAll操作,包括具有超时的操作,以及一些检查和监视方法。
  • ConditionObject类使条件能够与其他同步操作有效地集成,同样通过修复一些设计决策。 此类仅支持Java样式的监视器访问规则,其中条件操作仅在当前线程持有拥有条件的锁时才合法(有关备选方案的讨论,请参见[4])。 因此,附加到ReentrantLock的ConditionObject的行为方式与内置监视器(通过Object.wait等)相同,只是方法名称,额外功能以及用户可以为每个锁声明多个条件的事实不同。
  • ConditionObject使用与同步器相同的内部队列节点,但将它们保存在单独的条件队列中。 信号操作实现为从条件队列到锁定队列的队列传输,而不必在重新获取其锁定之唤醒信号线程。
  • 基本的await操作是:
创建和添加新的节点到条件队列;
释放锁;
阻塞,直到节点c处于锁定队列;
重新获得锁;
  • 信号操作是:
将第一个节点从条件队列转移到锁定队列;
  • 由于这些操作仅在保持锁定时执行,因此它们可以使用顺序链接队列操作(使用节点中的nextWaiter字段)来维护条件队列。 传输操作只是将第一个节点与条件队列取消链接,然后使用CLH插入将其附加到锁定队列。
  • 实现这些操作的主要复杂因素是处理由于超时或Thread.interrupt而导致的条件等待的取消。 在大约同一时间发生的取消和信号遇到竞争,其结果符合内置监视器的规范。 正如在JSR133中修订的那样,这些要求如果在信号之前发生中断,那么await方法必须在重新获取锁之后抛出InterruptedException。 但是如果它在信号之后被中断,那么该方法必须返回而不抛出异常,但是设置了它的线程中断状态。
  • 为了保持正确的排序,队列节点状态中的一个位记录节点是否已经(或正在进行)传输。信令代码和取消代码都尝试compareAndSet这种状态。如果信号操作失去该竞争,则它转移队列上的下一个节点(如果存在)。如果取消失败,则必须中止转移,然后等待锁定重新获取。后一种情况引入了潜在的无限旋转。取消的等待无法开始锁定重新获取,直到节点已成功插入锁定队列,因此必须旋转等待由信令线程执行的CLH队列插入compareAndSet成功。在这里旋转的需求很少,并且使用Thread.yield来提供调度提示,其中一些其他线程(理想情况下是执行信号的线程)应该运行。虽然可以在这里实现一个帮助策略来取消插入节点,但这种情况太少了,无法证明这会带来额外的开销。在所有其他情况下,此处和其他地方的基本机制不使用旋转或产量,这在单处理器上保持合理的性能。

使用

  • AbstractQueuedSynchronizer类将上述功能联系在一起,并作为同步器的“模板方法模式”[6]基类。 子类仅定义实现状态检查的方法和控制获取和释放的更新。 但是,AbstractQueuedSynchronizer的子类本身不能用作同步器ADT,因为该类必须导出内部控制获取和释放策略所需的方法,这些方法不应对这些类的用户可见。 所有java.util.concurrent同步器类都声明一个私有的内部AbstractQueuedSynchronizer子类,并将所有同步方法委托给它。 这也允许公共方法被赋予适合于同步器的名称。
  • 例如,这是一个最小的Mutex类,它使用同步状态0表示解锁,1表示锁定。 此类不需要同步方法支持的值参数,因此使用1,否则忽略它们。
class Mutex {
    class Sync
    extends AbstractQueuedSynchronizer {
        public boolean tryAcquire(int ignore) {
            return compareAndSetState(0, 1);
        }
        public boolean tryRelease(int ignore) {
            setState(0); return true;
        }
    }
    private final Sync sync = new Sync();
    public void lock() { sync.acquire(0); }
    public void unlock() { sync.release(0); }
}
  • 可以在J2SE文档中找到此示例的更完整版本以及其他使用指南。 许多变体当然是可能的。 例如,tryAcquire可以通过在尝试更改状态值之前检查状态值来使用“testand-test-and-set”。
  • 令人惊讶的是,作为互斥锁的性能敏感的构造旨在使用委托和虚拟方法的组合来定义。 然而,这些是现代动态编译器长期关注的各种OO设计结构。 他们倾向于善于优化这种开销,至少在频繁调用同步器的代码中。
  • AbstractQueuedSynchronizer类还提供了许多方法来协助策略控制中的同步器类。 例如,它包括基本获取方法的超时和可中断版本。 虽然到目前为止的讨论主要集中在独占模式同步器(如锁)上,但AbstractQueuedSynchronizer类还包含一组并行方法(如acquireShared),这些方法的不同之处在于tryAcquireShared和tryReleaseShared方法可以通知框架(通过它们的返回值) 进一步获得可能是最终的,最终导致它通过级联信号唤醒多个线程。
  • 虽然序列化(持久存储或传输)同步器通常不合理,但这些类通常用于构造通用序列化的其他类,例如线程安全集合。 AbstractQueuedSynchronizer和ConditionObject类提供了序列化同步状态的方法,但不提供底层阻塞线程或其他本质上的瞬时记录。 即便如此,大多数同步器类只是在反序列化时将同步状态重置为初始值,这与内置锁定的隐式策略一致,该锁定始终反序列化为解锁状态。 这相当于无操作,但仍必须明确支持以启用final字段的反序列化。

公平控制

  • 即使它们基于FIFO队列,同步器也不一定公平。 请注意,在基本获取算法(第3.3节)中,tryAcquire检查在排队之前执行。 因此,新获取的线程可以“窃取”对于队列头部的第一线程“预期”的访问。

  • 这种插入FIFO策略通常提供比其他技术更高的聚合吞吐量。 它减少了竞争锁可用的时间,但没有线程,因为预期的下一个线程正处于解除阻塞的过程中。 同时,它通过仅允许一个(第一个)排队线程唤醒并尝试获取任何释放来避免过度的,无效的争用。 创建同步器的开发人员可能会进一步强调插入效应,在这种情况下,通过在传回控制之前将tryAcquire定义为自身重试几次,预期只会暂时保持同步器。

  • 限制FIFO同步器只具有概率公平性。 锁定队列头部的未停放线程具有无偏见的机会,可以通过任何传入的插入线程赢得比赛,如果丢失则重新进行重新锁定和重试。 但是,如果传入的线程到达的时间比解除阻塞的非停放线程要快,则队列中的第一个线程很少会赢得竞争,因此几乎总是会重新锁定,并且其后继线将保持阻塞状态。 使用暂时保持的同步器,在第一个线程取消阻塞的过程中,多处理器上会发生多个条形码和释放。 如下所示,净效应是保持一个或多个线程的高进展速率,同时仍然至少在概率上避免饥饿。
    tryAccquire.png
  • 当需要更大的公平性时,安排它是一件相对简单的事情。 如果当前线程不在队列的头部,那么需要严格公平的程序员可以将tryAcquire定义为失败(返回false),使用方法getFirstQueuedThread(少数提供的检查方法之一)检查这一点。
  • 如果队列(暂时)为空,则更快,更不严格的变体也允许tryAcquire成功。 在这种情况下,遇到空队列的多个线程可能竞争成为第一个获取的线程,通常不会将其中至少一个排队。 在支持“公平”模式的所有java.util.concurrent同步器中采用此策略。

  • 虽然它们在实践中往往有用,但公平性设置无法保证,因为Java语言规范不提供调度保证。 例如,即使使用严格公平的同步器,JVM也可以决定纯粹按顺序运行一组线程,如果它们永远不需要阻塞等待彼此。 实际上,在单处理器上,这些线程可能在先发制人地上下文切换之前每个都运行一段时间。 如果这样的线程持有一个独占锁,它很快就会立即切换回来,只是为了释放锁定并阻止现在知道另一个线程需要锁定,从而进一步增加同步器可用但不是收购。 同步器公平性设置往往会对多处理器产生更大的影响,这会产生更多的交错,因此一个线程有更多机会发现另一个线程需要锁定。

  • 尽管在保护短暂保存的代码体时它们可能在高争用下表现不佳,但公平锁定工作良好,例如,当它们保护相对较长的代码体和/或具有相对较长的互锁间隔时,在这种情况下,条带提供很少的性能 优势,但无限期推迟的风险更大。 同步器框架将这样的工程决策留给其用户。

同步器

  • 以下是使用此框架定义java.util.concurrent同步器类的草图:
  • ReentrantLock类使用同步状态来保存(递归)锁定计数。 获取锁时,它还会记录当前线程的标识,以检查递归并在错误的线程尝试解锁时检测非法状态异常。 该类还使用提供的ConditionObject,并导出其他监视和检查方法。 该类通过内部声明两个不同的AbstractQueuedSynchronizer子类(公平的一个禁用插入)并设置每个ReentrantLock实例以在构造时使用适当的一个来支持可选的“公平”模式。
  • ReentrantReadWriteLock类使用16位同步状态来保持写锁定计数,剩余的16位用于保持读锁定计数。 WriteLock的结构与ReentrantLock的结构相同。 ReadLock使用acquireShared方法启用多个读取器。
  • Semaphore(计数信号量)使用同步状态来保存当前计数。 它将acquireShared定义为递减计数或阻塞(如果是非正数),并将tryRelease定义为递增计数,如果现在为正,则可能解除阻塞。
  • CountDownLatch类使用同步状态来表示计数。 所有获得通过时达到零。
  • FutureTask类使用同步状态来表示未来的运行状态(初始,运行,取消,完成)。 设置或取消将来调用release,通过acquire解锁等待其计算值的线程。
  • SynchronousQueue类(CSP样式的切换)使用匹配生产者和消费者的内部等待节点。 它使用同步状态允许生产者在消费者获取物品时继续进行,反之亦然。
  • java.util.concurrent包的用户当然可以为自定义应用程序定义自己的同步器。 例如,在包中考虑但未采用的那些类中提供了各种风格的WIN32事件,二进制锁存器,集中管理锁和基于树的屏障的语义。

性能

  • 虽然同步器框架除了互斥锁之外还支持许多其他样式的同步,但锁定性能最容易测量和比较。 即便如此,还有许多不同的测量方法。 这里的实验旨在揭示开销和吞吐量。
  • 在每个测试中,每个线程重复更新使用函数nextRandom(int seed)计算的伪随机数:
int t = (seed % 127773) * 16807 – (seed / 127773) * 2836;
return (t > 0)? t : t + 0x7fffffff;
  • 在每次迭代中,线程以概率S更新互斥锁下的共享生成器,否则更新其自己的本地生成器,而不进行锁定。 这会导致短时间锁定区域,从而在线程被抢占时保持锁定时最大限度地减少了外部影响。 函数的随机性有两个目的:它用于决定是否锁定(它是一个足够好的生成器用于当前目的),并且还使得循环内的代码不可能轻易地优化掉。
  • 比较了四种锁:内置,使用同步块; Mutex,使用简单的Mutex类,如第4节所示; 可重入,使用ReentrantLock; 和Fair,使用ReentrantLock设置为“合理”模式。 所有测试都在“服务器”模式下使用Sun J2SE1.5 JDK构建46(与beta2大致相同)。 测试程序在收集测量值之前执行20次无竞争运行,以消除预热效应。 测试每个线程运行了一千万次迭代,除了Fair模式测试只运行了一百万次迭代。
  • 测试在四台基于x86的机器和四台基于UltraSparc的机器上进行。 所有x86机器都使用基于RedHat NPTL的2.4内核和库运行Linux。 所有UltraSparc机器都运行Solaris-9。 测试时,所有系统的负载最轻。 测试的性质并不要求他们完全闲置。 “4P”这个名字反映了双超线程(HT)Xeon更像是4路而不是2路机器的事实。 没有尝试在这里对差异进行标准化。 如下所示,同步的相对成本与处理器数量,类型或速度没有简单的关系。
    test.png

开销

  • 通过仅运行一个线程来测量无用的开销,从S = 1的运行中减去版本设置S = 0(访问共享随机的零概率)所采用的每次迭代的时间。 表2显示了这些估计的同步代码在非同步代码上的每锁开销,以纳秒为单位。 Mutex类最接近于测试框架的基本成本。 Reentrant锁的额外开销表示记录当前所有者线程和错误检查的成本,而对于Fair锁定首先检查队列是否为空的额外成本。

  • 表2还显示了tryAcquire与内置锁的“快速路径”的成本。 这里的差异主要反映了在锁和机器上使用不同原子指令和内存障碍的成本。 在多处理器上,这些指令往往完全压倒所有其他指令。 Builtin和同步器类之间的主要区别显然是由于使用compareAndSet进行锁定和解锁的Hotspot锁定,而这些同步器使用compareAndSet进行获取和易失性写入(即,在多处理器上使用内存屏障,并对所有进行重新排序约束) 处理器)发布。 每种机器的绝对和相对成本因机器而异。

  • 在另一个极端,表3显示了S = 1并运行256个并发线程的每个锁定开销,从而产生了大量的锁争用。 在完全饱和状态下,驳船-STOP锁定比内置锁具有大约一个数量级的开销(并且相当于更大的吞吐量),并且通常比公平锁定低两个数量级。 这证明了即使在极端争用下,插入FIFO策略在维护线程进度方面的有效性。

test2.png

test3.png

  • 表3还说明即使内部开销较低,上下文切换时间也完全决定了公平锁的性能。 列出的时间大致与阻止和解锁各种平台上的线程的时间成正比。
  • 此外,后续实验(仅使用机器4P)显示,使用此处使用的非常短暂的锁定,公平性设置对总体方差的影响很小。 线程终止时间的差异被记录为粗粒度的可变性度量。 机器4P上的时间标准偏差为Fair的平均值的0.7%,而Reentrant的平均值为6.0%。 作为对比,为了模拟长期持有的锁,运行了一个测试版本,其中每个线程在保持每个锁的同时计算16K随机数。 在这里,总运行时间几乎相同(Fair为9.79秒,Reentrant为9.72秒)。 公平模式变异性仍然很小,标准差为平均值的0.1%,而Reentrant上升至平均值的29.5%。

吞吐量

  • 大多数同步器的使用范围介于无争用和饱和的极端情况之间。 这可以通过改变一组固定线程的争用概率和/或通过向具有固定争用概率的集合添加更多线程来沿两个维度进行实验检查。 为了说明这些影响,使用不同的争用概率和线程数运行测试,所有这些都使用Reentrant锁。 附图使用减速指标:

thought.png

  • 这里,t是观察到的总执行时间,b是没有争用或同步的一个线程的基线时间,n是线程数,p是处理器数,S仍然是共享访问的比例。 该值是观察时间与(通常无法实现的)理想执行时间的比率,使用Amdahl定律计算顺序和并行任务的混合。 理想的时间模拟执行,其中没有任何同步开销,没有任何线程块由于与任何其他的冲突。 即便如此,在非常低的争用下,一些测试结果显示出与此理想相比非常小的加速比,可能是由于优化,流水线等在基线与测试运行之间的细微差别。
  • 这些数字使用基数2对数标度。 例如,值1.0表示测量时间是理想情况下的两倍,值4.0表示慢16倍。 使用日志可以改善对任意基准时间的依赖(这里是计算随机数的时间),因此使用不同基本计算的结果应该显示类似的趋势。 测试使用从1/128(标记为“0.008”)到1的争用概率,步进2的幂,以及从1到1024的线程数,步进为2的半幂。
  • 在单处理器(1P和1U)上,性能随着争用的增加而降低,但通常不会随着线程数量的增加而降低。 多处理器通常在争用下遇到更糟糕的减速。 多处理器的图表显示了一个早期的峰值,其中仅涉及少数线程的争用通常会产生最差的相对性能。 这反映了性能的过渡区域,其中驳船和信令线程几乎同样可能获得锁定,因此经常迫使彼此阻止。 在大多数情况下,接下来是一个更平滑的区域,因为锁几乎从不可用,导致访问类似于近序列模式的单处理器; 在具有更多处理器的机器上更快地接近这一点。 例如,请注意,完全争用的值(标记为“1.000”)在具有较少处理器的计算机上表现出相对较差的减速。

thought2.png
thought3.png

  • 在这些结果的基础上,似乎进一步调整阻塞(停放/取消停放)支持以减少上下文切换和相关开销可能会在此框架中提供小但显着的改进。 此外,同步器类可以为多处理器上的短暂持有的高竞争锁采用某种形式的自适应旋转,以避免一些在这里看到的瑕疵。 虽然自适应旋转往往很难在不同的环境中很好地工作,但是可以使用这个框架构建自定义锁的形式,针对遇到这些类型的使用配置文件的特定应用程序。

结论

  • 在撰写本文时,java.util.concurrent同步器框架太新了,无法在实践中进行评估。 在J2SE1.5最终发布之后,它不太可能被广泛使用,并且肯定会出现其设计,API,实现和性能的意外后果。 但是,此时,该框架似乎成功地实现了为创建新同步器提供有效基础的目标。

致谢

  • 感谢Dave Dice在这个框架的开发过程中提出了无数的想法和建议,感谢Mark Moir和Michael Scott敦促考虑CLH队列,以及David Holmes批评早期版本的代码和API,以及Victor Luchangco和Bill Scherer的审查 以前的源代码版本,以及JSR166专家组的其他成员(Joe Bowbeer,Josh Bloch,Brian Goetz,David Holmes和Tim Peierls)以及Bill Pugh,帮助设计和规范以及评论草稿 这篇论文。 这项工作的一部分是通过DARPA PCES拨款,NSF拨款EIA-0080206(用于访问24way Sparc)和Sun协作研究拨款实现的。

引用

  • [1] Agesen, O., D. Detlefs, A. Garthwaite, R. Knippel, Y. S. Ramakrishna, and D. White. An Efficient Meta-lock for Implementing Ubiquitous - Synchronization. ACM OOPSLA Proceedings, 1999.
  • [2] Andrews, G. Concurrent Programming. Wiley, 1991.
  • [3] Bacon, D. Thin Locks: Featherweight Synchronization for Java. ACM PLDI Proceedings, 1998.
  • [4] Buhr, P. M. Fortier, and M. Coffin. Monitor Classification, ACM Computing Surveys, March 1995.
  • [5] Craig, T. S. Building FIFO and priority-queueing spin locks from atomic swap. Technical Report TR 93-02-02, Department of Computer - Science, University of Washington, Feb. 1993.
  • [6] Gamma, E., R. Helm, R. Johnson, and J. Vlissides. Design Patterns, Addison Wesley, 1996.
  • [7] Holmes, D. Synchronisation Rings, PhD Thesis, Macquarie University, 1999.
  • [8] Magnussen, P., A. Landin, and E. Hagersten. Queue locks on cache coherent multiprocessors. 8th Intl. Parallel Processing Symposium, - Cancun, Mexico, Apr. 1994.
  • [9] Mellor-Crummey, J.M., and M. L. Scott. Algorithms for Scalable Synchronization on Shared-Memory Multiprocessors. ACM Trans. on Computer - Systems, February 1991
  • [10] M. L. Scott and W N. Scherer III. Scalable Queue-Based Spin Locks with Timeout. 8th ACM Symp. on Principles and Practice of Parallel - Programming, Snowbird, UT, June 2001.
  • [11] Sun Microsystems. Multithreading in the Solaris Operating Environment. White paper available at - http://wwws.sun.com/software/solaris/whitepapers.html 2002.
  • [12] Zhang, H., S. Liang, and L. Bak. Monitor Conversion in a Multithreaded Computer System. United States Patent 6,691,304. 2004.

参考

Doug Lea 论文PDF http://gee.cs.oswego.edu/dl/papers/aqs.pdf

转载于:https://www.cnblogs.com/fubinhnust/p/9929451.html

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值