天天看點

JDK8 Fork/Join Work Stealing

java 8 已經釋出一段時間了,許多開發者已經開始使用 java 8。本文也将讨論最新釋出在 jdk 中的并發功能更新。事實上,jdk 中已經有多處<code>java.util.concurrent</code> 改動,但本文重點将是 fork-join 架構的改進。我們将讨論一點 fork-join,然後實作一個簡單的基準測試以比較 fj 在 java 7 和java 8 中的性能。

JDK8 Fork/Join Work Stealing

forkjoin 是一個通常用于并行計算遞歸任務的架構。它最早被引入java 7 中,從那時起它就能很好地完成目标任務。原因在于,許多大型任務本質上都可以遞歸表示。

使 forkjoinpool 不同于其他 executorservices 的是,在當下并不執行任務的工作線程會檢查其夥伴的工作狀态,并向他們借取任務。這種技術稱為 work-stealing 。那麼,work-stealing 有什麼妙用呢?

JDK8 Fork/Join Work Stealing

work-stealing 是一種分散式的工作量管理方法,無需将工作單元配置設定給所有可用的工作線程,而是每個線程自己管理其任務隊列。關鍵在于高效地管理這些隊列。

關于讓每個工作程序處理自己的隊列,有兩個主要問題:

外部送出的任務去哪裡了?

我們怎樣組織 work-stealing 以有效通路隊列

本質上來說,在執行大型任務時,外部送出任務和由工作線程建立的任務之間差別不大。他們都有類似的執行要求并提供結果。然而,運作方式是不同的。最主要的差別在于由工作程序建立的任務可以被竊取。這意味着即便被放進了一個工作程序的任務隊列中,他們仍可能被其他工作程序執行。

forkjoin 架構處理它的方法很簡單,每個工作線程都有2個任務隊列,一個用于外部任務,另一個用于實作竊取工作程序的運作。當外部送出任務時,會将任務添加至随機的工作隊列中。當一個任務被分為更小的任務時,工作線程将他們添加到自己的任務隊列中,并希望其他工作線程來幫忙。

竊取任務的想法基于以下事實:工作線程在它任務隊列末尾添加任務。在正常的執行過程中,每個工作線程試着去從任務隊列的隊首拿任務,當其個人隊列的任務為空時,這一操作就會失敗,轉而竊取别的工作線程的任務隊列末尾的任務。這有效避免了多數任務隊列的互鎖問題,提高了性能。

另一個使 forkjoin 池工作更快的訣竅是當一個工作線程竊取任務時,它留下了它在哪裡取得任務的線索,這樣原始的工作線程可以找到它并且幫助該工作線程,是以父任務的的工作進展會更快。

總而言之,這是一套極其複雜的系統,需要大量的背景知識使其順利運作。并且,系統的屬性和性能與具體實作的方式關系很大。是以筆者懷疑,若不進行重大的重構,系統會徹底改變。

增加了 forkjoinpools 的功能并提高其性能,使其應用在使用者希望的日益廣泛的應用中,且效率更高。新特性包括對最适于 io-bound 使用的 completion-based 設計的支援等。
當大量的使用者送出大量任務時,吞吐量能大幅度提高。其原理是将外部送出者與工作線程相似地對待——均使用随機任務隊列和竊取任務。當所有任務都為異步,且被送出至 pool 而不是 forked 時,能極大地提高吞吐量。

然而找出究竟什麼被改變了、哪些場景被影響了并不簡單。是以,讓我們換一種方式解決。筆者會建立一個基準測試程式以模仿簡單的 forkjoin 計算,并測量 forkjoin 處理任務與單個線程依次完成任務各自所需時間,希望這種方法能幫我們找出改善的具體内容。

jmh 還附帶了 maven 原型項目。是以,将一切設定好其實很簡單。

在寫本文時,jmh core 的最新版本是 0.4.1 ,包括了 @param 注釋,可用一系列的參數化輸入運作基準測試程式。這減輕了手動重複執行相同基準測試的痛苦,并簡化了擷取結果的流程。

現在,每個基準測試疊代會獲得自己的 forkjoinpool 執行個體,這也減少了常用 forkjoinpool 執行個體化在 java 8 與其之前版本中的差別。

<code>sin</code> 、<code>cos</code> 和 <code>tan</code> 是 <code>recursivetask</code> 的執行個體,實際上 sin 和 cos 并不遞歸,但會分别計算 <code>math.sin(input)</code> 和 <code>math.cos(input)</code> 的值 。tan 的任務實際上會遞歸為一組 sin 和 cos ,并傳回兩者的除法結果。

jmh 處理項目的代碼并從标有 <code>@generatemicrobenchmark</code> 注釋的方法處生成基準測試程式。你在該類上方看到的其他注釋指定了基準測試的選項:疊代次數,計入最終結果的疊代次數,是否 fork 另一個 jvm 程序用于基準測試以及測量哪些值。測量值可以是代碼的吞吐量,或這些方法在一段時間内的執行次數。

<code>@param</code> 指定運作基準測試程式時幾個輸入的大小。總而言之,jmh非常簡單,建立基準測試程式不需要手動處理疊代、定時或整理結果。

用 java 7 和 8 運作該基準測試得到以下結果。筆者分别使用的是1.7.0_40 and 1.8.0.版本。

為了便于檢視結果,下面以圖表形式進行展示。

JDK8 Fork/Join Work Stealing

我們可以看到 jdk 7 與 8 間的基線結果(直接用同一線程運作程式的吞吐量)差異并不大。然而,若加入管理遞歸任務的時間,使用 forkjoin 來執行,則 java 8 的速度更快。這個簡單的基準測試表明,在最新版的 java 中,管理 forkjoin 任務的效率有了 35% 左右的性能提高。

基線和 fj 計算之間的結果差異是因為我們刻意建立的遞歸任務非常單薄。該任務實質上隻是調用一個優化後的數學類。是以,直接進行數學運算會快得多。一個更強壯的任務必将改變這一情況,但是它們會減輕 forkjoin 管理的開銷,而這是我們起初就想測量的目标。不過,一般而言,執行遞歸任務比多次執行同個方法調用要高效得多。

同時,java 7 和 java 8 的基線測試結果也有略微的不同。這個差異是可以忽視的,但很可能不是因為 java 7 和 8 中數學類的實作差異造成的。而是一個測量假象,jmh 努力抵消卻還是無法避免。

JDK8 Fork/Join Work Stealing

免責聲明:當然,這些結果是模拟所得的,你應該持保留态度。然而,除了讨論 java 性能,筆者也想展示 jmh 建立基準測試程式是如何簡單,且能避免一些常見基準測試問題,比如沒有提前預熱 jvm 。如果基準測試本身存在缺陷,熱身也無濟于事,但是肯定還是有所裨益。是以,如果你看到以上代碼中的邏輯缺陷,請一定告訴筆者。

JDK8 Fork/Join Work Stealing

然而,這些類的文檔豐富,并且包含許多内部注釋。它也可能學習挖掘jdk最有趣的地方。

另一個相關的發現是 forkjoinpool 在 java8 中的性能更好,至少在一些用例中是這樣的。雖然筆者不能精确地描述這背後的原因,但如果我在代碼中用到 forkjoin ,我一定會更新 java 版本。