1. 程式人生 > >Effective Java 第三版——48. 謹慎使用流並行

Effective Java 第三版——48. 謹慎使用流並行

Tips
《Effective Java, Third Edition》一書英文版已經出版,這本書的第二版想必很多人都讀過,號稱Java四大名著之一,不過第二版2009年出版,到現在已經將近8年的時間,但隨著Java 6,7,8,甚至9的釋出,Java語言發生了深刻的變化。
在這裡第一時間翻譯成中文版。供大家學習分享之用。
書中的原始碼地址:https://github.com/jbloch/effective-java-3e-source-code
注意,書中的有些程式碼裡方法是基於Java 9 API中的,所以JDK 最好下載 JDK 9以上的版本。但是Java 9 只是一個過渡版本,所以建議安裝JDK 10。

Effective Java, Third Edition

48.謹慎使用流並行

在主流語言中,Java一直處於提供簡化併發程式設計任務的工具的最前沿。 當Java於1996年釋出時,它內建了對執行緒的支援,包括同步和wait / notify機制。 Java 5引入了java.util.concurrent類庫,帶有併發集合和執行器框架。 Java 7引入了fork-join包,這是一個用於並行分解的高效能框架。 Java 8引入了流,可以通過對parallel方法的單個呼叫來並行化。 用Java編寫併發程式變得越來越容易,但編寫正確快速的併發程式還像以前一樣困難。 安全和活躍度違規(liveness violation)是併發程式設計中的事實,並行流管道也不例外。

考慮條目 45中的程式:

// Stream-based program to generate the first 20 Mersenne primes

public static void main(String[] args) {

    primes().map(p -> TWO.pow(p.intValueExact()).subtract(ONE))

        .filter(mersenne -> mersenne.isProbablePrime(50))

        .limit(20)

        .forEach(System.out::println);

}

static Stream<BigInteger> primes() {

    return Stream.iterate(TWO, BigInteger::nextProbablePrime);

}

在我的機器上,這個程式立即開始列印素數,執行到完成需要12.5秒。假設我天真地嘗試通過向流管道中新增一個到parallel()的呼叫來加快速度。你認為它的表現會怎樣?它會快幾個百分點嗎?慢幾個百分點?遺憾的是,它不會列印任何東西,但是CPU使用率會飆升到90%,並且會無限期地停留在那裡(liveness failure:活性失敗)。這個程式可能最終會終止,但我不願意去等待;半小時後我強行阻止了它。

這裡發生了什麼?簡而言之,流類庫不知道如何並行化此管道並且啟發式失敗(heuristics fail.)。即使在最好的情況下,如果源來自Stream.iterate方法,或者使用中間操作limit方法,並行化管道也不太可能提高其效能。這個管道必須應對這兩個問題。更糟糕的是,預設的並行策略處理不可預測性的limit方法,假設在處理一些額外的元素和丟棄任何不必要的結果時沒有害處。在這種情況下,找到每個梅森素數的時間大約是找到上一個素數的兩倍。因此,計算單個額外元素的成本大致等於計算所有先前元素組合的成本,並且這種無害的管道使自動並行化演算法癱瘓。這個故事的寓意很簡單:不要無差別地並行化流管道(stream pipelines)。效能後果可能是災難性的。

通常,並行性帶來的效能收益在ArrayList、HashMap、HashSet和ConcurrentHashMap例項、陣列、int類型範圍和long型別的範圍的流上最好。這些資料結構的共同之處在於,它們都可以精確而廉價地分割成任意大小的子程式,這使得在並行執行緒之間劃分工作變得很容易。用於執行此任務的流淚庫使用的抽象是spliterator,它由spliterator方法在Stream和Iterable上返回。

所有這些資料結構的共同點的另一個重要因素是它們在順序處理時提供了從良好到極好的引用位置( locality of reference):順序元素引用在儲存器中儲存在一塊。 這些引用所引用的物件在儲存器中可能彼此不接近,這降低了引用區域性性。 對於並行化批量操作而言,引用位置非常重要:沒有它,執行緒大部分時間都處於空閒狀態,等待資料從記憶體傳輸到處理器的快取中。 具有最佳引用位置的資料結構是基本型別的陣列,因為資料本身連續儲存在儲存器中。

流管道終端操作的性質也會影響並行執行的有效性。 如果與管道的整體工作相比,在終端操作中完成了大量的工作,並且這種操作本質上是連續的,那麼並行化管道的有效性將是有限的。 並行性的最佳終操作是縮減(reductions),即使用流的reduce方法組合管道中出現的所有元素,或者預先打包的reduce(如min、max、count和sum)。短路操作anyMatchallMatchnoneMatch也可以支援並行性。由Stream的collect方法執行的操作,稱為可變縮減(mutable reductions),不適合並行性,因為組合集合的開銷非常大。

如果編寫自己的Stream,Iterable或Collection實現,並且希望獲得良好的並行效能,則必須重寫spliterator方法並廣泛測試生成的流的並行效能。 編寫高質量的spliterator很困難,超出了本書的範圍。

並行化一個流不僅會導致糟糕的效能,包括活性失敗(liveness failures);它會導致不正確的結果和不可預知的行為(安全故障)。使用對映器(mappers),過濾器(filters)和其他程式設計師提供的不符合其規範的功能物件的管道並行化可能會導致安全故障。 Stream規範對這些功能物件提出了嚴格的要求。 例如,傳遞給Stream的reduce方法操作的累加器(accumulator)和組合器(combiner)函式必須是關聯的,非干擾的和無狀態的。 如果違反了這些要求(其中一些在第46項中討論過),但按順序執行你的管道,則可能會產生正確的結果; 如果將它並行化,它可能會失敗,也許是災難性的。

沿著這些思路,值得注意的是,即使並行的梅森素數程式已經執行完成,它也不會以正確的(升序的)順序列印素數。為了保持順序版本顯示的順序,必須將forEach終端操作替換為forEachOrdered操作,它保證以遇出現順序(encounter order)遍歷並行流。

即使假設正在使用一個高效的可拆分的源流、一個可並行化的或廉價的終端操作以及非干擾的函式物件,也無法從並行化中獲得良好的加速效果,除非管道做了足夠的實際工作來抵消與並行性相關的成本。作為一個非常粗略的估計,流中的元素數量乘以每個元素執行的程式碼行數應該至少是100,000 [Lea14]。

重要的是要記住並行化流是嚴格的效能優化。 與任何優化一樣,必須在更改之前和之後測試效能,以確保它值得做(第67項)。 理想情況下,應該在實際的系統設定中執行測試。 通常,程式中的所有並行流管道都在公共fork-join池中執行。 單個行為不當的管道可能會損害系統中不相關部分的其他行為。

如果在並行化流管道時,這種可能性對你不利,那是因為它們確實存在。一個認識的人,他維護一個數百萬行程式碼庫,大量使用流,他發現只有少數幾個地方並行流是有效的。這並不意味著應該避免並行化流。在適當的情況下,只需向流管道新增一個parallel方法呼叫,就可以實現處理器核心數量的近似線性加速。某些領域,如機器學習和資料處理,特別適合這些加速。

作為並行性有效的流管道的簡單示例,請考慮此函式來計算π(n),素數小於或等於n:
// Prime-counting stream pipeline - benefits from parallelization
static long pi(long n) {
    return LongStream.rangeClosed(2, n)
        .mapToObj(BigInteger::valueOf)
        .filter(i -> i.isProbablePrime(50))
        .count();
}

在我的機器上,使用此功能計算π(108)需要31秒。 只需新增parallel()方法呼叫即可將時間縮短為9.2秒:

// Prime-counting stream pipeline - parallel version
static long pi(long n) {
    return LongStream.rangeClosed(2, n)
        .parallel()
        .mapToObj(BigInteger::valueOf)
        .filter(i -> i.isProbablePrime(50))
        .count();
}

換句話說,在我的四核計算機上,平行計算速度提高了3.7倍。值得注意的是,這不是你在實踐中如何計算π(n)為n的值。還有更有效的演算法,特別是Lehmer’s formula。

如果要並行化隨機數流,請從SplittableRandom例項開始,而不是ThreadLocalRandom(或基本上過時的Random)。 SplittableRandom專為此用途而設計,具有線性加速的潛力。ThreadLocalRandom設計用於單個執行緒,並將自身適應作為並行流源,但不會像SplittableRandom一樣快。Random例項在每個操作上進行同步,因此會導致過度的並行殺死爭用( parallelism-killing contention)。

總之,甚至不要嘗試並行化流管道,除非你有充分的理由相信它將保持計算的正確性並提高其速度。不恰當地並行化流的代價可能是程式失敗或效能災難。如果您認為並行性是合理的,那麼請確保您的程式碼在並行執行時保持正確,並在實際情況下進行仔細的效能度量。如果您的程式碼是正確的,並且這些實驗證實了您對效能提高的懷疑,那麼並且只有這樣才能在生產程式碼中並行化流。