许可:本作品的全部或部分在不为牟利或商业利益为目的的,且在第一页引述本声明及全完整引用的前提下,以数码或硬拷贝形式供个人或课堂使用的复制或分发不收取任何费用。以其他方式复制、重新发布、发布到服务器或重新分发到列表,都需要事先获得特定的许可和/或付费。2004年7月26日,加州纽芬兰,圣约翰,CSJP ’ 04。
在J2SE 1.5的java.util.concurrent包中,大部分的同步器(例如锁,屏障等)都是基于AbstractQueuedSynchronizer这个类的简单框架而构建的。这个框架为同步状态的原子性管理、线程的阻塞和解除阻塞以及队列提供了一种通用的机制。本文介绍了这个框架的原理、设计、实现、用法和性能。
D.1.3[编程技术]:并发编程-并行编程
算法,测量,性能,设计。
Synchronization, Java
Java发行版本 J2SE-1.5通过 Java Community Process(JCP)的Java Specification Request(JSR)166引入了包java.util.concurrent。
这个包提供了一系列创建中级并发支持类的集合。在这些组件中有一组同步器——抽象数据类型(ADT)类,它们维护内部同步状态(例如,表示锁是锁定的还是未锁定的),更新和检查状态的操作,如果状态需要,至少有一个方法会导致调用线程阻塞,当其他线程更改同步状态时允许它恢复。示例包括各种形式的互斥锁、读写锁、信号量、屏障、预期futures、事件指示器和切换队列。
众所周知(如[2]),几乎任何同步器都可以用来实现几乎任何其他同步器。例如,可以从可重入锁构建信号量,反之亦然。然而,这样做通常需要足够的复杂性、开销和灵活性,充其量只能成为二流的项目选择。此外,它在概念上没有吸引力。如果这些构造在本质上没有一个比其他构造更简洁,那么开发人员不应该被迫任意选择其中一个来作为构建其他构造的基础。相反,JSR166建立了一个以AbstractQueuedSynchronizer类为中心的小框架,它提供了大多数同步器以及用户可能自己定义的其他类所使用的公共机制。本文的其余部分将讨论此框架的需求、其设计和实现背后的主要思想、示例用法以及显示其性能特征的一些度量。
2.1 功能需求
同步器一般包含两种方法[7],一种是acquire,另一种是release。acquire操作阻塞调用的线程,直到或除非同步状态允许其继续执行。而release操作则是通过某种方式改变同步状态,使得一个或多个被acquire阻塞的线程继续执行。
java.util.concurrent包并没有给同步器的API做一个统一的定义。有些是通过公共接口(例如锁)定义的,但是其他的只包含特定的版本。因此在不同的类中,acquire和release操作的名字和形式会各有不同。例如:Lock.lock,Semaphore.acquire,CountDownLatch.await和FutureTask.get,在这个框架里,这些方法都是acquire操作。但是,为支持一系列通用的使用选项,在类间都有个一致约定。当有意义时,每个同步器都支持:非阻塞同步尝试(例如,tryLock)以及阻塞同步尝试;可选的超时设置,因此应用程序可以选择放弃等待。
通过中断的可取消性,通常分为一个可取消的获取版本和一个不可取消的获取版本。
同步器可能会根据它们是否只管理独占状态(即一次只有一个线程在可能的阻塞点之后继续运行)和共享状态(至少有多个线程可以同时继续运行)而有所不同。常规锁类当然只维护独占状态,但是计数信号量可以由计数允许的尽可能多的线程获得。要广泛使用,框架必须支持这两种操作模式。
java.util.concurrent包也定义了Condition接口,支持监视器风格的等待/信号操作,这些操作可能与独占锁类Lock相关联,并且其实现本质上与其关联的锁类Lock交织在一起。
2.2 性能目标
Java内置锁(使用synchronized的方法或代码块是一直以来都在被人们关注的性能问题,并且已经有一系列的文章描述其构造(例如引文[1],[3])。然而,这类工作的主要焦点是最小化空间开销(因为任何Java对象都可以用作锁),以及在单处理器上的单线程上下文中最小化时间开销。这些都不是某个特别重要的问题:程序员只在需要的时候才会构建同步器,因此不需要压缩空间,并且同步器几乎是专门用在多线程设计中(越来越频繁地在多处理器),这种情况下,偶尔争用是在预料之中的。因此,常规的JVM优化锁策略主要针对零争用情况,而将其他情况留给不那么可预测的“慢路径”[12],对于严重依赖java.util.concurrent的典型多线程服务器应用程序来说,常规的策略并不适用。
相反,这里的主要性能目标是可伸缩性:即使在争用同步器时,也可预测地保持效率,尤其是在争用同步器时。理想情况下,无论有多少线程尝试通过同步点,通过同步点所需的开销都应该是常量。在某一线程被允许通过同步点但还没有通过的情况下,使其耗费的总时间最少,这是主要目标之一。但是,这必须与资源考虑因素相平衡,包括总CPU时间需求、内存流量和线程调度开销。例如,自旋锁通常比阻塞锁提供更短的获取时间,但通常会浪费周期并产生内存争用,因此通常不常用。
这些目标包含两种一般的使用风格。大多数应用程序应该最大限度地提高总吞吐量,最多也只能容忍对减少饥饿的概率保证。然而,在资源控制等应用程序中,维护线程间访问的公平性要重要得多,这可以容忍较差的聚合吞吐量。没有一个框架能够代表用户在这些冲突的目标之间做出决定;相反,必须适应不同的公平政策。
不管同步器的内部设计有多好,它们都是同步器,将在某些应用程序中产生性能瓶颈。因此,框架必须能够监视和检查基本操作,以允许用户发现和缓解瓶颈。这至少(也是最有用的)需要提供一种方法来确定有多少线程被阻塞。
3.设计与实现
同步器背后的基本思想非常简单。一个acquire操作如下:
while (同步状态不允许acquire) {
当前线程如果不在队列中就增加到队列;
可能阻塞当前队列;
}
当前线程如果在队列中就出列;
一个release操作如下:
更新同步状态;
if (状态允许阻塞线程acquire)
解锁一个或多个队列中的线程; 对这些行动的支持需要协调三个基本组成部分:
同步状态的原子性管理;
阻塞和解除阻塞线程;
保持队列;
也许可以创建一个框架,允许这三个部分中的每一个独立变化
然而,这既不是很有效也不是很有用。例如,队列节点中保存的信息必须与解除阻塞所需的信息相匹配,导出方法的签名取决于同步状态的特性。
同步器框架的核心设计决策是选择这三个组件的每个具体实现,同时仍然允许在如何使用它们时使用多种选项。这有意地限制了适用性的范围,但是提供了足够有效的支持,在实际应用框架的情况下,几乎没有理由不使用框架(而是从头构建同步器)。
3.1 同步状态
AbstractQueuedSynchronizer类使用单个int(32位)来保存同步状态,并暴露出getState、setState以及compareAndSet操作来读取和更新这个状态。这些方法又依赖于java.util.concurrent. atomic的支持,这个包在读写上提供JSR133 (Java内存模型)兼容的volatile语义,并且通过使用本地的compare-and-swap或load-linked/store-conditional指令来实现compareAndSetState,只有当状态具有给定的期望值时,才原子地将状态设置为给定的新值。
将同步状态限制为32位int是一个实用的决定。虽然JSR166还提供了64位长字段上的原子操作,但是必须在足够多的平台上使用内部锁来模拟这些操作,从而导致同步器不能很好地执行。在将来,很可能会出现第二个专门用于64位状态(即,使用long控制参数)的基类将被添加。然而,现在没有一个令人信服的理由把它包括在这个包中。目前,32位对大多数应用程序来说已经足够了。只有一个java.util.concurrent并发同步器类CyclicBarrier需要更多的位来维护状态,因此它使用锁(与包中大多数高级实用程序一样)。
基于AbstractQueuedSynchronizer的具体类必须根据这些导出的状态定义tryacquisition和tryRelease方法,以便控制获取acquire和释放release操作。如果获得了同步,tryacquisition方法必须返回true;如果新的同步状态允许将来获得,tryRelease方法必须返回true。这些方法接受一个int参数,该参数可用于通信所需的状态。例如,在重入锁中,要在从条件wait返回后重新获取锁时重新建立递归计数。许多同步器不需要这样的参数,所以忽略它。
3.2 阻塞
在JSR166之前,还没有可用的Java API来阻塞和取消阻塞线程,以便创建不基于内置监视器的同步器。唯一可以选择的是Thread.suspend和Thread.resume,但是它们都有无法解决的竞态问题,所以也没法用。如果一个未阻塞的线程在阻塞线程执行挂起(suspend)之前调用恢复(resume),那么resume操作将不起作用。
java.util.concurrent.locks 包包含了LockSupport类,类中有方法针对这个问题进行处理。LockSupport.park方法阻塞当前线程,直到或者除非LockSupport.unpark这个方法被处理。(虚拟唤醒也是被支持的)。对unpark的调用不被“计数”,因此在一个park前多个unpark只会取消对一个park的阻塞。此外,这适用于per-thread,而不是per-synchronizer。调用新同步器上的park的线程可能会立即返回,因为在以前的使用中有一个“剩余的”unpark。然而,在没有unpark的情况下,它的下一个调用将被阻塞。虽然可以显式地清除这种状态,但是不值得这样做。当需要多次调用park时,调用它会更有效。这个简单的机制在某种程度上类似于solars -9线程库[11]、WIN32“消耗性事件”和Linux NPTL线程库中使用的机制,因此可以有效地映射到Java运行的最常见平台上的每一个线程库。(不过,为了适应现有的运行时设计,Solaris和Linux上当前的Sun Hotspot JVM参考实现实际上使用了pthread condvar。), park方法还支持可选的相对和绝对超时,并与JVM线程.interrupt支持集成——中断一个线程将其unparks。
3.3 队列
框架的核心是维护阻塞线程的队列,这里将其限制为FIFO队列。因此,该框架不支持基于优先级的同步。
目前,几乎没有争议的是,同步队列的最合适选择是非阻塞数据结构,这些数据结构本身不需要使用低级锁来构造。其中,有两个主要的候选:Mellor-Crummey和Scott (MCS)锁[9]的变体,以及Craig, Landin和Hagersten (CLH)锁[5][8][10]的变体。历史上,CLH锁只在自旋锁中使用。但是,在同步器框架中,它们看起来比MCS更易于使用,因为它们更容易适应处理取消和超时,所以选择了MCS作为基础。最终的设计与原来的CLH结构需要解释的相差甚远。
CLH队列不是很像队列,因为它的入队列和出队列操作与它作为锁的使用密切相关。它是一个链接队列,通过两个原子可更新字段head和tail访问,它们最初都指向一个虚拟节点。
新节点node使用原子操作进入队列:
do { pred = tail;
} while(!tail.compareAndSet(pred, node));
每个节点的发布状态都保存在其前身节点中。
所以,自旋锁的“自旋”看起来是这样的:
while (pred.status != RELEASED) ; // spin
旋转之后的dequeue操作只需要设置head字段指向刚刚获得锁的节点:
head = node;
CLH锁的优点之一是排队和退出队列速度快、无锁、无阻塞(即使在争用的情况下,一个线程也总是会赢得插入竞争,所以会取得进展).
检测是否有线程正在等待也是快速的(只要检查头部是否与尾部相同);而且发布状态是分散的,避免了一些内存争用。
在CLH锁的最初版本中,甚至没有连接节点的链接。在自旋锁中,pred变量可以作为局部变量保存。然而,Scott和Scherer[10]表明,通过显式地维护节点中的前置字段,CLH锁可以处理超时和其他形式的取消:如果节点的前置取消了,则节点可以向上滑到使用前一个节点的状态字段。
使用CLH队列来阻塞同步器所需的主要额外修改是为一个节点提供一种有效的方法来定位其后续节点。在自旋锁中,节点只需要更改其状态,它的后续节点将在下一次自旋时注意到这一点,因此链接是不必要的。但是在阻塞同步器中,节点需要显式地唤醒(unpark)它的后续。AbstractQueuedSynchronizer队列节点包含到其后续节点的下一个链接。但是,由于没有使用compareAndSet实现双链表节点的无锁原子插入的实用技术,所以这个链接不是原子插入的一部分;它只是简单地在插入后赋值。pred.next = node; 这反映在所有的用法中。next链接只作为优化路径处理。如果节点的后继节点似乎不存在(或似乎被取消),则始终可以从列表的末尾开始,然后使用pred字段向后遍历,以准确地检查是否真的存在。
第二组修改是使用每个节点中保存的status字段来控制阻塞,而不是旋转。在同步器框架中,排队的线程只有通过在具体子类中定义的tryacquisition方法才能从获取操作返回;一个单独的“released”位是不够的。但是仍然需要控制,以确保活动线程只允许在位于队列头部时调用tryacquisition。在这种情况下,它可能无法获得和(重新)阻塞。这不需要每个节点的状态标志,因为可以通过检查当前节点的前身是否为head来确定权限。与自旋锁的情况不同,没有足够的内存争用读取头来保证复制。但是,取消状态必须仍然存在于状态字段中。
队列节点状态字段还用于避免不必要的调用park和unpark。虽然这些方法作为阻塞原语相对较快,但是在Java和JVM运行时以及/或OS之间的边界交叉时,它们会遇到可避免的开销。调用park之前,线程设置signal me位,并且调用park之前再次检查同步和节点状态。释放线程会清除状态。这将避免线程不必要地频繁尝试阻塞,特别是对于锁类,在锁类中,等待下一个符合条件的线程获得锁所花费的时间会加重其他争用效果。这也避免了需要释放线程来确定它的后继线程,除非后继线程已经设置了信号位。这反过来又消除了必须遍历多个节点才能处理明显为空的next字段的情况,除非在取消的同时发出信号。同步器框架中使用的CLH锁的变体与其他语言中使用的CLH锁的主要区别可能在于,依赖垃圾收集来管理节点的存储回收,从而避免了复杂性和开销。然而,依赖GC仍然会导致链接字段为空,而这些字段肯定永远不需要。这通常可以在退出队列时完成。否则,未使用的节点仍然是可访问的,导致它们不可收集。J2SE1.5版本的源代码文档中描述了一些更小的调优,包括CLH队列在第一次争用时所需的初始虚拟节点的延迟初始化。
略去这些细节,基本获取操作(排他的、不可中断的、不定时的)的最终实现的一般形式是:
if (!tryAcquire(arg)) {
node = create and enqueue new node;
pred = node’s effective predecessor;
while (pred is not head node || !tryAcquire(arg)) {
if (pred’s signal bit is set)
park();
else
compareAndSet pred’s signal bit to true;
pred = node’s effective predecessor;
}
head = node;
}
释放操作是:
if (tryRelease(arg) && head node’s signal bit is set) {
compareAndSet head’s signal bit to false;
unpark head’s successor, if one exists
}
当然,主获取循环的迭代次数取决于tryacquisition的性质。否则,在没有取消的情况下,获取和释放的每个组件都是一个固定时间的O(1)操作,跨线程平摊。不管park在任何操作系统线程调度发生。
取消支持主要是在获取循环中每次从park返回时检查中断或超时。由于超时或中断而被取消的线程将设置其节点状态并卸载其后续线程,以便重新设置链接。取消时,确定前代和后代以及重置状态可能包括O(n)遍历(其中n是队列的长度)。因为线程再也不会阻塞已取消的操作,所以链接和状态字段趋向于快速恢复。
3.4条件队列
同步器框架提供了一个条件对象类,供同步器使用,同步器维护和符合独占同步锁接口。可以将任意数量的条件对象附加到锁对象上,提供经典的监视器风格的等待、信号和信号所有操作,包括超时操作,以及一些检查和监视方法。通过修复一些设计决策,条件对象类使条件能够有效地与其他同步操作集成。该类只支持java风格的monitor访问规则,其中只有当拥有条件的锁由当前线程持有时,条件操作才是合法的(有关替代方法的讨论,请参阅[4])。因此,附加到ReentrantLock的条件对象的行为与内置监视器(通过对象等等)的行为相同),只在方法名、额外功能以及每个锁可以声明多个条件等方面有所不同。条件对象使用与同步器相同的内部队列节点,但在单独的条件队列中维护它们。信号操作实现为从条件队列到锁队列的队列传输,而不必在发出信号的线程重新获得锁之前唤醒它。
基本的await操作如下:
create and add new node to condition queue;
release lock;
block until node is on lock queue;
re-acquire lock;
signal操作如下:
transfer the first node from condition queue to lock queue;
因为这些操作只在持有锁时执行,所以它们可以使用顺序链接的队列操作(在节点中使用nextWaiter字段)来维护条件队列。传输操作只是从条件队列断开第一个节点的链接,然后使用CLH插入将其附加到锁队列。
实现这些操作的主要复杂性是处理由于超时或Thread.interrupt而取消条件等待。约在同一时间发生的取消和信号遇到比赛,其结果符合内置监视器的规范。正如JSR133中修改过的,这些规则要求,如果中断发生在信号之前,那么wait方法必须在重新获取锁之后抛出InterruptedException。但是如果它在一个信号之后被中断,那么这个方法必须返回而不抛出异常,但是要设置它的线程中断状态。
为了保持适当的顺序,队列节点状态中的一个位记录节点是否已经(或正在)被传输。信令代码和取消代码都试图比较和设置此状态。如果一个信号操作丢失了这个竞争,那么它将传输队列上的下一个节点(如果存在下一个节点)。如果取消操作失败,则必须中止传输,然后等待锁的重新获取。后一种情况引入了潜在的无界自旋。被取消的等待在节点成功插入锁队列之前不能开始锁的重新获取,因此必须旋转等待信号线程执行CLH队列插入compareAndSet才能成功。这里很少需要旋转,需要使用线程。提供调度提示,指示其他线程(理想情况下是执行信号的线程)应该运行。虽然可以在这里实现帮助取消插入节点的策略,但是这种情况非常罕见,无法证明这将带来额外的开销。在所有其他情况下,这里和其他地方的基本机制都不使用自旋或yield,这在单处理器上保持了合理的性能。
类AbstractQueuedSynchronizer将上述功能绑定在一起,作为同步器的“模板方法模式”[6]基类。子类只定义实现控制获取和发布的状态检查和更新的方法。然而,AbstractQueuedSynchronizer的子类本身并不能作为同步器adt使用,因为该类必须导出内部控制获取和发布策略所需的方法,这些方法不应该对这些类的用户可见。所有java.util.concurrent synchronizer类声明一个私有的内部AbstractQueuedSynchronizer子类,并将所有同步方法委托给它。这还允许为公共方法指定适合于同步器的名称。例如,这里有一个最小的互斥锁类,它使用同步状态0表示解锁,使用状态1表示锁定。该类不需要同步方法支持的值参数,因此使用zero,否则将忽略它们。
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文档中找到。当然,有许多变体是可能的。例如,tryacquisition可以使用“test- stand-test-and-set”,在尝试更改状态值之前检查状态值。
令人惊讶的是,像互斥锁这样对性能敏感的构造居然是使用委托和虚拟方法的组合来定义的。然而,这些是现代动态编译器长期关注的面向对象设计结构。至少在频繁调用同步器的代码中,它们往往能够很好地优化掉这种开销。
类AbstractQueuedSynchronizer还提供了一些方法来辅助策略控制中的同步器类。例如,它包含基本获取方法的超时和可中断版本。虽然迄今为止,讨论集中在某个浏览器独占模式如锁。 AbstractQueuedSynchronizer类还包含一组并行的方法(如acquireShared)的区别在于tryAcquireShared和tryReleaseShared方法可以通知框架(通过他们的返回值),进一步获得可能,最终导致多个线程通过级联信号醒来。虽然序列化(持久地存储或传输)同步器通常是不明智的,但是这些类通常被用来构造其他类,例如通常序列化的线程安全集合。
AbstractQueuedSynchronizer和ConditionObject类提供了序列化同步状态的方法,但没有提供底层阻塞线程或其他本质上是临时bookkeeping的方法。尽管如此,大多数同步器类只是在反序列化时将同步状态重置为初始值,这与内置锁的隐式策略保持一致,即始终反序列化为解锁状态。这相当于一个no-op,但仍然必须显式地支持,以启用最终字段的反序列化。
4.1控制失败
即使同步器基于FIFO队列,它们也不一定是公平的。注意,在基本获取算法(第3.3节)中,tryacquisition检查在排队之前执行。因此,一个新获取的线程可以“窃取”队列首的第一个线程的访问权限。
这种快速FIFO策略通常比其他技术提供更高的总吞吐量。它减少了争用锁可用但没有线程拥有争用锁的时间,因为预期的下一个线程正在解除阻塞。与此同时,它只允许一个(第一个)排队的线程在任何发布时醒来并尝试获取,从而避免了过多的、没有效率的争用。
创建同步器的开发人员可能会通过在传递回控制之前将tryacquisition定义为自身重试几次,从而在预期同步器只会被短暂持有的情况下,进一步强调跳转效果。
阻塞FIFO同步器只有概率公平属性。位于锁队列头部的未停放线程有无偏倚的机会赢得与任何传入的阻塞线程的比赛,如果失败,则重新阻塞并重试。但是,如果传入的线程比未停放的线程更快到达,则需要释放阻塞。
但是,如果传入的线程到达的速度比未停放的线程释放阻塞的速度快,那么队列中的第一个线程很少会赢得比赛,因此几乎总是会重新阻塞,而它的后续线程仍然被阻塞。对于briefly持有的同步器,在第一个线程解阻塞期间,多处理器上通常会发生多个bargings和release。如下所示,净效果是保持一个或多个线程的高进度,同时至少在概率上避免了饿死。
当需要更大的公平性时,安排它是一件相对简单的事情。序员可以定义需要严格公平性的程tryAcquire to fail(返回false),如果当前线程不在队列的最前面,则使用getFirstQueuedThread方法检查这个函数,这是提供的几种检查方法之一。
一个更快、更不严格的变体是,如果队列(暂时)为空,还允许tryAcquire成功。在这种情况下,遇到空队列的多个线程可能会争着成为第一个获得该队列的线程,通常情况下不需要对其中至少一个队列进行排队。所有java.util.concurrent同步都采用了支持“公平”模式的这种策略。
虽然公平设置在实践中很有用,但它没有保证,因为Java语言规范没有提供调度保证。例如,即使使用严格公平的同步器,如果一组线程不需要阻塞彼此等待,JVM也可以决定完全按顺序运行一组线程。实际上,在单处理器上,这样的线程很可能每次运行一段时间,然后预先切换上下文。如果这样一个线程持有一个独占锁,那么它很快就会被短暂地切换回来,只是在知道另一个线程需要锁之后才释放锁并阻塞。因此,进一步增加同步器可用但不被获取的周期。同步器公平性设置往往对多处理器有更大的影响,这会产生更多的交错,因此一个线程有更多的机会发现另一个线程需要锁。
尽管在保护briefly持有的代码体时,它们在高争用下可能表现得很差,但是公平锁的工作很好。例如,当它们保护相对较长的代码体和/或具有相对较长的互锁间隔时,在这种情况下,barging提供的性能优势很小,但是无限期延迟的风险更大。同步器框架将这样的工程决策留给它的用户。
4.2 同步
下面是使用这个框架定义java.util.concurrent synchronizer类的示意图。ReentrantLock类使用同步状态来保存(递归)锁计数。当获取锁时,它还记录当前线程的标识,以检查递归,并在错误的线程试图解锁时检测非法状态异常。该类还使用提供的condition对象,并导出其他监视和检查方法。该类通过在内部声明两个不同的AbstractQueuedSynchronizer子类(禁用barging的子类),并设置每个ReentrantLock实例在构造时使用适当的类,从而支持可选的“公平”模式。
ReentrantReadWriteLock类使用同步状态的16位来保存写锁计数,其余的16位保存读锁计数。WriteLock的其他结构与ReentrantLock相同。ReadLock使用默认的方法来启用多个阅读器。
信号量类(计数信号量)使用同步状态来保存当前计数。它定义了acquirered来递减count或block(如果是非正数),并尝试release来增加count(如果它现在是正数),可能会释放阻塞线程。
CountDownLatch类使用同步状态来表示计数。当它达到零时,所有的都获得通过。
FutureTask类使用同步状态来表示未来的运行状态(初始、运行、取消、完成)。设置或取消将来的调用版本,通过获取解除阻塞等待其计算值的线程。
SynchronousQueue类(一种csp样式的切换)使用内部的等待节点来匹配生产者和消费者。它使用同步状态来允许生产者在消费者接受该项目时继续进行,反之亦然。java.util.concurrent 包的使用当然可以为自定义应用程序定义自己的同步器。例如,在包中考虑但没有采用的类中,有一些类提供了各种WIN32事件、二进制锁、集中管理锁和基于树的屏障的语义。
虽然同步器框架除了互斥锁之外还支持许多其他类型的同步,但是锁的性能是最容易测量和比较的。即便如此,还是有许多不同的测量方法。这里的实验旨在揭示开销和吞吐量。
在每个测试中,每个线程都重复地更新使用函数计算的伪随机数:nextRandom(int seed):
int t = (seed % 127773) * 16807 –
(seed / 127773) * 2836;
return (t > 0)? t : t + 0x7fffffff;
在每个迭代中,一个线程以概率S更新互斥锁下的共享生成器,否则它将在没有锁的情况下更新自己的本地生成器。这将导致短时间的锁定区域,当线程在持有锁时被抢占时,会将无关的影响降到最低。函数的随机性有两个目的:它用于决定是否锁定(对于当前的目的,它是一个足够好的生成器),还使得循环内的代码不可能被简单地优化掉。
比较了四种锁:内置锁、同步锁;互斥量,使用一个简单的互斥量类,如第4节所示;可重入,使用ReentrantLock;并且公平,使用在其“公平”模式中设置的ReentrantLock。所有测试都使用Sun J2SE1.5 JDK在“server"模式下的build 46(与beta2大致相同)。测试程序在收集测量值之前执行20次非竞争运行,以消除热身效果。每个线程运行1000万个迭代的测试,除了公平模式测试只运行100万个迭代。
在4台基于x86的机器和4台基于UltraSparc的机器上进行了测试。所有x86机器都使用基于RedHat nptl的2.4内核和库运行Linux。所有超parc机器都在运行Solaris-9。在测试时,所有系统的负载都很轻。测试的性质并不要求它们完全空闲。“4P”名称反映了一个事实,即双超线程(HT) Xeon的行为更像一个4路而不是2路机器。这里没有试图使分歧正常化。如下所示,同步的相对成本与处理器的数量、类型或速度没有简单的关系。
5.1开销
无争用开销是通过只运行一个线程来度量的,用S=1的运行减去版本设置为S=0(访问共享随机的概率为0)时每次迭代所花费的时间。表2显示了同步代码对非同步代码的每次锁开销的估计,单位为纳秒。互斥类最接近于测试框架的基本成本。重入锁的额外开销指示记录当前所有者线程和错误检查的成本,而对于公平锁,则指示首先检查队列是否为空的额外成本。
表2还显示了tryacquisition的成本与内置锁的“快速路径”的比较。这里的差异主要反映了跨锁和机器使用不同原子指令和内存屏障的成本。在多处理器上,这些指令往往完全压倒其他所有指令。内建类和同步器类之间的主要区别显然是由于热点锁使用compareAndSet进行锁定和解锁,而这些同步器使用compareAndSet进行获取和volatile写(即,在多处理器上设置内存屏障,并在所有处理器上重新排序约束)。每种机器的绝对成本和相对成本各不相同。
在另一个极端,表3显示了每个锁的开销,S=1,运行256个并发线程,导致大量锁争用。在完全饱和的情况下,bar- fifo锁的开销比内置锁低一个数量级(相当于更高的吞吐量),通常比公平锁低两个数量级。这证明了bar- fifo策略在极端争用情况下保持线程进程的有效性。
表3还说明,即使内部开销很低,上下文切换时间也完全决定了公平锁的性能。列出的时间大致与在各种平台上阻塞和解除阻塞线程的时间成正比。
此外,后续的实验(仅使用machine 4P)表明,对于这里使用的非常短暂的持有锁,公平性设置对总体方差的影响很小。线程终止时间的差异被记录为粗粒度的可变性度量。在机器4P上运行的时间的标准差为平均值的0.7%,而在可重入性上为6.0%。相反,为了模拟长时间持有的锁,运行了一个版本的测试,其中每个线程在持有每个锁的同时计算16K个随机数。在这里,总运行时间几乎相同(Fair为9.79秒,Reentrant为9.72秒)。均匀模式变异性仍然很小,标准差为均值的0.1%,而可重入性上升到均值的29.5%。
5.2吞吐量
大多数同步器的使用将介于无争用和饱和的极端之间。通过改变一组固定线程的争用概率和/或向一组具有固定争用概率的线程添加更多的线程,可以从两个维度对其进行实验检验。为了说明这些效果,我们使用不同的争用概率和线程数运行测试,所有这些测试都使用可重入锁。相关数据使用了一个放缓指标
这里,t为观察到的总执行时间,b为一个没有争用或同步的线程的基线时间,n为线程数,p为处理器数,S为共享访问的比例。这个值是观察到的时间与(通常无法达到的)理想执行时间的比值,使用Amdahl定律计算了顺序和并行任务的混合。理想时间为执行建模,在没有任何同步开销的情况下,不会因为与其他线程发生冲突而导致线程阻塞。即便如此,在非常低的争用情况下,一些测试结果显示的速度与理想情况相比非常小,这可能是由于基线与测试运行之间的优化、管道等方面的细微差异造成的。这些数字使用以2为底的对数刻度。例如,值1.0表示测量时间是理想情况下时间的两倍,值4.0表示慢16倍。logsameliorates的使用依赖于任意的基本时间(这里是计算随机数的时间),因此不同基础计算的结果应该显示类似的趋势。测试使用的争用概率从1/128(标记为“0.008”)到1,步进2的幂次,线程数从1到1024,步进2的半幂次。在单处理器上(1P和1U),性能随着争用的增加而下降,但通常不会随着线程数量的增加而下降。多处理器在争用下通常会遇到更糟糕的慢速。多处理器的图表显示了一个早期的峰值,在这个峰值中,只涉及几个线程的争用通常会产生最差的相对性能。这反映了性能的一个过渡区域,在这个过渡区域中,barging和发出信号的线程获得锁的可能性大致相同,因此常常相互阻塞。在大多数情况下,这之后是一个更平滑的区域,因为锁几乎永远不可用,导致访问类似于单处理器的近顺序模式;在拥有更多处理器的机器上更快地实现这一点。例如,请注意,在处理器较少的机器上,全争用的值(标记为“1.000”)表现出相对较差的慢速。
根据这些结果,进一步调优阻塞(park/unpark)支持,以减少上下文切换和相关开销,似乎可以在这个框架中提供小而明显的改进。
此外,同步器类为多处理器上的briefly持有的高度争用锁采用某种形式的自适应旋转,以避免这里看到的一些抖动,这可能会带来好处。虽然自适应自旋很难在不同的上下文中很好地工作,但是可以使用这个框架构建自定义形式的锁,针对遇到这种使用概要的特定应用程序。
在撰写本文时,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(用于访问24路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.
我上传了一份PDF版本在CSDN下载频道,好像现在默认都要收5个豆豆,计划中是免费共享来的。
https://download.csdn.net/download/sushengmiyan/11191274