12-ForkJoin

任務類型

思考: 線程池的線程數設置多少合適?

我們調整線程池中的線程數量的最主要的目的是爲了充分併合理地使用 CPU 和內存等資源,從而最大限度地提高程序的性能。在實際工作中,我們需要根據任務類型的不同選擇對應的策略。

CPU密集型任務

CPU密集型任務也叫計算密集型任務,比如加密、解密、壓縮、計算等一系列需要大量耗費 CPU 資源的任務。對於這樣的任務最佳的線程數爲 CPU 核心數的 1~2 倍,如果設置過多的線程數,實際上並不會起到很好的效果。此時假設我們設置的線程數量是 CPU 核心數的 2 倍以上,因爲計算任務非常重,會佔用大量的 CPU 資源,所以這時 CPU 的每個核心工作基本都是滿負荷的,而我們又設置了過多的線程,每個線程都想去利用 CPU 資源來執行自己的任務,這就會造成不必要的上下文切換,此時線程數的增多並沒有讓性能提升,反而由於線程數量過多會導致性能下降。

IO密集型任務

IO密集型任務,比如數據庫、文件的讀寫,網絡通信等任務,這種任務的特點是並不會特別消耗 CPU 資源,但是 IO 操作很耗時,總體會佔用比較多的時間。對於這種任務最大線程數一般會大於 CPU 核心數很多倍,因爲 IO 讀寫速度相比於 CPU 的速度而言是比較慢的,如果我們設置過少的線程數,就可能導致 CPU 資源的浪費。而如果我們設置更多的線程數,那麼當一部分線程正在等待 IO 的時候,它們此時並不需要 CPU 來計算,那麼另外的線程便可以利用 CPU 去執行其他的任務,互不影響,這樣的話在工作隊列中等待的任務就會減少,可以更好地利用資源。

線程數計算方法

《Java併發編程實戰》的作者 Brain Goetz 推薦的計算方法:

線程數 = CPU 核心數 *(1+平均等待時間/平均工作時間)

通過這個公式,我們可以計算出一個合理的線程數量,如果任務的平均等待時間長,線程數就隨之增加,而如果平均工作時間長,也就是對於我們上面的 CPU 密集型任務,線程數就隨之減少。

太少的線程數會使得程序整體性能降低,而過多的線程也會消耗內存等其他資源,所以如果想要更準確的話,可以進行壓測,監控 JVM 的線程情況以及 CPU 的負載情況,根據實際情況衡量應該創建的線程數,合理並充分利用資源。

算法題

思考: 如何充分利用多核CPU的性能,計算一個很大數組中所有整數的和?

  • 單線程相加,一個for循環搞定

  • 利用多線程進行任務拆分,比如藉助線程池進行分段相加,最後再把每個段的結果相加。

image

分治算法

分治算法的基本思想是將一個規模爲N的問題分解爲K個規模較小的子問題,這些子問題相互獨立且與原問題性質相同。求出子問題的解,就可得到原問題的解。

分治算法的步驟如下:

  1. 分解:將要解決的問題劃分成若干規模較小的同類問題;
  2. 求解:當子問題劃分得足夠小時,用較簡單的方法解決;
  3. 合併:按原問題的要求,將子問題的解逐層合併構成原問題的解。

image

在分治法中,子問題一般是相互獨立的,因此,經常通過遞歸調用算法來求解子問題。

image

應用場景

分治思想在很多領域都有廣泛的應用,例如算法領域有分治算法(歸併排序、快速排序都屬於分治算法,二分法查找也是一種分治算法);大數據領域知名的計算框架 MapReduce 背後的思想也是分治。既然分治這種任務模型如此普遍,那 Java 顯然也需要支持,Java 併發包裏提供了一種叫做 Fork/Join 的並行計算框架,就是用來支持分治這種任務模型的。

Fork/Join框架介紹

傳統線程池ThreadPoolExecutor有兩個明顯的缺點:一是無法對大任務進行拆分,對於某個任務只能由單線程執行;二是工作線程從隊列中獲取任務時存在競爭情況。這兩個缺點都會影響任務的執行效率。爲了解決傳統線程池的缺陷,Java7中引入Fork/Join框架,並在Java8中得到廣泛應用。Fork/Join框架的核心是ForkJoinPool類,它是對AbstractExecutorService類的擴展。ForkJoinPool允許其他線程向它提交任務,並根據設定將這些任務拆分爲粒度更細的子任務,這些子任務將由ForkJoinPool內部的工作線程來並行執行,並且工作線程之間可以竊取彼此之間的任務。

image

ForkJoinPool最適合計算密集型任務,而且最好是非阻塞任務。ForkJoinPool是ThreadPoolExecutor線程池的一種補充,是對計算密集型場景的加強。

根據經驗和實驗,任務總數、單任務執行耗時以及並行數都會影響到Fork/Join的性能。所以,當你使用Fork/Join框架時,你需要謹慎評估這三個指標,最好能通過模擬對比評估,不要憑感覺冒然在生產環境使用。

Fork/Join的使用

Fork/Join 計算框架主要包含兩部分,一部分是分治任務的線程池 ForkJoinPool,另一部分是分治任務 ForkJoinTask

ForkJoinPool

ForkJoinPool 是用於執行 ForkJoinTask 任務的執行池,不再是傳統執行池 Worker+Queue 的組合式,而是維護了一個隊列數組 WorkQueue(WorkQueue[]),這樣在提交任務和線程任務的時候大幅度減少碰撞。

ForkJoinPool構造器

image

ForkJoinPool中有四個核心參數,用於控制線程池的並行數、工作線程的創建、異常處理和模式指定等。各參數解釋如下:

  • int parallelism:指定並行級別(parallelism level)。ForkJoinPool將根據這個設定,決定工作線程的數量。如果未設置的話,將使用Runtime.getRuntime().availableProcessors()來設置並行級別;
  • ForkJoinWorkerThreadFactory factory:ForkJoinPool在創建線程時,會通過factory來創建。注意,這裏需要實現的是ForkJoinWorkerThreadFactory,而不是ThreadFactory。如果你不指定factory,那麼將由默認的DefaultForkJoinWorkerThreadFactory負責線程的創建工作;
  • UncaughtExceptionHandler handler:指定異常處理器,當任務在運行中出錯時,將由設定的處理器處理;
  • boolean asyncMode:設置隊列的工作模式:asyncMode ? FIFO_QUEUE : LIFO_QUEUE。當asyncMode爲true時,將使用先進先出隊列,而爲false時則使用後進先出的模式。

按類型提交不同任務

任務提交是ForkJoinPool的核心能力之一,提交任務有三種方式:

返回值 方法
提交異步執行 void execute(ForkJoinTask task)execute(Runnable task)
等待並獲取結果 T invoke(ForkJoinTask task)
提交執行獲取Future結果 ForkJoinTask submit(ForkJoinTask task)submit(Callable task)submit(Runnable task)submit(Runnable task, T result)
  • execute類型的方法在提交任務後,不會返回結果。ForkJoinPool不僅允許提交ForkJoinTask類型任務,還允許提交Runnable任務

執行Runnable類型任務時,將會轉換爲ForkJoinTask類型。由於任務是不可切分的,所以這類任務無法獲得任務拆分這方面的效益,不過仍然可以獲得任務竊取帶來的好處和性能提升。

  • invoke方法接受ForkJoinTask類型的任務,並在任務執行結束後,返回泛型結果。如果提交的任務是null,將拋出空指針異常。
  • submit方法支持三種類型的任務提交:ForkJoinTask類型、Callable類型和Runnable類型。在提交任務後,將返回ForkJoinTask類型的結果。如果提交的任務是null,將拋出空指針異常,並且當任務不能按計劃執行的話,將拋出任務拒絕異常。
//遞歸任務  用於計算數組總和
LongSum ls = new LongSum(array, 0, array.length);
// 構建ForkJoinPool
ForkJoinPool fjp  = new ForkJoinPool(12);
//ForkJoin計算數組總和
ForkJoinTask<Long> result = fjp.submit(ls);

ForkJoinTask

ForkJoinTask是ForkJoinPool的核心之一,它是任務的實際載體,定義了任務執行時的具體邏輯和拆分邏輯。ForkJoinTask繼承了Future接口,所以也可以將其看作是輕量級的Future。

ForkJoinTask 是一個抽象類,它的方法有很多,最核心的是 fork() 方法和 join() 方法,承載着主要的任務協調作用,一個用於任務提交,一個用於結果獲取。

  • fork()——提交任務

fork()方法用於向當前任務所運行的線程池中提交任務。如果當前線程是ForkJoinWorkerThread類型,將會放入該線程的工作隊列,否則放入common線程池的工作隊列中。

  • join()——獲取任務執行結果

join()方法用於獲取任務的執行結果。調用join()時,將阻塞當前線程直到對應的子任務完成運行並返回結果。

通常情況下我們不需要直接繼承ForkJoinTask類,而只需要繼承它的子類,Fork/Join框架提供了以下三個子類:

  • RecursiveAction:用於遞歸執行但不需要返回結果的任務。
  • RecursiveTask :用於遞歸執行需要返回結果的任務。
  • CountedCompleter :在任務完成執行後會觸發執行一個自定義的鉤子函數
public class LongSum extends RecursiveTask<Long> {
    // 任務拆分最小閾值
    static final int SEQUENTIAL_THRESHOLD = 10000;

    int low;
    int high;
    int[] array;

    LongSum(int[] arr, int lo, int hi) {
        array = arr;
        low = lo;
        high = hi;
    }

    @Override
    protected Long compute() {

        //當任務拆分到小於等於閥值時開始求和
        if (high - low <= SEQUENTIAL_THRESHOLD) {

            long sum = 0;
            for (int i = low; i < high; ++i) {
                sum += array[i];
            }
            return sum;
        } else {  // 任務過大繼續拆分
            int mid = low + (high - low) / 2;
            LongSum left = new LongSum(array, low, mid);
            LongSum right = new LongSum(array, mid, high);
            // 提交任務
            left.fork();
            right.fork();
            //獲取任務的執行結果,將阻塞當前線程直到對應的子任務完成運行並返回結果
            long rightAns = right.join();
            long leftAns = left.join();
            return leftAns + rightAns;
        }
    }
}

ForkJoinTask使用限制

ForkJoinTask最適合用於純粹的計算任務,也就是純函數計算,計算過程中的對象都是獨立的,對外部沒有依賴。提交到ForkJoinPool中的任務應避免執行阻塞I/O。

ForkJoinPool 的工作原理

  • ForkJoinPool 內部有多個工作隊列,當我們通過 ForkJoinPool 的 invoke() 或者 submit() 方法提交任務時,ForkJoinPool 根據一定的路由規則把任務提交到一個工作隊列中,如果任務在執行過程中會創建出子任務,那麼子任務會提交到工作線程對應的工作隊列中。
  • ForkJoinPool 的每個工作線程都維護着一個工作隊列(WorkQueue),這是一個雙端隊列(Deque),裏面存放的對象是任務(ForkJoinTask)。
  • 每個工作線程在運行中產生新的任務(通常是因爲調用了 fork())時,會放入工作隊列的top,並且工作線程在處理自己的工作隊列時,使用的是 LIFO 方式,也就是說每次從top取出任務來執行。
  • 每個工作線程在處理自己的工作隊列同時,會嘗試竊取一個任務,竊取的任務位於其他線程的工作隊列的base,也就是說工作線程在竊取其他工作線程的任務時,使用的是FIFO 方式。
  • 在遇到 join() 時,如果需要 join 的任務尚未完成,則會先處理其他任務,並等待其完成。
  • 在既沒有自己的任務,也沒有可以竊取的任務時,進入休眠 。

工作竊取

ForkJoinPool與ThreadPoolExecutor有個很大的不同之處在於,ForkJoinPool存在引入了工作竊取設計,它是其性能保證的關鍵之一。工作竊取,就是允許空閒線程從繁忙線程的雙端隊列中竊取任務。默認情況下,工作線程從它自己的雙端隊列的頭部獲取任務。但是,當自己的任務爲空時,線程會從其他繁忙線程雙端隊列的尾部中獲取任務。這種方法,最大限度地減少了線程競爭任務的可能性。

ForkJoinPool的大部分操作都發生在工作竊取隊列(work-stealing queues ) 中,該隊列由內部類WorkQueue實現。它是Deques的特殊形式,但僅支持三種操作方式:push、pop和poll(也稱爲竊取)。在ForkJoinPool中,隊列的讀取有着嚴格的約束,push和pop僅能從其所屬線程調用,而poll則可以從其他線程調用。

工作竊取的運行流程如下圖所示 :

image

  • 工作竊取算法的優點是充分利用線程進行並行計算,並減少了線程間的競爭;
  • 工作竊取算法缺點是在某些情況下還是存在競爭,比如雙端隊列裏只有一個任務時。並且消耗了更多的系統資源,比如創建多個線程和多個雙端隊列。

思考:爲什麼這麼設計,工作線程總是從頭部獲取任務,竊取線程從尾部獲取任務?

這樣做的主要原因是爲了提高性能,通過始終選擇最近提交的任務,可以增加資源仍分配在CPU緩存中的機會,這樣CPU處理起來要快一些。而竊取者之所以從尾部獲取任務,則是爲了降低線程之間的競爭可能,畢竟大家都從一個部分拿任務,競爭的可能要大很多。

此外,這樣的設計還有一種考慮。由於任務是可分割的,那隊列中較舊的任務最有可能粒度較大,因爲它們可能還沒有被分割,而空閒的線程則相對更有“精力”來完成這些粒度較大的任務。

工作隊列WorkQueue

  • WorkQueue 是雙向列表,用於任務的有序執行,如果 WorkQueue 用於自己的執行線程 Thread,線程默認將會從尾端選取任務用來執行 LIFO。
  • 每個 ForkJoinWorkThread 都有屬於自己的 WorkQueue,但不是每個 WorkQueue 都有對應的 ForkJoinWorkThread。
  • 沒有 ForkJoinWorkThread 的 WorkQueue 保存的是 submission,來自外部提交,在WorkQueues[] 的下標是 偶數 位。

image

ForkJoinWorkThread

ForkJoinWorkThread 是用於執行任務的線程,用於區別使用非 ForkJoinWorkThread 線程提交task。啓動一個該 Thread,會自動註冊一個 WorkQueue 到 Pool,擁有 Thread 的 WorkQueue 只能出現在 WorkQueues[] 的 奇數 位。

image

ForkJoinPool執行流程

https://www.processon.com/view/link/5db81f97e4b0c55537456e9a

image

總結

Fork/Join是一種基於分治算法的模型,在併發處理計算型任務時有着顯著的優勢。其效率的提升主要得益於兩個方面:

  • 任務切分:將大的任務分割成更小粒度的小任務,讓更多的線程參與執行;
  • 任務竊取:通過任務竊取,充分地利用空閒線程,並減少競爭。

在使用ForkJoinPool時,需要特別注意任務的類型是否爲純函數計算類型,也就是這些任務不應該關心狀態或者外界的變化,這樣纔是最安全的做法。如果是阻塞類型任務,那麼你需要謹慎評估技術方案。雖然ForkJoinPool也能處理阻塞類型任務,但可能會帶來複雜的管理成本。

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