1. 程式人生 > >Java Fork/Join框架

Java Fork/Join框架

譯序

Doug Lea 大神關於Java 7引入的他寫的Fork/Join框架的論文。

響應式程式設計Reactive Programming / RP)作為一種正規化在整個業界正在逐步受到認可和落地,是對過往系統的業務需求理解梳理之後對系統技術設計/架構模式的提升總結。Java作為一個成熟平臺,對於趨勢一向有些穩健的接納和跟進能力,有著令人驚歎的生命活力:

  1. Java 7提供了ForkJoinPool,支援了Java 8提供的Stream
  2. 另外Java 8還提供了Lamda(有效地表達和使用RP需要FP的語言構件和理念)。
  3. 有了前面的這些穩健但不失時機的準備,在Java 9中提供了面向RP
    的官方Flow API,實際上是直接把Reactive Streams的介面加在Java標準庫中,即Reactive Streams規範轉正了,Reactive StreamsRP的基礎核心元件。Flow API標誌著RP由集市式的自由探索階段 向 教堂式的統一使用的轉變。

通過上面這些說明,可以看到ForkJoinPool的基礎重要性。

對了,另外提一下Java 9Flow API@author也是 Doug Lee 哦~

PS:基於Alex/蕭歡 翻譯、方騰飛 校對的譯文稿:Java Fork Join 框架,補譯『結論』之後3節,調整了格式和一些用詞,整理成完整的譯文。譯文原始碼在

GitHub的這個倉庫中,可以提交Issue/Fork後提交程式碼來建議/指正。

0. 摘要

這篇論文描述了Fork/Join框架的設計、實現以及效能,這個框架通過(遞迴的)把問題劃分為子任務,然後並行的執行這些子任務,等所有的子任務都結束的時候,再合併最終結果的這種方式來支援平行計算程式設計。總體的設計參考了為Cilk設計的work-stealing框架。就設計層面來說主要是圍繞如何高效的去構建和管理任務佇列以及工作執行緒來展開的。效能測試的資料顯示良好的平行計算程式將會提升大部分應用,同時也暗示了一些潛在的可以提升的空間。

校注1: Cilk是英特爾Cilk語言。英特爾C++編輯器的新功能Cilk

語言擴充套件技術,為C/C++語言增加了細粒度任務支援,使其為新的和現有的軟體增加並行性來充分發掘多處理器能力變得更加容易。

1. 簡介

Fork/Join並行方式是獲取良好的平行計算效能的一種最簡單同時也是最有效的設計技術。Fork/Join並行演算法是我們所熟悉的分治演算法的並行版本,典型的用法如下:

Result solve(Problem problem) {
    if (problem is small) {
        directly solve problem
    } else {
        split problem into independent parts
        fork new subtasks to solve each part
        join all subtasks
        compose result from subresults
    }
}

fork操作將會啟動一個新的並行Fork/Join子任務。join操作會一直等待直到所有的子任務都結束。Fork/Join演算法,如同其他分治演算法一樣,總是會遞迴的、反覆的劃分子任務,直到這些子任務可以用足夠簡單的、短小的順序方法來執行。

一些相關的程式設計技術和例項在Java併發程式設計 —— 設計原則與模式 第二版》[7] 4.4章節中已經討論過。這篇論文將討論FJTask的設計(第2節)、實現(第3節)以及效能(第4節),它是一個支援並行程式設計方式的Java™框架。FJTask 作為util.concurrent軟體包的一部分,目前可以在 http://gee.cs.oswego.edu/ 獲取到。

2. 設計

Fork/Join程式可以在任何支援以下特性的框架之上執行:框架能夠讓構建的子任務並行執行,並且擁有一種等待子任務執行結束的機制。然而,java.lang.Thread類(同時也包括POSIX pthread,這些也是Java執行緒所基於的基礎)對Fork/Join程式來說並不是最優的選擇:

  • Fork/Join任務對同步和管理有簡單的和常規的需求。相對於常規的執行緒來說,Fork/Join任務所展示的計算佈局將會帶來更加靈活的排程策略。例如,Fork/Join任務除了等待子任務外,其他情況下是不需要阻塞的。因此傳統的用於跟蹤記錄阻塞執行緒的代價在這種情況下實際上是一種浪費。
  • 對於一個合理的基礎任務粒度來說,構建和管理一個執行緒的代價甚至可以比任務執行本身所花費的代價更大。儘管粒度是應該隨著應用程式在不同特定平臺上執行而做出相應調整的。但是超過執行緒開銷的極端粗粒度會限制並行的發揮。

簡而言之,Java標準的執行緒框架對Fork/Join程式而言太笨重了。但是既然執行緒構成了很多其他的併發和並行程式設計的基礎,完全消除這種代價或者為了這種方式而調整執行緒排程是不可能(或者說不切實際的)。

儘管這種思想已經存在了很長時間了,但是第一個釋出的能系統解決這些問題的框架是Cilk[5]Cilk和其他輕量級的框架是基於作業系統的基本的執行緒和程序機制來支援特殊用途的Fork/Join程式。這種策略同樣適用於Java,儘管Java執行緒是基於低級別的作業系統的能力來實現的。創造這樣一個輕量級的執行框架的主要優勢是能夠讓Fork/Join程式以一種更直觀的方式編寫,進而能夠在各種支援JVM的系統上執行。

FJTask框架是基於Cilk設計的一種演變。其他的類似框架有Hood[4]Filaments[8]Stackthreads[10]以及一些依賴於輕量級執行任務的相關係統。所有這些框架都採用和作業系統把執行緒對映到CPU上相同的方式來把任務對映到執行緒上。只是他們會使用Fork/Join程式的簡單性、常規性以及一致性來執行這種對映。儘管這些框架都能適應不能形式的並行程式,他們優化了Fork/Join的設計:

  • 一組工作者執行緒池是準備好的。每個工作執行緒都是標準的(『重量級』)處理存放在佇列中任務的執行緒(這地方指的是Thread類的子類FJTaskRunner的例項物件)。通常情況下,工作執行緒應該與系統的處理器數量一致。對於一些原生的框架例如說Cilk,他們首先將對映成核心執行緒或者是輕量級的程序,然後再在處理器上面執行。在Java中,虛擬機器和作業系統需要相互結合來完成執行緒到處理器的對映。然後對於計算密集型的運算來說,這種對映對於作業系統來說是一種相對簡單的任務。任何合理的對映策略都會導致執行緒對映到不同的處理器。
  • 所有的Fork/Join任務都是輕量級執行類的例項,而不是執行緒例項。在Java中,獨立的可執行任務必須要實現Runnable介面並重寫run方法。在FJTask框架中,這些任務將作為子類繼承FJTask而不是Thread,它們都實現了Runnable介面。(對於上面兩種情況來說,一個類也可以選擇實現Runnable介面,類的例項物件既可以在任務中執行也可以線上程中執行。因為任務執行受到來自FJTask方法嚴厲規則的制約,子類化FJTask相對來說更加方便,也能夠直接呼叫它們。)
  • 我們將採用一個特殊的佇列和排程原則來管理任務並通過工作執行緒來執行任務。這些機制是由任務類中提供的相關方式實現的:主要是由forkjoinisDone(一個結束狀態的標示符),和一些其他方便的方法,例如呼叫coInvoke來分解合併兩個或兩個以上的任務。
  • 一個簡單的控制和管理類(這裡指的是FJTaskRunnerGroup)來啟動工作執行緒池,並初始化執行一個由正常的執行緒呼叫所觸發的Fork/Join任務(就類似於Java程式中的main方法)。

作為一個給程式設計師演示這個框架如何執行的標準例項,這是一個計演算法斐波那契函式的類。

class Fib extends FJTask {
    static final int threshold = 13;
    volatile int number; // arg/result

    Fib(int n) {
        number = n;
    }

    int getAnswer() {
        if (!isDone())
            throw new IllegalStateException();
        return number;
    }

    public void run() {
        int n = number;
        if (n <= threshold) // granularity ctl
            number = seqFib(n);
        else {
            Fib f1 = new Fib(n - 1);
            Fib f2 = new Fib(n - 2);
            coInvoke(f1, f2);
            number = f1.number + f2.number;
        }
    }

    public static void main(String[] args) {
        try {
            int groupSize = 2; // for example
            FJTaskRunnerGroup group = new FJTaskRunnerGroup(groupSize);
            Fib f = new Fib(35); // for example
            group.invoke(f);
            int result = f.getAnswer();
            System.out.println("Answer: " + result);
        } catch (InterruptedException ex) {
        }
    }

    int seqFib(int n) {
        if (n <= 1) return n;
        else return seqFib(n − 1) + seqFib(n − 2);
    }
}

這個版本在第4節中所提到的平臺上的執行速度至少比每個任務都在Thread類中執行快30倍。在保持效能的同時這個程式仍然維持著Java多執行緒程式的可移植性。對程式設計師來說通常有兩個引數值的他們關注:

  • 對於工作執行緒的建立數量,通常情況下可以與平臺所擁有的處理器數量保持一致(或者更少,用於處理其他相關的任務,或者有些情況下更多,來提升非計算密集型任務的效能)。
  • 一個粒度引數代表了建立任務的代價會大於並行化所帶來的潛在的效能提升的臨界點。這個引數更多的是取決於演算法而不是平臺。通常在單處理器上執行良好的臨界點,在多處理器平臺上也會發揮很好的效果。作為一種附帶的效益,這種方式能夠與Java虛擬機器的動態編譯機制很好的結合,而這種機制在對小塊方法的優化方面相對於單塊的程式來說要好。這樣,加上資料本地化的優勢,Fork/Join演算法的效能即使在單處理器上面的效能都較其他演算法要好。

2.1 work−stealing

Fork/Join框架的核心在於輕量級排程機制。FJTask採用了Cilkwork-stealing所採用的基本排程策略:

  • 每一個工作執行緒維護自己的排程佇列中的可執行任務。
  • 佇列以雙端佇列的形式被維護(注:deques通常讀作『decks』),不僅支援後進先出 —— LIFOpushpop操作,還支援先進先出 —— FIFOtake操作。
  • 對於一個給定的工作執行緒來說,任務所產生的子任務將會被放入到工作者自己的雙端佇列中。
  • 工作執行緒使用後進先出 —— LIFO(最新的元素優先)的順序,通過彈出任務來處理佇列中的任務。
  • 當一個工作執行緒的本地沒有任務去執行的時候,它將使用先進先出 —— FIFO的規則嘗試隨機的從別的工作執行緒中拿(『竊取』)一個任務去執行。
  • 當一個工作執行緒觸及了join操作,如果可能的話它將處理其他任務,直到目標任務被告知已經結束(通過isDone方法)。所有的任務都會無阻塞的完成。
  • 當一個工作執行緒無法再從其他執行緒中獲取任務和失敗處理的時候,它就會退出(通過yieldsleep和/或者優先順序調整,參考第3節)並經過一段時間之後再度嘗試直到所有的工作執行緒都被告知他們都處於空閒的狀態。在這種情況下,他們都會阻塞直到其他的任務再度被上層呼叫。

使用後進先出 —— LIFO用來處理每個工作執行緒的自己任務,但是使用先進先出 —— FIFO規則用於獲取別的任務,這是一種被廣泛使用的進行遞迴Fork/Join設計的一種調優手段。引用[5]討論了詳細討論了裡面的細節。

讓竊取任務的執行緒從佇列擁有者相反的方向進行操作會減少執行緒競爭。同樣體現了遞迴分治演算法的大任務優先策略。因此,更早期被竊取的任務有可能會提供一個更大的單元任務,從而使得竊取執行緒能夠在將來進行遞迴分解。

作為上述規則的一個後果,對於一些基礎的操作而言,使用相對較小粒度的任務比那些僅僅使用粗粒度劃分的任務以及那些沒有使用遞迴分解的任務的執行速度要快。儘管相關的少數任務在大多數的Fork/Join框架中會被其他工作執行緒竊取,但是建立許多組織良好的任務意味著只要有一個工作執行緒處於可執行的狀態,那麼這個任務就有可能被執行。

3. 實現

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

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

3.1 雙端佇列

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

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

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

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

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

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

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

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

if (–top >= base) ...

take預遞減到base時:

if (++base < top) ...

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

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

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

3.2 搶斷和閒置

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

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

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

4. 效能

如今,隨著編譯器與Java虛擬機器效能的不斷提升,效能測試結果也僅僅只能適用一時。但是,本節中所提到的測試結果資料卻能揭示Fork/Join框架的基本特性。

下面表格中簡單介紹了在下文將會用到的一組Fork/Join測試程式。這些程式是從util.concurrent包裡的示例程式碼改編而來,用來展示Fork/Join框架在解決不同型別的問題模型時所表現的差異,同時得到該框架在一些常見的並行測試程式上的測試結果。

程式 描述
Fib(菲波那契數列) 如第2節所描述的Fibonnaci程式,其中引數值為47閥值為13
Integrate(求積分) 使用遞迴高斯求積對公式 (2 \cdot i - 1) \cdot x ^ {(2 \cdot i - 1)} 求-47到48的積分,i 為1到5之間的偶數
Micro(求微分) 對一種棋盤遊戲尋找最好的移動策略,每次計算出後面四次移動
Sort(排序) 使用合併/快速排序演算法對1億數字進行排序(基於Cilk演算法)
MM(矩陣相乘) 2048 X 2048的double型別的矩陣進行相乘
LU(矩陣分解) 4096 X 4096的double型別的矩陣進行分解
Jacobi(雅克比迭代法) 對一個4096 X 4096的double矩陣使用迭代方法進行矩陣鬆弛,迭代次數上限為100

下文提到的主要的測試,其測試程式都是執行在Sun Enterprise 10000伺服器上,該伺服器擁有30個CPU,作業系統為Solaris 7系統,執行Solaris商業版1.2 JVM2.2.2_05釋出版本的一個早期版本)。同時,Java虛擬機器的關於執行緒對映的環境引數選擇為『bound threads』(譯者注:XX:+UseBoundThreads,繫結使用者級別的執行緒到核心執行緒,只與Solaris有關),而關於虛擬機器的記憶體引數設定在4.2章節討論。另外,需要注意的是下文提到的部分測試則是執行在擁有4 CPUSun Enterprise 450伺服器上。

為了降低定時器粒度以及Java虛擬機器啟動因素對測試結果的影響,測試程式都使用了數量巨大的輸入引數。而其它一些啟動因素我們通過在啟動定時器之前先執行初始化任務來進行遮蔽。所得到的測試結果資料,大部分都是在三次測試結果的中間值,然而一些測試資料僅僅來自一次執行結果(包括4.2 ~ 4.4章節很多測試),因此這些測試結果會有噪音表現。

4.1 加速效果

通過使用不同數目(1 ~ 30)的工作執行緒對同一問題集進行測試,用來得到框架的擴充套件性測試結果。雖然我們無法保證Java虛擬機器是否總是能夠將每一個執行緒對映到不同的空閒CPU上,同時,我們也沒有證據來證明這點。有可能對映一個新的執行緒到CPU的延遲會隨著執行緒數目的增加而變大,也可能會隨不同的系統以及不同的測試程式而變化。但是,所得到的測試結果的確顯示出增加執行緒的數目確實能夠增加使用的CPU的數目。

加速比通常表示為 Timen / Time1。如上圖所示,其中求積分的程式表現出最好的加速比(30個執行緒的加速比為28.2),表現最差的是矩陣分解程式(30執行緒是加速比只有15.35)

另一種衡量擴充套件性的依據是:任務執行率,及執行一個單獨任務(這裡的任務有可能是遞迴分解節點任務也可能是根節點任務)所開銷的平均時間。下面的資料顯示出一次性執行各個程式所得到的任務執行率資料。很明顯,單位時間內執行的任務數目應該是固定常量。然而事實上,隨著執行緒數目增加,所得到的資料會表現出輕微的降低,這也表現出其一定的擴充套件性限制。這裡需要說明的是,之所以任務執行率在各個程式上表現的巨大差異,是因其任務粒度的不同造成的。任務執行率最小的程式是Fib(菲波那契數列),其閥值設定為13,在30個執行緒的情況下總共完成了280萬個單元任務。

導致這些程式的任務完成率沒有表現為水平直線的因素有四個。其中三個對所有的併發框架來說都是普遍原因,所以,我們就從對FJTask框架(相對於Cilk等框架)所特有的因素說起,即垃圾回收。

4.2 垃圾回收

總的來說,現在的垃圾回收機制的效能是能夠與Fork/Join框架所匹配的:Fork/Join程式在執行時會產生巨大數量的任務單元,然而這些任務在被執行之後又會很快轉變為記憶體垃圾。相比較於順序執行的單執行緒程式,在任何時候,其對應的F'
;%ވZh8HE'
;%ވZh最多
p倍的記憶體空間(其中p為執行緒數目)。基於分代的半空間拷貝垃圾回收器(也就是本文中測試程式所使用的Java虛擬機器所應用的垃圾回收器)能夠很好的處理這種情況,因為這種垃圾回收機制在進行記憶體回收的時候僅僅拷貝非垃圾記憶體單元。這樣做,就避免了在手工併發記憶體管理上的一個複雜的問題,即跟蹤那些被一個執行緒分配卻在另一個執行緒中使用的記憶體單元。這種垃圾回收機制並不需要知道記憶體分配的源頭,因此也就無需處理這個棘手的問題。

這種垃圾回收機制優勢的一個典型體現:使用這種垃圾回收機制,四個執行緒執行的Fib程式耗時僅為5.1秒鐘,而如果在Java虛擬機器設定關閉代拷貝回收(這種情況下使用的就是標記清除(mark−sweep)垃圾回收機制了),耗時需要9.1秒鐘。

然而,只有記憶體使用率只有達到一個很高的值的情況下,垃圾回收機制才會成為影響擴充套件性的一個因素,因為這種情況下,虛擬機器必須經常停止其他執行緒來進行垃圾回收。以下的資料顯示出在三種不同的記憶體設定下(Java虛擬機器支援通過額外的引數來設定記憶體引數),加速比所表現出的差異:預設的4M的半空間,64M的半空間,另外根據執行緒數目按照公式(2 + 2p)M設定半空間。使用較小的半空間,在額外執行緒導致垃圾回收率攀高的情況下,停止其他執行緒並進行垃圾回收的開銷開始影響加封。

鑑於上面的結果,我們使用64M的半空間作為其他測試的執行標準。其實設定記憶體大小的一個更好的策略就是根據每次測試的實際執行緒數目來確定。(正如上面的測試資料,我們發現這種情況下,加速比會表現的更為平滑)。相對的另一方面,程式所設定的任務粒度的閥值也應該隨著執行緒數目成比例的增長。

4.3 記憶體分配和字寬

在上文提到的測試程式中,有四個程式會建立並運算元量巨大的共享陣列和矩陣:數字排序,矩陣相乘/分解以及鬆弛。其中,排序演算法應該是對資料移動操作(將記憶體資料移動到CPU快取)以及系統總記憶體頻寬,最為敏感的。為了確定這些影響因素的性質,我們將排序演算法sort改寫為四個版本,分別對byte位元組資料,short型資料,int型資料以及long型資料進行排序。這些程式所操作的資料都在0 ~ 255之間,以確保這些對比測試之間的平等性。理論上,操作資料的字寬越大,記憶體操作壓力也相應越大。

測試結果顯示,記憶體操作壓力的增加會導致加速比的降低,雖然我們無法提供明確的證據來證明這是引起這種表現的唯一原因。但資料的字寬的確是影響程式的效能的。比如,使用一個執行緒,排序位元組byte資料需要耗時122.5秒,然而排序long資料則需要耗時242.5秒。

4.4 任務同步

正如3.2章節所討論的,任務竊取模型經常會在處理任務的同步上遇到問題,如果工作執行緒獲取任務的時候,但相應的佇列已經沒有任務可供獲取,這樣就會產生競爭。在FJTask框架中,這種情況有時會導致執行緒強制睡眠。

Jacobi程式中我們可以看到這類問題。Jacobi程式執行100步,每一步的操作,相應矩陣點周圍的單元都會進行重新整理。程式中有一個全域性的屏障分隔。為了明確這種同步操作的影響大小。我們在一個程式中每10步操作進行一次同步。如圖中表現出的擴充套件性的差異說明了這種併發策略的影響。也暗示著我們在這個框架後續的版本中應該增加額外的方法以供程式設計師來重寫,以調整框架在不同的場景中達到最大的效率。(注意,這種圖可能對同步因素的影響略有誇大,因為10步同步的版本很可能需要管理更多的任務區域性性)

4.5 任務區域性性

FJTask,或者說其他的Fork/Join框架在任務分配上都是做了優化的,儘可能多的使工作執行緒處理自己分解產生的任務。因為如果不這樣做,程式的效能會受到影響,原因有二:

  1. 從其他佇列竊取任務的開銷要比在自己佇列執行pop操作的開銷大。
  2. 在大多數程式中,任務操作操作的是一個共享的資料單元,如果只執行自己部分的任務可以獲得更好的區域性資料訪問。

如上圖所示,在大多數程式中,竊取任務的相對資料都最多維持在很低的百分比。然後其中LUMM程式隨著執行緒數目的增加,會在工作負載上產生更大的不平衡性(相對的產生了更多的任務竊取)。通過調整演算法我們可以降低這種影響以獲得更好的加速比。

4.6 與其他框架比較

與其他不同語言的框架相比較,不太可能會得到什麼明確的或者說有意義的比較結果。但是,通過這種方法,最起碼可以知道FJTask在與其他語言(這裡主要指的是CC++)所編寫的相近框架比較所表現的優勢和限制。下面這個表格展示了幾種相似框架(CilkHoodStackthreads以及Filaments)所測試的效能資料。涉及到的測試都是在4 CPUSun Enterprise 450伺服器執行4個執行緒進行的。為了避免在不同的框架或者程式上進行重新配置,所有的測試程式執行的問題集都比上面的測試稍小些。得到的資料也是取三次測試中的最優值,以確保編譯器或者說是執行時配置都提供了最好的效能。其中Fib程式沒有指定任務粒度的閥值,也就是說預設的1。(這個設定在Filaments版的Fib程式中設定為1024,這樣程式會表現的和其它版本更為一致)。

在加速比的測試中,不同框架在不同程式上所得到的測試結果非常接近,執行緒數目1 ~ 4,加速比表現在(3.0 ~ 4.0之間)。因此下圖也就只聚焦在不同框架表現的不同的絕對效能上,然而因為在多執行緒方面,所有的框架都是非常快的,大多數的差異更多的是有程式碼本身的質量,編譯器的不同,優化配置項或者設定引數造成的。實際應用中,根據實際需要選擇不同的框架以彌補不同框架之間表現的巨大差異。

FJTask在處理浮點陣列和矩陣的計算上效能表現的比較差。即使Java虛擬機器效能不斷的提升,但是相比於那些CC++語言所使用的強大的後端優化器,其競爭力還是不夠的。雖然在上面的圖表中沒有顯示,但FJTask版本的所有程式都要比那些沒有進行編譯優化的框架還是執行的快的。以及一些非正式的測試也表明,測試所得的大多數差異都是由於陣列邊界檢查,執行時義務造成的。這也是Java虛擬機器以及編譯器開發者一直以來關注並持續解決的問題。

相比較,計算敏感型程式因為編碼質量所引起的效能差異卻是很少的。

5. 結論

本論文闡述了使用純Java實現支援可移植的(portable)、高效率的(efficient)和可伸縮的(scalable)並行處理的可能性,並提供了便利的API讓程式設計師可以遵循很少幾個設計規則和模式(參考資料[7]中有提出和討論)就可以利用好框架。從本文的示例程式中觀察分析到的效能特性也同時為使用者提供了進一步的指導,並提出了框架本身可以潛在改進的地方。

儘管所展示的可伸縮性結果針對的是單個JVM,但根據經驗這些主要的發現在更一般的情況下應該仍然成立:

  • 儘管分代GCgenerational GC)通常與並行協作得很好,但當垃圾生成速度很快而迫使GC很頻繁時會阻礙程式的伸縮性。在這樣的JVM上,這個底層原因看起來會導致為了GC導致停止執行緒的花費的時間大致與執行的執行緒數量成正比。因為執行的執行緒越多那麼單位時間內生成的垃圾也就越多,開銷的增加大致與執行緒數的平方。即使如此,只有在GC頻度相對高時,才會對效能有明顯的影響。當然,這個問題需要進一步的研究和開發並行GC演算法。本文的結果也說明了,在多處理器JVM上提供優化選項(tuning options)和適應機制(adaptive mechanisms)以讓記憶體可以按活躍CPU數目擴充套件是有必要的。
  • 大多數的伸縮性問題只有當執行的程式所用的CPU多於多數裝置上可用CPU時,才會顯現出來。FJTask(以及其它Fork/Join框架)在常見的2路、4路和8路的SMP機器上表現出接近理想情況加速效果。對於為stock multiprocessor設計的執行在多於16個CPU上的Fork/Join框架,本文可能是第一篇給出系統化報告結果的論文。在其它框架中這個結果中的模式是否仍然成立需要進一步的測量。
  • 應用程式的特徵(包括記憶體區域性性、任務區域性性和全域性同步的使用)常常比框架、JVM或是底層OS的特徵對於伸縮性和絕對效能的影響更大。舉個例子,在非正式的測試中可以看到,精心避免deques上同步操作(在3.1節中討論過)對於生成任務相對少的程式(如LU)完全沒有改善。然而,把任務管理上開銷減至最小卻可以拓寬框架及其相關設計和程式設計技巧的適用範圍和效用。

除了對於框架做漸進性的改良,未來可以做的包括在框架上構建有用的應用(而不是Demo和測試)、在生產環境的應用負載下的後續評估、在不同的JVM上測量以及為搭載多處理器的叢集的方便使用開發擴充套件。

6. 致謝

本文的部分工作受到來自Sun實驗室的合作研究資助的支援。感謝Sun實驗室Java課題組的 Ole AgesenDave DetlefsChristine FloodAlex Garthwaite 和 Steve Heller 的建議、幫助和評論。David HolmesOle AgesenKeith RandallKenjiro Taura 以及哪些我不知道名字的審校人員為本論文的草稿提供的有用的評論。Bill Pugh 指出了在3.1節討論到的JVM的寫後讀的侷限(read−after−write limitations)。特別感謝 Dave Dice 抽出時間在30路企業機型上執行了測試。

7. 參考文獻

  • [1] Agesen, Ole, David Detlefs, and J. Eliot B. Moss. Garbage Collection and Local Variable Type−Precision and Liveness in Java Virtual Machines. In Proceedings of 1998 ACM SIGPLAN Conference on Programming Language Design and Implementation (PLDI), 1998.
  • [2] Agesen, Ole, David Detlefs, Alex Garthwaite, Ross Knippel, Y.S. Ramakrishna, and Derek White. An Efficient Meta−lock for Implementing Ubiquitous Synchronization. In Proceedings of OOPSLA ’99, ACM, 1999.
  • [3] Arora, Nimar, Robert D. Blumofe, and C. Greg Plaxton. Thread Scheduling for Multiprogrammed Multiprocessors. In Proceedings of the Tenth Annual ACM Symposium on Parallel Algorithms and Architectures (SPAA), Puerto Vallarta, Mexico, June 28 − July 2, 1998.
  • [4] Blumofe, Robert D. and Dionisios Papadopoulos. Hood: A User−Level Threads Library for Multiprogrammed Multiprocessors. Technical Report, University of Texas at Austin, 1999.
  • [5] Frigo, Matteo, Charles Leiserson, and Keith Randall. The Implementation of the Cilk−5 Multithreaded Language. In Proceedings of 1998 ACM SIGPLAN Conference on Programming Language Design and Implementation (PLDI), 1998.
  • [6] Gosling, James, Bill Joy, and Guy Steele. The Java Language Specification, Addison−Wesley, 1996.
  • [7] Lea, Doug. Concurrent Programming in Java, second edition, Addison−Wesley, 1999.
  • [8] Lowenthal, David K., Vincent W. Freeh, and Gregory R. Andrews. Efficient Fine−Grain Parallelism on Shared−Memory Machines. Concurrency−Practice and Experience, 10,3:157−173, 1998.
  • [9] Simpson, David, and F. Warren Burton. Space efficient execution of deterministic parallel programs. IEEE Transactions on Software Engineering, December, 1999.
  • [10] Taura, Kenjiro, Kunio Tabata, and Akinori Yonezawa. “Stackthreads/MP: Integrating Futures into Calling Standards.” In Proceedings of ACM SIGPLAN Symposium on Principles & Practice of Parallel Programming (PPoPP), 1999.