1. 程式人生 > >Java Fork Join 框架(三)實現

Java Fork Join 框架(三)實現

作者:Doug Lea  譯者Alex  校對:方騰飛

這個框架是由大約800行純Java程式碼組成,主要的類是FJTaskRunner,它是java.lang.Thread的子類。FJTasks 自己僅僅維持一個關於結束狀態的布林值,所有其他的操作都是通過當前的工作執行緒來代理完成的。JFTaskRunnerGroup類用於建立工作執行緒,維護一些共享的狀態(例如:所有工作執行緒的標示符,在偷取操作時需要),同時還要協調啟動和關閉。

更多實現的細節文件可以在util.concurrent併發包中檢視。這一節只著重討論兩類問題以及在實現這個框架的時候所形成的一些解決方案:支援高效的雙端列表操作(push, pop 和 take), 並且當工作執行緒在嘗試獲取新的任務時維持偷取的協議。

3.1雙端佇列

(校注:雙端佇列中的元素可以從兩端彈出,其限定插入和刪除操作在佇列的兩端進行。)

為了能夠獲得高效以及可擴充套件的執行任務,任務管理需要越快越好。建立、釋出、和彈出(或者出現頻率很少的獲取)任務在順序程式設計模式中會引發程式呼叫開銷。更低的開銷可以使得程式設計師能夠構建更小粒度的任務,最終也能更好的利用並行所帶來的益處。

Java虛擬機器會負責任務的記憶體分配。Java垃圾回收器使我們不需要再去編寫一個特殊的記憶體分配器去維護任務。相對於其他語言的類似框架,這個原因使我們大大降低了實現FJTasks的複雜性以及所需要的程式碼數。

雙端佇列的基本結構採用了很常規的一個結構——使用一個數組(儘管是可變長的)來表示每個佇列,同時附帶兩個索引:top 索引就類似於陣列中的棧指標,通過push和pop操作來改變。Base 索引只能通過take操作來改變。鑑於FJTaskRunner操作都是無縫的繫結到雙端佇列的細節之中,(例如,fork直接呼叫push),所以這個資料結構直接放在類之中,而不是作為一個單獨的元件。

但是雙端佇列的元素會被多執行緒併發的訪問,在缺乏足夠同步的情況下,而且單個的Java陣列元素也不能宣告為volitile變數(校注:宣告成volatile的陣列,其元素並不具備volatile語意),每個陣列元素實際上都是一個固定的引用,這個引用指向了一個維護著單個volitile引用的轉發物件。一開始做出這個決定主要是考慮到Java記憶體模型的一致性。但是在這個級別它所需要的間接定址被證明在一些測試過的平臺上能夠提升效能。可能是因為訪問鄰近的元素而降低了快取爭用,這樣記憶體裡面的間接定址會更快一點。

實現雙端佇列的主要挑戰來自於同步和他的撤銷。儘管在Java虛擬機器上使用經過優化過的同步工具,對於每個push和pop操作都需要獲取鎖還是讓這一切成為效能瓶頸。然後根據以下的觀察結果我們可以修改Clik中的策略,從而為我們提供一種可行的解決方案:

  • Push和pop操作僅可以被工作執行緒的擁有者所呼叫。
  • 對Take的操作很容易會由於偷取任務執行緒在某一時間對take操作加鎖而限制。(雙端佇列在必要的時間也可以禁止take操作。)這樣,控制衝突將被降低為兩個部分同步的層次。
  • Pop和take操作只有在雙端佇列為空的時候才會發生衝突,否則的話,佇列會保證他們在不同的陣列元素上面操作。

把top和base索引定義為volitile變數可以保證當佇列中元素不止一個時,pop和take操作可以在不加鎖的情況下進行。這是通過一種類似於Dekker演算法來實現的。當push 預遞減到top時:

If (–top >=base)…

和take 預遞減到 base時:

If(++base < top)…

在上述每種情況下他們都通過比較兩個索引來檢查這樣是否會導致雙端佇列變成一個空佇列。一個不對稱的規則將用於防止潛在的衝突:pop會重新檢查狀態並在獲取鎖之後繼續(對take所持有的也一樣),直到佇列真的為空才退出。而Take操作會立即退出,特別是當嘗試去獲得另外一個任務。與其他類似使用Clik的THE協議一樣,這種不對稱性是唯一重要的改變。

使用volitile變數索引push操作在佇列沒有滿的情況下不需要同步就可以進行。如果佇列將要溢位,那麼它首先必須要獲得佇列鎖來重新設定佇列的長度。其他情況下,只要確保top操作排在佇列陣列槽盛在抑制干涉帶之後更新。

在隨後的初始化實現中,發現有好幾種JVM並不符合Java記憶體模型中正確讀取寫入的volitile變數的規則。作為一個工作區,pop操作在持有鎖的情況下重試的條件已經被調整為:如果有兩個或者更少的元素,並且take操作加了第二把鎖以確保記憶體屏障效果,那麼重試就會被觸發。只要最多隻有一個索引被擁有者執行緒丟失這就是滿足的,並且只會引起輕微的效能損耗。

3.2 搶斷和閒置

在搶斷式工作框架中,工作執行緒對於他們所執行的程式對同步的要求一無所知。他們只是構建、釋出、彈出、獲取、管理狀態和執行任務。這種簡單的方案使得當所有的執行緒都擁有很多工需要去執行的時候,它的效率很高。然而這種方式是有代價的,當沒有足夠的工作的時候它將依賴於試探法。也就是說,在啟動一個主任務,直到它結束,在有些fork/join演算法中都使用了全面停止的同步指標。

主要的問題在於當一個工作執行緒既無本地任務也不能從別的執行緒中搶斷任務時怎麼辦。如果程式執行在專業的多核處理器上面,那麼可以依賴於硬體的忙等待自旋迴圈的去嘗試搶斷一個任務。然而,即使這樣,嘗試搶斷還是會增加競爭,甚至會導致那些不是閒置的工作執行緒降低效率(由於鎖協議,3.1節中)。除此之外,在一個更適合此框架執行的場景中,作業系統應該能夠很自信的去執行那些不相關並可執行的程序和執行緒。

Java中並沒有十分健壯的工作來保證這個,但是在實際中它往往是可以讓人接受的。一個搶斷失敗的執行緒在嘗試另外的搶斷之前會降低自己的優先順序,在嘗試搶斷之間執行Thread.yeild操作,然後將自己的狀態在FJTaskRunnerGroup中設定為不活躍的。他們會一直阻塞直到有新的主執行緒。其他情況下,在進行一定的自旋次數之後,執行緒將進入休眠階段,他們會休眠而不是放棄搶斷。強化的休眠機制會給人造成一種需要花費很長時間去劃分任務的假象。但是這似乎是最好的也是通用的折中方案。框架的未來版本也許會支援額外的控制方法,以便於讓程式設計師在感覺效能受到影響時可以重寫預設的實現。