Java中使用ThreadPoolExecutor並行執行獨立的單執行緒任務

文 學敏發表於2016-08-06

Java SE 5.0中引入了任務執行框架,這是簡化多執行緒程式設計開發的一大進步。使用這個框架可以方便地管理任務:管理任務的生命週期以及執行策略。

在這篇文章中,我們通過一個簡單的例子來展現這個框架所帶來的靈活與簡單。

基礎

執行框架引入了Executor介面來管理任務的執行。Executor是一個用來提交Runnable任務的介面。這個介面將任務提交與任務執行隔離起來:擁有不同執行策略的executor都實現了同一個提交介面。改變執行策略不會影響任務的提交邏輯。

如果你要提交一個Runnable物件來執行,很簡單:

Executor exec = …;
exec.execute(runnable);

執行緒池

如前所述,executor如何去執行提交的runnable任務並沒有在Executor介面中規定,這取決於你所用的executor的具體型別。這個框架提供了幾種不同的executor,執行策略針對不同的場景而不同。

你可能會用到的最常見的executor型別就是執行緒池executor,也就是ThreadPoolExecutor類(及其子類)的例項。ThreadPoolExecutor管理著一個執行緒池和一個工作佇列,執行緒池存放著用於執行任務的工作執行緒。

你肯定在其他技術中也瞭解過“池”的概念。使用“池”的一個最大的好處就是減少資源建立的開銷,用過並釋放後,還可以重用。另一個間接的好處是你可以控制使用資源的多少。比如,你可以調整執行緒池的大小達到你想要的負載,而不損害系統的資源。

這個框架提供了一個工廠類,叫Executors,來建立執行緒池。使用這個工程類你可以建立不同特性的執行緒池。儘管底層的實現常常是一樣的(ThreadPoolExecutor),但工廠類可以使你不必使用複雜的建構函式就可以快速地設定一個執行緒池。工程類的工廠方法有:

  • newFixedThreadPool:該方法返回一個最大容量固定的執行緒池。它會按需建立新執行緒,執行緒數量不大於配置的數量大小。當執行緒數達到最大以後,執行緒池會一直維持這麼多不變。
  • newCachedThreadPool:該方法返回一個無界的執行緒池,也就是沒有最大數量限制。但當工作量減小時,這類執行緒池會銷燬沒用的執行緒。
  • newSingleThreadedExecutor:該方法返回一個executor,它可以保證所有的任務都在一個單執行緒中執行。
  • newScheduledThreadPool:該方法返回一個固定大小的執行緒池,它支援延時和定時任務的執行。

這僅僅是一個開端。Executor還有一些其他用法已超出了這篇文章的範圍,我強烈推薦你研究以下內容:

  • 生命週期管理的方法,這些方法由ExecutorService介面宣告(比如shutdown()和awaitTermination())。
  • 使用CompletionService來查詢任務狀態、獲取返回值,如果有返回值的話。

ExecutorService介面特別重要,因為它提供了關閉執行緒池的方法,並確保清理了不再使用的資源。令人欣慰的是,ExecutorService介面相當簡單、一目瞭然,我建議全面地學習下它的文件。

大致來說,當你向ExecutorService傳送了一個shutdown()訊息後,它就不會接收新提交的任務,但是仍在佇列中的任務會被繼續處理完。你可以使用isTerminated()來查詢ExecutorService終止狀態,或使用awaitTermination(…)方法來等待ExecutorService終止。如果傳入一個最大超時時間作為引數,awaitTermination方法就不會永遠等待。

警告: 對JVM程式永遠不會退出的理解上,存在著一些錯誤和迷惑。如果你不關閉executorService,只是銷燬了底層的執行緒,JVM就不會退出。當最後一個普通執行緒(非守護執行緒)退出後,JVM也會退出。

配置ThreadPoolExecutor

如果你決定不使用Executor的工廠類,而是手動建立一個 ThreadPoolExecutor,你需要使用建構函式來建立並配置。下面是這個類使用最廣泛的一個建構函式:

public ThreadPoolExecutor(
    int corePoolSize,
    int maxPoolSize,
    long keepAlive,
    TimeUnit unit,
    BlockingQueue<Runnable> workQueue,
    RejectedExecutionHandler handler);

如你所見,你可以配置以下內容:

  • 核心池的大小(執行緒池將會使用的大小)
  • 最大池大小
  • 存活時間,空閒執行緒在這個時間後被銷燬
  • 存放任務的工作佇列
  • 任務提交拒絕後要執行的策略

限制佇列中任務數

限制執行任務的併發數、限制執行緒池大小對應用程式以及程式執行結果的可預期性與穩定性有很大的好處。無盡地建立執行緒,最終會耗盡執行時資源。你的應用程式因此會產生嚴重的效能問題,甚至導致程式不穩定。

這隻解決了部分問題:限制了併發任務數,但並沒有限制提交到等待佇列的任務數。如果任務提交的速率一直高於任務執行的速率,那麼應用程式最終會出現資源短缺的狀況。

解決方法是:

  • 為Executor提供一個存放待執行任務的阻塞佇列。如果佇列填滿,以後提交的任務會被“拒絕”。
  • 當任務提交被拒絕時會觸發RejectedExecutionHandler,這也是為什麼這個類名中引用動詞“rejected”。你可以實現自己的拒絕策略,或者使用框架內建的策略。

預設的拒絕策略可以讓executor丟擲一個RejectedExecutionException異常。然而,還有其他的內建策略:

  • 悄悄地丟棄一個任務
  • 丟棄最舊的任務,重新提交最新的
  • 在呼叫者的執行緒中執行被拒絕的任務

什麼時候以及為什麼我們才會這樣配置執行緒池?讓我們看一個例子。

示例:並行執行獨立的單執行緒任務

最近,我被叫去解決一個很久以前的任務的問題,我的客戶之前就執行過這個任務。大致來說,這個任務包含一個元件,這個元件監聽目錄樹所產生的檔案系統事件。每當一個事件被觸發,必須處理一個檔案。一個專門的單執行緒執行檔案處理。說真的,根據任務的特點,即使我能把它並行化,我也不想那麼做。一天的某些時候,事件到達率才很高,檔案也沒必要實時處理,在第二天之前處理完即可。

當前的實現採用了一些混合且匹配的技術,包括使用UNIX SHELL指令碼掃描目錄結構,並檢測是否發生改變。實現完成後,我們採用了雙核的執行環境。同樣,事件的到達率相當低:目前為止,事件數以百萬計,總共要處理1~2T位元組的原始資料。

執行處理程式的主機是12核的機器:很好機會去並行化這些舊的單執行緒任務。基本上,我們有了食譜的所有原料,我們需要做的僅僅是把程式建立起來並調節。在寫程式碼前,我們必須瞭解下程式的負載。我列一下我檢測到的內容:

  • 有非常多的檔案需要被週期性地掃描:每個目錄包含1~2百萬個檔案
  • 掃描演算法很快,可以並行化
  • 處理一個檔案至少需要1s,甚至上升到2s或3s
  • 處理檔案時,效能瓶頸主要是CPU
  • CPU利用率必須可調,根據一天時間的不同而使用不同的負載配置。

我需要這樣一個執行緒池,它的大小在程式執行的時候通過負載配置來設定。我傾向於根據負載策略建立一個固定大小的執行緒池。由於執行緒的效能瓶頸在CPU,它的核心使用率是100%,不會等待其他資源,那麼負載策略就很好計算了:用執行環境的CPU核心數乘以一個負載因子(保證計算的結果在峰值時至少有一個核心):

int cpus = Runtime.getRuntime().availableProcessors();
int maxThreads = cpus * scaleFactor;
maxThreads = (maxThreads > 0 ? maxThreads : 1);

然後我需要使用阻塞佇列建立一個ThreadPoolExecutor,可以限制提交的任務數。為什麼?是這樣,掃描演算法執行很快,很快就產生龐大數量需要處理的檔案。數量有多龐大呢?很難預測,因為變動太大了。我不想讓executor內部的佇列不加選擇地填滿了要執行的任務例項(這些例項包含了龐大的檔案描述符)。我寧願在佇列填滿時,拒絕這些檔案。

而且,我將使用ThreadPoolExecutor.CallerRunsPolicy作為拒絕策略。為什麼?因為當佇列已滿時,執行緒池的執行緒忙於處理檔案,我讓提交任務的執行緒去執行它(被拒絕的任務)。這樣,掃面會停止,轉而去處理一個檔案,處理結束後馬上又會掃描目錄。

下面是建立executor的程式碼:

ExecutorService executorService =
    new ThreadPoolExecutor(
        maxThreads, // core thread pool size
        maxThreads, // maximum thread pool size
        1, // time to wait before resizing pool
        TimeUnit.MINUTES, 
        new ArrayBlockingQueue<Runnable>(maxThreads, true),
        new ThreadPoolExecutor.CallerRunsPolicy());
 下面是程式的框架(極其簡化版):
// scanning loop: fake scanning
while (!dirsToProcess.isEmpty()) {
    File currentDir = dirsToProcess.pop();

    // listing children
    File[] children = currentDir.listFiles();

    // processing children
    for (final File currentFile : children) {
        // if it's a directory, defer processing
        if (currentFile.isDirectory()) {
            dirsToProcess.add(currentFile);
            continue;
        }

        executorService.submit(new Runnable() {
            @Override
            public void run() {
                try {
                    // if it's a file, process it
                    new ConvertTask(currentFile).perform();
                } catch (Exception ex) {
                    // error management logic
                }
            }
        });
    }
}

// ...
// wait for all of the executor threads to finish
executorService.shutdown();
try {
    if (!executorService.awaitTermination(60, TimeUnit.SECONDS)) {
        // pool didn't terminate after the first try
        executorService.shutdownNow();
    }

    if (!executorService.awaitTermination(60, TimeUnit.SECONDS)) {
        // pool didn't terminate after the second try
    }
} catch (InterruptedException ex) {
    executorService.shutdownNow();
    Thread.currentThread().interrupt();
}

總結

看到了吧,Java併發API非常簡單易用,十分靈活,也很強大。真希望我多年前可以多花點功夫寫一個這樣簡單的程式。這樣我就可以在幾小時內解決由傳統單執行緒元件所引發的擴充套件性問題。

相關文章