JUC同步框架(譯)

前言

最近幾個週末都在學習JUC有關的東西,在學習AQS時在網上發現了《The java.util.concurrent Synchronizer Framework》這篇論文的中文翻譯,因此分享下。


英文版地址:http://gee.cs.oswego.edu/dl/papers/aqs.pdf

摘要

在J2SE 1.5的java.util.concurrent包(下稱j.u.c包)中,大部分的同步器(例如鎖,屏障等等)都是基於AbstractQueuedSynchronizer類(下稱AQS類),這個簡單的框架而構建的。這個框架爲同步狀態的原子性管理、線程的阻塞和解除阻塞以及排隊提供了一種通用的機制。這篇論文主要描述了這個框架基本原理、設計、實現、用法以及性能。


1. 背景介紹

通過JCP的JSR166規範,Java的1.5版本引入了j.u.c包,這個包提供了一系列支持中等程度併發的類。這些組件是一系列的同步器(抽象數據類型(ADT))。這些同步器主要維護着以下幾個功能:內部同步狀態的管理(例如:表示一個鎖的狀態是獲取還是釋放),同步狀態的更新和檢查操作,且至少有一個方法會導致調用線程在同步狀態被獲取時阻塞,以及在其他線程改變這個同步狀態時解除線程的阻塞。上述的這些的實際例子包括:互斥排它鎖的不同形式、讀寫鎖、信號量、屏障、Future、事件指示器以及傳送隊列等。

幾乎任一同步器都可以用來實現其他形式的同步器。例如,可以用可重入鎖實現信號量或者用信號量實現可重入鎖。但是,這樣做帶來的複雜性,開銷,不靈活使其至多隻能是個二流工程。且缺乏吸引力。如果任何這樣的構造方式不能在本質上比其他形式更簡潔,那麼開發者就不應該隨意地選擇其中的某個來構建另一個同步器。取而代之,JSR166建立了一個小框架,AQS類。這個框架爲構造同步器提供一種通用的機制,並且被j.u.c包中大部分類使用,同時很多用戶也用它來定義自己的同步器。

在這篇論文的下面部分會討論這個框架的需求、設計與實現背後的主要思路、示例用法,以及性能指標的一些測量。


2 需求

2.1 功能

同步器一般包含兩種方法,一種是acquire,另一種是release。acquire操作阻塞調用的線程,直到或除非同步狀態允許其繼續執行。而release操作則是通過某種方式改變同步狀態,使得一或多個被acquire阻塞的線程繼續執行。

j.u.c包中並沒有對同步器的API做一個統一的定義。因此,有一些類定義了通用的接口(如Lock),而另外一些則定義了其專有的版本。因此在不同的類中,acquire和release操作的名字和形式會各有不同。例如:Lock.lock,Semaphore.acquire,CountDownLatch.await和FutureTask.get,在這個框架裏,這些方法都是acquire操作。但是,J.U.C爲支持一系列常見的使用選項,在類間都有個一致約定。在有意義的情況下,每一個同步器都支持下面的操作:

  • 阻塞和非阻塞(例如tryLock)同步。

  • 可選的超時設置,讓調用者可以放棄等待

  • 通過中斷實現的任務取消,通常是分爲兩個版本,一個acquire可取消,而另一個不可以。

同步器的實現根據其狀態是否獨佔而有所不同。獨佔狀態的同步器,在同一時間只有一個線程可以通過阻塞點,而共享狀態的同步器可以同時有多個線程在執行。一般鎖的實現類往往只維護獨佔狀態,但是,例如計數信號量在數量許可的情況下,允許多個線程同時執行。爲了使框架能得到廣泛應用,這兩種模式都要支持。

j.u.c包裏還定義了Condition接口,用於支持監控形式的await/signal操作,這些操作與獨佔模式的Lock類有關,且Condition的實現天生就和與其關聯的Lock類緊密相關。

2.2 性能目標

Java內置鎖(使用synchronized的方法或代碼塊)的性能問題一直以來都在被人們關注,並且已經有一系列的文章描述其構造(例如引文[1],[3])。然而,大部分的研究主要關注的是在單核處理器上大部分時候使用於單線程上下文環境中時,如何儘量降低其空間(因爲任何的Java對象都可以當成是鎖)和時間的開銷。對於同步器來說這些都不是特別重要:程序員僅在需要的時候纔會使用同步器,因此並不需要壓縮空間來避免浪費,並且同步器幾乎是專門用在多線程設計中(特別是在多核處理器上),在這種環境下,偶爾的競爭是在意料之中的。因此,常規的JVM鎖優化策略主要是針對零競爭的場景,而其它場景則使用缺乏可預見性的“慢速路徑(slow paths)” ,所以常規的JVM鎖優化策略並不適用於嚴重依賴於J.U.C包的典型多線程服務端應用。

這裏主要的性能目標是可伸縮性,即在大部分情況下,即使,或特別在同步器有競爭的情況下,穩定地保證其效率。在理想的情況下,不管有多少線程正試圖通過同步點,通過同步點的開銷都應該是個常量。在某一線程被允許通過同步點但還沒有通過的情況下,使其耗費的總時間最少,這是主要目標之一。然而,這也必須考慮平衡各種資源,包括總CPU時間的需求,內存負載以及線程調度的開銷。例如:獲取自旋鎖通常比阻塞鎖所需的時間更短,但是通常也會浪費CPU時鐘週期,並且造成內存競爭,所以使用的並不頻繁。

實現同步器的這些目標包含了兩種不同的使用類型。大部分應用程序是最大化其總的吞吐量,容錯性,並且最好保證儘量減少飢餓的情況。然而,對於那些控制資源分配的程序來說,更重要是去維持多線程讀取的公平性,可以接受較差的總吞吐量。沒有任何框架可以代表用戶去決定應該選擇哪一個方式,因此,應該提供不同的公平策略。

無論同步器的內部實現是多麼的精雕細琢,它還是會在某些應用中產生性能瓶頸。因此,框架必須提供相應的監視工具讓用戶發現和緩和這些瓶頸。至少需要提供一種方式來確定有多少線程被阻塞了。


3 設計與實現

同步器背後的基本思想非常簡單。acquire操作如下:

1

2

3

4

5

while (synchronization state does not allow acquire) {

    enqueue current thread if not already queued;

    possibly block current thread;

}

dequeue current thread if it was queued;

release操作如下:

1

2

3

update synchronization state;

if (state may permit a blocked thread to acquire)

    unblock one or more queued threads;

爲了實現上述操作,需要下面三個基本組件的相互協作:

  • 同步狀態的原子性管理;

  • 線程的阻塞與解除阻塞;

  • 隊列的管理;

創建一個框架分別實現這三個組件是有可能的。但是,這會讓整個框架既難用又沒效率。例如:存儲在隊列節點的信息必須與解除阻塞所需要的信息一致,而暴露出的方法的簽名必須依賴於同步狀態的特性。

同步器框架的核心決策是爲這三個組件選擇一個具體實現,同時在使用方式上又有大量選項可用。這裏有意地限制了其適用範圍,但是提供了足夠的效率,使得實際上沒有理由在合適的情況下不用這個框架而去重新建造一個。

3.1 同步狀態

AQS類使用單個int(32位)來保存同步狀態,並暴露出getStatesetState以及compareAndSet操作來讀取和更新這個狀態。這些方法都依賴於j.u.c.atomic包的支持,這個包提供了兼容JSR133中volatile在讀和寫上的語義,並且通過使用本地的compare-and-swap或load-linked/store-conditional指令來實現compareAndSetState,使得僅當同步狀態擁有一個期望值的時候,纔會被原子地設置成新值。

將同步狀態限制爲一個32位的整形是出於實踐上的考量。雖然JSR166也提供了64位long字段的原子性操作,但這些操作在很多平臺上還是使用內部鎖的方式來模擬實現的,這會使同步器的性能可能不會很理想。當然,將來可能會有一個類是專門使用64位的狀態的。然而現在就引入這麼一個類到這個包裏並不是一個很好的決定(譯者注:JDK1.6中已經包含java.util.concurrent.locks.AbstractQueuedLongSynchronizer類,即使用 long 形式維護同步狀態的一個 AbstractQueuedSynchronizer 版本)。目前來說,32位的狀態對大多數應用程序都是足夠的。在j.u.c包中,只有一個同步器類可能需要多於32位來維持狀態,那就是CyclicBarrier類,所以,它用了鎖(該包中大多數更高層次的工具亦是如此)。

基於AQS的具體實現類必須根據暴露出的狀態相關的方法定義tryAcquiretryRelease方法,以控制acquire和release操作。當同步狀態滿足時,tryAcquire方法必須返回true,而當新的同步狀態允許後續acquire時,tryRelease方法也必須返回true。這些方法都接受一個int類型的參數用於傳遞想要的狀態。例如:可重入鎖中,當某個線程從條件等待中返回,然後重新獲取鎖時,爲了重新建立循環計數的場景。很多同步器並不需要這樣一個參數,因此忽略它即可。

3.2 阻塞

在JSR166之前,阻塞線程和解除線程阻塞都是基於Java內置監視器,沒有基於Java API可以用來創建同步器。唯一可以選擇的是Thread.suspendThread.resume,但是它們都有無法解決的競態問題,所以也沒法用:當一個非阻塞的線程在一個正準備阻塞的線程調用suspend前調用了resume,這個resume操作將不會有什麼效果。

j.u.c包有一個LockSuport類,這個類中包含了解決這個問題的方法。方法LockSupport.park阻塞當前線程除非/直到有個LockSupport.unpark方法被調用(unpark方法被提前調用也是可以的)。unpark的調用是沒有被計數的,因此在一個park調用前多次調用unpark方法只會解除一個park操作。另外,它們作用於每個線程而不是每個同步器。一個線程在一個新的同步器上調用park操作可能會立即返回,因爲在此之前可能有“剩餘的”unpark操作。但是,在缺少一個unpark操作時,下一次調用park就會阻塞。雖然可以顯式地消除這個狀態(譯者注:就是多餘的unpark調用),但並不值得這樣做。在需要的時候多次調用park會更高效。

這個簡單的機制與有些用法在某種程度上是相似的,例如Solaris-9的線程庫,WIN32中的“可消費事件”,以及Linux中的NPTL線程庫。因此最常見的運行Java的平臺上都有相對應的有效實現。(但目前Solaris和Linux上的Sun Hotspot JVM參考實現實際上是使用一個pthread的condvar來適應目前的運行時設計的)。park方法同樣支持可選的相對或絕對的超時設置,以及與JVM的Thread.interrupt結合 —— 可通過中斷來unpark一個線程。

3.3 隊列

整個框架的關鍵就是如何管理被阻塞的線程的隊列,該隊列是嚴格的FIFO隊列,因此,框架不支持基於優先級的同步。

同步隊列的最佳選擇是自身沒有使用底層鎖來構造的非阻塞數據結構,目前,業界對此很少有爭議。而其中主要有兩個選擇:一個是Mellor-Crummey和Scott鎖(MCS鎖)[9]的變體,另一個是Craig,Landin和Hagersten鎖(CLH鎖)[5][8][10]的變體。一直以來,CLH鎖僅被用於自旋鎖。但是,在這個框架中,CLH鎖顯然比MCS鎖更合適。因爲CLH鎖可以更容易地去實現“取消(cancellation)”和“超時”功能,因此我們選擇了CLH鎖作爲實現的基礎。但是最終的設計已經與原來的CLH鎖有較大的出入,因此下文將對此做出解釋。

CLH隊列實際上並不那麼像隊列,因爲它的入隊和出隊操作都與它的用途(即用作鎖)緊密相關。它是一個鏈表隊列,通過兩個字段headtail來存取,這兩個字段是可原子更新的,兩者在初始化時都指向了一個空節點。

一個新的節點,node,通過一個原子操作入隊:

1

2

3

do {

    pred = tail;

while(!tail.compareAndSet(pred, node));

每一個節點的“釋放”狀態都保存在其前驅節點中。因此,自旋鎖的“自旋”操作就如下:

1

while (pred.status != RELEASED); // spin

自旋後的出隊操作只需將head字段指向剛剛得到鎖的節點:

1

head = node;

CLH鎖的優點在於其入隊和出隊操作是快速、無鎖的,以及無障礙的(即使在競爭下,某個線程總會贏得一次插入機會而能繼續執行);且探測是否有線程正在等待也很快(只要測試一下head是否與tail相等);同時,“釋放”狀態是分散的(譯者注:幾乎每個節點都保存了這個狀態,當前節點保存了其後驅節點的“釋放”狀態,因此它們是分散的,不是集中於一塊的。),避免了一些不必要的內存競爭。

在原始版本的CLH鎖中,節點間甚至都沒有互相鏈接。自旋鎖中,pred變量可以是一個局部變量。然而,Scott和Scherer證明了通過在節點中顯式地維護前驅節點,CLH鎖就可以處理“超時”和各種形式的“取消”:如果一個節點的前驅節點取消了,這個節點就可以滑動去使用前面一個節點的狀態字段。

爲了將CLH隊列用於阻塞式同步器,需要做些額外的修改以提供一種高效的方式定位某個節點的後繼節點。在自旋鎖中,一個節點只需要改變其狀態,下一次自旋中其後繼節點就能注意到這個改變,所以節點間的鏈接並不是必須的。但在阻塞式同步器中,一個節點需要顯式地喚醒(unpark)其後繼節點。

AQS隊列的節點包含一個next鏈接到它的後繼節點。但是,由於沒有針對雙向鏈表節點的類似compareAndSet的原子性無鎖插入指令,因此這個next鏈接的設置並非作爲原子性插入操作的一部分,而僅是在節點被插入後簡單地賦值:

1

pred.next = node;

next鏈接僅是一種優化。如果通過某個節點的next字段發現其後繼結點不存在(或看似被取消了),總是可以使用pred字段從尾部開始向前遍歷來檢查是否真的有後續節點。

第二個對CLH隊列主要的修改是將每個節點都有的狀態字段用於控制阻塞而非自旋。在同步器框架中,僅在線程調用具體子類中的tryAcquire方法返回true時,隊列中的線程才能從acquire操作中返回;而單個“released”位是不夠的。但仍然需要做些控制以確保當一個活動的線程位於隊列頭部時,僅允許其調用tryAcquire;這時的acquire可能會失敗,然後(重新)阻塞。這種情況不需要讀取狀態標識,因爲可以通過檢查當前節點的前驅是否爲head來確定權限。與自旋鎖不同,讀取head以保證複製時不會有太多的內存競爭( there is not enough memory contention reading head to warrant replication.)。然而,“取消”狀態必須存在於狀態字段中。

隊列節點的狀態字段也用於避免沒有必要的parkunpark調用。雖然這些方法跟阻塞原語一樣快,但在跨越Java和JVM runtime以及操作系統邊界時仍有可避免的開銷。在調用park前,線程設置一個“喚醒(signal me)”位,然後再一次檢查同步和節點狀態。一個釋放的線程會清空其自身狀態。這樣線程就不必頻繁地嘗試阻塞,特別是在鎖相關的類中,這樣會浪費時間等待下一個符合條件的線程去申請鎖,從而加劇其它競爭的影響。除非後繼節點設置了“喚醒”位(譯者注:源碼中爲-1),否則這也可避免正在release的線程去判斷其後繼節點。這反過來也消除了這些情形:除非“喚醒”與“取消”同時發生,否則必須遍歷多個節點來處理一個似乎爲null的next字段。

同步框架中使用的CLH鎖的變體與其他語言中的相比,主要區別可能是同步框架中使用的CLH鎖需要依賴垃圾回收管理節點的內存,這就避免了一些複雜性和開銷。但是,即使依賴GC也仍然需要在確定鏈接字段不再需要時將其置爲null。這往往可以與出隊操作一起完成。否則,無用的節點仍然可觸及,它們就沒法被回收。

其它一些更深入的微調,包括CLH隊列首次遇到競爭時才需要的初始空節點的延遲初始化等,都可以在J2SE1.5的版本的源代碼文檔中找到相應的描述。

拋開這些細節,基本的acquire操作的最終實現的一般形式如下(互斥,非中斷,無超時):

1

2

3

4

5

6

7

8

9

10

11

12

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)

            pard()

        else

            compareAndSet pred's signal bit to true;

        pred = node's effective predecessor;

    }

    head = node;

}

release操作:


   
   
   
  1. if(tryRelease(arg) && head node's signal bit is set) {

  2. compareAndSet head's bit to false;

  3. unpark head's successor, if one exist

  4. }

acquire操作的主循環次數依賴於具體實現類中tryAcquire的實現方式。另一方面,在沒有“取消”操作的情況下,每一個組件的acquirerelease都是一個O(1)的操作,忽略park中發生的所有操作系統線程調度。

支持“取消”操作主要是要在acquire循環裏的park返回時檢查中斷或超時。由超時或中斷而被取消等待的線程會設置其節點狀態,然後unpark其後繼節點。在有“取消”的情況下,判斷其前驅節點和後繼節點以及重置狀態可能需要O(n)的遍歷(n是隊列的長度)。由於“取消”操作,該線程再也不會被阻塞,節點的鏈接和狀態字段可以被快速重建。

3.4 條件隊列

AQS框架提供了一個ConditionObject類,給維護獨佔同步的類以及實現Lock接口的類使用。一個鎖對象可以關聯任意數目的條件對象,可以提供典型的管程風格的awaitsignalsignalAll操作,包括帶有超時的,以及一些檢測、監控的方法。

通過修正一些設計決策,ConditionObject類有效地將條件(conditions)與其它同步操作結合到了一起。該類只支持Java風格的管程訪問規則,這些規則中,僅噹噹前線程持有鎖且要操作的條件(condition)屬於該鎖時,條件操作纔是合法的(一些替代操作的討論參考[4])。這樣,一個ConditionObject關聯到一個ReentrantLock上就表現的跟內置的管程(通過Object.wait等)一樣了。兩者的不同僅僅在於方法的名稱、額外的功能以及用戶可以爲每個鎖聲明多個條件。

ConditionObject使用了與同步器一樣的內部隊列節點。但是,是在一個單獨的條件隊列中維護這些節點的。signal操作是通過將節點從條件隊列轉移到鎖隊列中來實現的,而沒有必要在需要喚醒的線程重新獲取到鎖之前將其喚醒。

基本的await操作如下:

1

2

3

4

create and add new node to conditon queue;

release lock;

block until node is on lock queue;

re-acquire lock;

signal操作如下:

1

transfer the first node from condition queue to lock queue;

因爲只有在持有鎖的時候才能執行這些操作,因此他們可以使用順序鏈表隊列操作來維護條件隊列(在節點中用一個nextWaiter字段)。轉移操作僅僅把第一個節點從條件隊列中的鏈接解除,然後通過CLH插入操作將其插入到鎖隊列上。

實現這些操作主要複雜在,因超時或Thread.interrupt導致取消了條件等待時,該如何處理。“取消”和“喚醒”幾乎同時發生就會有競態問題,最終的結果遵照內置管程相關的規範。JSR133修訂以後,就要求如果中斷髮生在signal操作之前,await方法必須在重新獲取到鎖後,拋出InterruptedException。但是,如果中斷髮生在signal後,await必須返回且不拋異常,同時設置線程的中斷狀態。

爲了維護適當的順序,隊列節點狀態變量中的一個位記錄了該節點是否已經(或正在)被轉移。“喚醒”和“取消”相關的代碼都會嘗試用compareAndSet修改這個狀態。如果某次signal操作修改失敗,就會轉移隊列中的下一個節點(如果存在的話)。如果某次“取消”操作修改失敗,就必須中止此次轉移,然後等待重新獲得鎖。後面的情況採用了一個潛在的無限的自旋等待。在節點成功的被插到鎖隊列之前,被“取消”的等待不能重新獲得鎖,所以必須自旋等待CLH隊列插入(即compareAndSet操作)被“喚醒”線程成功執行。這裏極少需要自旋,且自旋里使用Thread.yield來提示應該調度某一其它線程,理想情況下就是執行signal的那個線程。雖然有可能在這裏爲“取消”實現一個幫助策略以幫助插入節點,但這種情況實在太少,找不到合適的理由來增加這些開銷。在其它所有的情況下,這個基本的機制都不需要自旋或yield,因此在單處理器上保持着合理的性能。


4 用法

AQS類將上述的功能結合到一起,並且作爲一種基於“模版方法模式”[6]的基類提供給同步器。子類只需定義狀態的檢查與更新相關的方法,這些方法控制着acquire和 release操作。然而,將AQS的子類作爲同步器ADT並不適合,因爲這個類必須提供方法在內部控制acquire和release的規則,這些都不應該被用戶所看到。所有java.util.concurrent包中的同步器類都聲明瞭一個私有的繼承了AbstractQueuedSynchronizer的內部類,並且把所有同步方法都委託給這個內部類。這樣各個同步器類的公開方法就可以使用適合自己的名稱。

下面是一個最簡單的Mutex類的實現,它使用同步狀態0表示解鎖,1表示鎖定。這個類並不需要同步方法中的參數,因此這裏在調用的時候使用0作爲實參,方法實現裏將其忽略。

1

2

3

4

5

6

7

8

9

10

11

12

13

14

class Mutex {

    class Sync extends AbstractQueuedSynchronizer {

        public boolean tryAcquire(int ignore) {

            return compareAndSetState(01);

        }

        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可以使用一種“test-and-test-and-set”策略,即在改變狀態值前先對狀態進行校驗。

令人詫異的是,像互斥鎖這樣性能敏感的東西也打算通過委託和虛方法結合的方式來定義。然而,這正是現代動態編譯器一直在重點研究的面向對象設計結構。編譯器擅長將這方面的開銷優化掉,起碼會優化頻繁調用同步器的那些代碼。

AbstractQueuedSynchronizer類也提供了一些方法用來協助策略控制。例如,基礎的acquire方法有可超時和可中斷的版本。雖然到目前爲止,我們的討論都集中在像鎖這樣的獨佔模式的同步器上,但AbstractQueuedSynchronizer類也包含另一組方法(如acquireShared),它們的不同點在於tryAcquireSharedtryReleaseShared方法能夠告知框架(通過它們的返回值)尚能接受更多的請求,最終框架會通過級聯的signal(cascading signals)喚醒多個線程。

雖然將同步器序列化(持久化存儲或傳輸)一般來說沒有太大意義,但這些類經常會被用於構造其它類,例如線程安全的集合,而這些集合通常是可序列化的。AbstractQueuedSynchronizerConditionObject類都提供了方法用於序列化同步狀態,但不會序列化潛在的被阻塞的線程,也不會序列化其它內部暫時性的簿記(bookkeeping)變量。即使如此,在反序列化時,大部分同步器類也只僅將同步狀態重置爲初始值,這與內置鎖的隱式策略一致 —— 總是反序列化到一個解鎖狀態。這相當於一個空操作,但仍必須顯式地支持以便final字段能夠反序列化。

4.1 公平調度的控制

儘管同步器是基於FIFO隊列的,但它們並不一定就得是公平的。可以注意到,在基礎的acquire算法(3.3節)中,tryAcquire是在入隊前被執行的。因此一個新的acquire線程能夠“竊取”本該屬於隊列頭部第一個線程通過同步器的機會。

闖入的FIFO策略通常會提供比其它技術更高的總吞吐率。當一個有競爭的鎖已經空閒,而下一個準備獲取鎖的線程又正在解除阻塞的過程中,這時就沒有線程可以獲取到這個鎖,如果使用闖入策略,則可減少這之間的時間間隔。與此同時,這種策略還可避免過分的,無效率的競爭,這種競爭是由於只允許一個(第一個)排隊的線程被喚醒然後嘗試acquire操作導致的。在只要求短時間持有同步器的場景中,創建同步器的開發者可以通過定義tryAcquire在控制權返回之前重複調用自己若干次,來進一步凸顯闖入的效果。

可闖入的FIFO同步器只有概率性的公平屬性。鎖隊列頭部一個解除了阻塞的線程擁有一次無偏向的機會(譯者注:即不會偏向隊頭的線程也不會偏向闖入的線程)來贏得與闖入的線程之間的競爭,如果競爭失敗,要麼重新阻塞要麼進行重試。然而,如果闖入的線程到達的速度比隊頭的線程解除阻塞快,那麼在隊列中的第一個線程將很難贏得競爭,以至於幾乎總要重新阻塞,並且它的後繼節點也會一直保持阻塞。對於短暫持有的同步器來說,在隊列中第一個線程被解除阻塞期間,多處理器上很可能發生過多次闖入譯者注:即闖入的線程的acquire操作)和release了。正如下文所提到的,最終結果就是保持一或多個線程的高進展速度的同時,仍至少在一定概率上避免了飢餓的發生。

當有更高的公平性需求時,實現起來也很簡單。如果需要嚴格的公平性,程序員可以把tryAcquire方法定義爲,若當前線程不是隊列的頭節點(可通過getFirstQueuedThread方法檢查,這是框架提供的爲數不多的幾個檢測方法之一),則立即失敗(返回false)。

一個更快,但非嚴格公平的變體可以這樣做,若隊列爲空(判斷的瞬間),仍然允許tryAcquire執行成功。在這種情況下,多個線程同時遇到一個空隊列時可能會去競爭以使自己第一個獲得鎖,這樣,通常至少有一個線程是無需入隊列的。java.util.concurrent包中所有支持公平模式的同步器都採用了這種策略。

儘管公平性設置在實踐中很有用,但是它們並沒有保障,因爲Java Language Specification沒有提供這樣的調度保證。例如:即使是嚴格公平的同步器,如果一組線程永遠不需要阻塞來達到互相等待,那麼JVM可能會決定純粹以順序方式運行它們。在實際中,單處理器上,在搶佔式上下文切換之前,這樣的線程有可能是各自運行了一段時間。如果這樣一個線程正持有某個互斥鎖,它將很快會被切換回來,僅是爲了釋放其持有的鎖,然後會繼續阻塞,因爲它知道有另外一個線程需要這把鎖,這更增加了同步器可用但沒有線程能來獲取之間的間隔。同步器公平性設置在多處理器上的影響可能會更大,因爲在這種環境下會產生更多的交錯,因此一個線程就會有更多的機會發現鎖被另一個線程請求。

在高競爭下,當保護的是短暫持有鎖的代碼體時,儘管性能可能會較差,但公平鎖仍然能有效地工作。例如,當公平性鎖保護的是相對長的代碼體和/或有着相對長的鎖間(inter-lock)間隔,在這種情況下,闖入只能帶來很小的性能優勢,但卻可能會大大增加無限等待的風險。同步器框架將這些工程決策留給用戶來確定。

4.2 同步器

下面是java.util.concurrent包中同步器定義方式的概述:

ReentrantLock類使用AQS同步狀態來保存鎖(重複)持有的次數。當鎖被一個線程獲取時,ReentrantLock也會記錄下當前獲得鎖的線程標識,以便檢查是否是重複獲取,以及當錯誤的線程(譯者注:如果線程不是鎖的持有者,在此線程中執行該鎖的unlock操作就是非法的)試圖進行解鎖操作時檢測是否存在非法狀態異常。ReentrantLock也使用了AQS提供的ConditionObject,還向外暴露了其它監控和監測相關的方法。ReentrantLock通過在內部聲明兩個不同的AbstractQueuedSynchronizer實現類(提供公平模式的那個禁用了闖入策略)來實現可選的公平模式,在創建ReentrantLock實例的時候根據設置(譯者注:即ReentrantLock構造方法中的fair參數)使用相應的AbstractQueuedSynchronizer實現類。

ReentrantReadWriteLock類使用AQS同步狀態中的16位來保存寫鎖持有的次數,剩下的16位用來保存讀鎖的持有次數。WriteLock的構建方式同ReentrantLockReadLock則通過使用acquireShared方法來支持同時允許多個讀線程。

Semaphore類(計數信號量)使用AQS同步狀態來保存信號量的當前計數。它裏面定義的acquireShared方法會減少計數,或當計數爲非正值時阻塞線程;tryRelease方法會增加計數,可能在計數爲正值時還要解除線程的阻塞。

CountDownLatch類使用AQS同步狀態來表示計數。當該計數爲0時,所有的acquire操作(譯者注:acquire操作是從aqs的角度說的,對應到CountDownLatch中就是await方法)才能通過。

FutureTask類使用AQS同步狀態來表示某個異步計算任務的運行狀態(初始化、運行中、被取消和完成)。設置(譯者注:FutureTaskset方法)或取消(譯者注:FutureTaskcancel方法)一個FutureTask時會調用AQS的release操作,等待計算結果的線程的阻塞解除是通過AQS的acquire操作實現的。

SynchronousQueues類(一種CSP(Communicating Sequential Processes)形式的傳遞)使用了內部的等待節點,這些節點可以用於協調生產者和消費者。同時,它使用AQS同步狀態來控制當某個消費者消費當前一項時,允許一個生產者繼續生產,反之亦然。

java.util.concurrent包的使用者當然也可以爲自定義的應用定義自己的同步器。例如,那些曾考慮到過的,但沒有采納進這個包的同步器包括提供WIN32事件各種風格的語義類,二元信號量,集中管理的鎖以及基於樹的屏障。

5 性能

雖然AQS框架除了支持互斥鎖外,還支持其它形式的同步方式,但鎖的性能是最容易測量和比較的。即使如此,也還存在許多不同的測量方式。這裏的實驗主要是設計來展示鎖的開銷和吞吐量。

在每個測試中,所有線程都重複的更新一個僞隨機數,該隨機數由nextRandom(int seed)方法計算:

1

2

int t = (seed % 127773) * 16807 - (seed / 127773) * 2836;

return (t > 0) ? t : t + 0x7fffffff;

在每次迭代中,線程以概率S在一個互斥鎖下更新共享的生成器,否則(譯者注:概率爲1-S)更新其自己局部的生成器,此時是不需要鎖的。如此,鎖佔用區域的耗時是短暫的,這就使線程持有鎖期間被搶佔時的外界干擾降到了最小。這個函數的隨機性主要是爲了兩個目的:確定是否需要使用鎖(這個生成器足以應付這裏的需求),以及使循環中的代碼不可能被輕易地優化掉。

這裏比較了四種鎖:內置鎖,用的是synchronized塊;互斥鎖,用的是像第四節例子中的那樣簡單的Mutex類;可重入鎖,用的是ReentrantLock;以及公平鎖,用的是ReentrantLock的公平模式。所有測試都運行在J2SE1.5 JDK build46(大致與beta2相同)的server模式下。在收集測試數據前,測試程序先運行20次非競爭的測試,以排除JVM“預熱”(譯者注:更多關於“預熱”的內容,參見:Java 理論與實踐: 動態編譯與性能測量)過程的影響。除了公平模式下的測試只跑了一百萬次迭代,其它每個線程中的測試都運行了一千萬次迭代。

該測試運行在四個X86機器和四個UltraSparc機器上。所有X86機器都運行的是RedHat基於NPTL 2.4內核和庫的Linux系統。所有的UltraSparc機器都運行的是Solaris-9。測試時所有系統的負載都很輕。根據該測試的特徵,並不要求系統完全空閒(譯者注:即測試時操作系統上有其它較輕的負載也不會影響本次測試的結果。)。“4P”這個名字反映出雙核超線程的Xeon更像是4路機器,而不是2路機器。這裏沒有將測試數據規範化。如下所示,同步的相對開銷與處理器的數量、類型、速度之間不具備簡單的關係。

表1 測試的平臺

名字 處理器數量 類型 速度(Mhz)
1P 1 Pentium3 900
2P 2 Pentium3 1400
2A 2 Athlon 2000
4P 2HT Pentium4/Xeon 2400
1U 1 UltraSparc2 650
4U 4 UltraSparc2 450
8U 8 UltraSparc3 750
24U 24 UltraSparc3 750


 


5.1 開銷

無競爭情況下的開銷是通過僅運行一個線程,將概率S爲1時的每次迭代時間減去概率S爲0(訪問共享內存的概率爲0)時的每次迭代時間得到的(譯者注:這裏的“概率S”即前文提到的“概率S”,概率爲0時是沒有鎖操作的,概率爲1時是每次都有鎖操作,因此將概率爲1時的耗時減去概率爲0時的耗時就是整個鎖操作的開銷。)。表2以納秒爲單位顯示了非競爭場景下每次鎖操作的開銷。Mutex類最接近於框架的基本耗時,可重入鎖的額外開銷是記錄當前所有者線程和錯誤檢查的耗時,對於公平鎖來說還包含開始時檢查隊列是否爲空的耗時。

表格2也展示與內置鎖的“快速路徑(fast path)”對比,tryAcquire的耗時。這裏的差異主要反映出了各鎖和機器中使用的不同的原子指令以及內存屏障的耗時。在多處理器上,這些指令常常是完全優於所有其它指令的。內置鎖和同步器類之間的主要差別,顯然是由於Hotspot鎖在鎖定和解鎖時都使用了一次compareAndSet,而同步器的acquire操作使用了一次compareAndSet,但release操作用的是一次volatile寫(即,多處理器上的一次內存屏障以及所有處理器上的重排序限制)。每個鎖的絕對的和相對耗時因機器的不同而不同。

表2 無競爭時的單鎖開銷(單位:納秒)

機器 內置 互斥 可重入 公平可重入
1P 18 9 31 37
2P 58 71 77 81
2A 13 21 31 30
4P 116 95 109 117
1U 90 40 58 67
4U 122 82 100 115
8U 160 83 103 123
24U 161 84 108 119

 從另一個極端看,表3展示了概率S爲1,運行256個併發線程時產生了大規模的鎖競爭下每個鎖的開銷。在完全飽和的情況下,可闖入的FIFO鎖比內置鎖的開銷少了一個數量級(也就是更大的吞吐量),比公平鎖更是少了兩個數量級。這表現出即使有着極大的競爭,在維持線程進展方面可闖入FIFO策略的效率。

表3也說明了即使在內部開銷比較低的情況下,公平鎖的性能也完全是由上下文切換的時間所決定的。列出的時間大致上都與各平臺上線程阻塞和解除線程阻塞的時間相稱。

此外,後面增加的一個實驗(僅使用機器4P)表明,對於這裏用到的短暫持有的鎖,公平參數的設置在總差異中的影響很小。這裏將線程終止時間間的差異記錄成一個粗粒度的離散量數。在4P的機器上,公平鎖的時間度量的標準差平均爲0.7%,可重入鎖平均爲6.0%。作爲對比,爲模擬一個長時間持有鎖的場景,測試中使每個線程在持有鎖的情況下計算了16K次隨機數。這時,總運行時間幾乎是相同的(公平鎖:9.79s,可重入鎖:9.72s)。公平模式下的差異依然很小,標準差平均爲0.1%,而可重入鎖上升到了平均29.5%。

表格3 飽和時的單鎖開銷(單位:納秒)

機器 內置 互斥 可重入 公平可重入
1P 521 46 67 8327
2P 930 108 132 14967
2A 748 79 84 33910
4P 1146 188 247 15328
1U 879 153 177 41394
4U 2590 347 368 30004
8U 1274 157 174 31084
24U 1983 160 182 32291


 


5.2 吞吐量

大部分同步器都是用於無競爭和極大競爭之間的。這可以用實驗在兩個方面進行檢查,通過修改固定個線程的競爭概率,和/或通過往擁有固定競爭概率的線程集合裏增加更多的線程。爲了說明這些影響,測試運行在不同的競爭概率和不同的線程數目下,都用的是可重入鎖。附圖使用了一個slowdown度量標準。

這裏,t是總運行時間,b是一個線程在沒有競爭或同步下的基線時間,n是線程數,p是處理器數,S是共享訪問的比例(譯者注:即前面的競爭概率S)。計算結果是實際執行時間與理想執行時間(通常是無法得到的)的比率,理想執行時間是通過使用Amdahl’s法則計算出來的。理想時間模擬了一次沒有同步開銷,沒有因鎖爭用而導致線程阻塞的執行過程。即使這樣,在很低的競爭下,相比理想時間,有一些測試結果卻表現出了很小的速度增長,大概是由於基線和測試之間的優化、流水線等方面有着輕微的差別。

圖中用以2爲底的對數爲比例進行了縮放。例如,值爲1表示實際時間是理想時間的兩倍,4表示慢16倍。使用對數就不需要依賴一個隨意的基線時間(這裏指的是計算隨機數的時間),因此,基於不同底數計算的結果表現出的趨勢應該是類似的。這些測試使用的競爭概率從1/128(標識爲“0.008”)到1,以2的冪爲步長,線程的數量從1到1024,以2的冪的一半爲步長。

在單處理器(1P和1U)上,性能隨着競爭的上升而下降,但不會隨着線程數的增加而下降。多處理器在遭遇競爭時,性能下降的更快。根據多處理器相關的圖表顯示,開始出現的峯值處雖然只有幾個線程的競爭,但相對性能通常卻最差。這反映出了一個性能的過渡區域,在這裏闖入的線程和被喚醒的線程都準備獲取鎖,這會讓它們頻繁的迫使對方阻塞。在大部分時候,過渡區域後面會緊接着一個平滑區域,因爲此時幾乎沒有空閒的鎖,所以會與單處理器上順序執行的模式差不多;在多處理器機器上會較早進入平滑區域。例如,請注意,在滿競爭(標識爲“1.000”)下這些值表示,在處理器越少的機器上,會有更糟糕的相對速度下降。

根據這些結果,可以針對阻塞(park/unpark)做進一步調優以減少上下文切換和相關的開銷,這會給本框架帶來小但顯著的提升。此外,在多處理器上爲短時間持有的但高競爭的鎖採用某種形式的適應性自旋,可以避免這裏看到的一些波動,這對同步器類大有裨益。雖然在跨不同上下文時適應性自旋很難很好的工作,但可以使用本框架爲遇到這類使用配置的特定應用構建一個自定義形式的鎖。


6 總結

本文撰寫之時,java.util.concurrent包中的同步器框架還太新所以還不能在實踐中使用。因此在J2SE 1.5最終版本發佈之前都很難看到其大範圍的使用,並且,它的設計,API實現以及性能肯定還有無法預料的後果。但是,此時,這個框架明顯能勝任其基本的目標,即爲創建新的同步器提供一個高效的基礎。

 

7 致謝

Thanks to Dave Dice for countless ideas and advice during the development of this framework, to Mark Moir and Michael Scott for urging consideration of CLH queues, to David Holmes for critiquing early versions of the code and API, to Victor Luchangco and Bill Scherer for reviewing previous incarnations of the source code, and to the other members of the JSR166 Expert Group (Joe Bowbeer, Josh Bloch, Brian Goetz, David Holmes, and Tim Peierls) as well as Bill Pugh, for helping with design and specifications and commenting on drafts of this paper. Portions of this work were made possible by a DARPA PCES grant, NSF grant EIA-0080206 (for access to the 24way Sparc) and a Sun Collaborative Research Grant.


參考文獻

  • [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.


本文分享自微信公衆號 - 源碼筆記(jinyue_lll)。
如有侵權,請聯繫 [email protected] 刪除。
本文參與“OSC源創計劃”,歡迎正在閱讀的你也加入,一起分享。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章