執行緒池沒你想的那麼簡單(續)

crossoverJie發表於2019-06-06

執行緒池沒你想的那麼簡單(續)

前言

前段時間寫過一篇《執行緒池沒你想的那麼簡單》,和大家一起擼了一個基本的執行緒池,具備:

  • 執行緒池基本排程功能。
  • 執行緒池自動擴容縮容。
  • 佇列快取執行緒。
  • 關閉執行緒池。

這些功能,最後也留下了三個待實現的 features

  • 執行帶有返回值的執行緒。
  • 異常處理怎麼辦?
  • 所有任務執行完怎麼通知我?

這次就實現這三個特性來看看 j.u.c 中的執行緒池是如何實現這些需求的。

再看本文之前,強烈建議先檢視上文《執行緒池沒你想的那麼簡單》

任務完成後的通知

大家在用執行緒池的時候或多或少都會有這樣的需求:

執行緒池中的任務執行完畢後再通知主執行緒做其他事情,比如一批任務都執行完畢後再執行下一波任務等等。

執行緒池沒你想的那麼簡單(續)

以我們之前的程式碼為例:

總共往執行緒池中提交了 13 個任務,直到他們都執行完畢後再列印 “任務執行完畢” 這個日誌。

執行結果如下:

執行緒池沒你想的那麼簡單(續)

為了簡單的達到這個效果,我們可以在初始化執行緒池的時候傳入一個介面的實現,這個介面就是用於任務完成之後的回撥。

執行緒池沒你想的那麼簡單(續)

public interface Notify {

    /**
     * 回撥
     */
    void notifyListen() ;
}

以上就是執行緒池的建構函式以及介面的定義。

所以想要實現這個功能的關鍵是在何時回撥這個介面?

仔細想想其實也簡單:只要我們記錄提交到執行緒池中的任務及完成的數量,他們兩者的差為 0 時就認為執行緒池中的任務已執行完畢;這時便可回撥這個介面。

所以在往執行緒池中寫入任務時我們需要記錄任務數量:

執行緒池沒你想的那麼簡單(續)

為了併發安全的考慮,這裡的計數器採用了原子的 AtomicInteger


執行緒池沒你想的那麼簡單(續)

而在任務執行完畢後就將計數器 -1 ,一旦為 0 時則任務任務全部執行完畢;這時便可回撥我們自定義的介面完成通知。


JDK 的實現

這樣的需求在 jdk 中的 ThreadPoolExecutor 中也有相關的 API ,只是用法不太一樣,但本質原理都大同小異。

我們使用 ThreadPoolExecutor 的常規關閉流程如下:

    executorService.shutdown();
    while (!executorService.awaitTermination(100, TimeUnit.MILLISECONDS)) {
        logger.info("thread running");
    }

執行緒提交完畢後執行 shutdown() 關閉執行緒池,接著迴圈呼叫 awaitTermination() 方法,一旦任務全部執行完畢後則會返回 true 從而退出迴圈。

這兩個方法的目的和原理如下:

  • 執行 shutdown() 後會將執行緒池的狀態置為關閉狀態,這時將會停止接收新的任務同時會等待佇列中的任務全部執行完畢後才真正關閉執行緒池。
  • awaitTermination 會阻塞直到執行緒池所有任務執行完畢或者超時時間已到。

為什麼要兩個 api 結合一起使用呢?

主要還在最終的目的是:所有執行緒執行完畢後再做某件事情,也就是線上程執行完畢之前其實主執行緒是需要被阻塞的。

shutdown() 執行後並不會阻塞,會立即返回,所有才需要後續用迴圈不停的呼叫 awaitTermination(),因為這個 api 才會阻塞執行緒。

其實我們檢視原始碼會發現,ThreadPoolExecutor 中的阻塞依然也是等待通知機制的運用,只不過用的是 LockSupportAPI 而已。

帶有返回值的執行緒

接下來是帶有返回值的執行緒,這個需求也非常常見;比如需要執行緒非同步計算某些資料然後得到結果最終彙總使用。

先來看看如何使用(和 jdk 的類似):

首先任務是不能實現 Runnable 介面了,畢竟他的 run() 函式是沒有返回值的;所以我們改實現一個 Callable 的介面:

執行緒池沒你想的那麼簡單(續)

這個介面有一個返回值。

同時在提交任務時也稍作改動:

執行緒池沒你想的那麼簡單(續)

首先是執行任務的函式由 execute() 換為了 submit(),同時他會返回一個返回值 Future,通過它便可拿到執行緒執行的結果。

最後通過第二步將所有執行結果列印出來:

執行緒池沒你想的那麼簡單(續)

實現原理

再看具體實現之前先來思考下這樣的功能如何實現?

  • 首先受限於 jdk 的執行緒 api 的規範,要執行一個執行緒不管是實現介面還是繼承類,最終都是執行的 run() 函式。
  • 所以我們想要一個執行緒有返回值無非只能是在執行 run() 函式時去呼叫一個有返回值的方法,再將這個返回值存放起來用於後續使用。

比如我們這裡新建了一個 Callable<T> 的介面:

public interface Callable<T> {

    /**
     * 執行任務
     * @return 執行結果
     */
    T call() ;
}

它的 call 函式就是剛才提到的有返回值的方法,所以我們應當線上程的 run() 函式中去呼叫它。

接著還會有一個 Future 的介面,他的主要作用是獲取執行緒的返回值,也就是 再將這個返回值存放起來用於後續使用 這裡提到的後續使用

既然有了介面那自然就得有它的實現 FutureTask,它實現了 Future 介面用於後續獲取返回值。

同時實現了 Runnable 介面會把自己變為一個執行緒。

執行緒池沒你想的那麼簡單(續)

所以在它的 run() 函式中會呼叫剛才提到的具有返回值的 call() 函式。


再次結合 submit() 提交任務和 get() 獲取返回值的原始碼來看會更加理解這其中的門道。

    /**
     * 有返回值
     *
     * @param callable
     * @param <T>
     * @return
     */
    public <T> Future<T> submit(Callable<T> callable) {
        FutureTask<T> future = new FutureTask(callable);
        execute(future);
        return future;
    }

submit() 非常簡單,將我們丟進來的 Callable 物件轉換為一個 FutureTask 物件,然後再呼叫之前的 execute() 來丟進執行緒池(後續的流程就和一個普通的執行緒進入執行緒池的流程一樣)。

FutureTask 本身也是執行緒,所以可以直接使用 execute() 函式。


future.get() 函式中 future 物件由於在 submit() 中返回的真正物件是 FutureTask,所以我們直接看其中的原始碼就好。

執行緒池沒你想的那麼簡單(續)

由於 get() 線上程沒有返回之前是一個阻塞函式,最終也是通過 notify.wait() 使執行緒進入阻塞狀態來實現的。

而使其從 wait() 中返回的條件必然是線上程執行完畢拿到返回值的時候才進行喚醒。

也就是圖中的第二部分;一旦執行緒執行完畢(callable.call())就會喚醒 notify 物件,這樣 get 方法也就能返回了。


同樣的道理,ThreadPoolExecutor 中的原理也是類似,只不過它考慮的細節更多所以看起來很複雜,但精簡程式碼後核心也就是這些。

甚至最終使用的 api 看起來都是類似的:

執行緒池沒你想的那麼簡單(續)

異常處理

最後一個是一些新手使用執行緒池很容易踩坑的一個地方:那就是異常處理。

比如類似於這樣的場景:

執行緒池沒你想的那麼簡單(續)

建立了只有一個執行緒的執行緒池,這個執行緒只做一件事,就是一直不停的 while 迴圈。

但是迴圈的過程中不小心丟擲了一個異常,巧的是這個異常又沒有被捕獲。你覺得後續會發生什麼事情呢?

是執行緒繼續執行?還是執行緒池會退出?

執行緒池沒你想的那麼簡單(續)

通過現象來看其實哪種都不是,執行緒既沒有繼續執行同時執行緒池也沒有退出,會一直卡在這裡。

當我們 dump 執行緒快照會發現:

執行緒池沒你想的那麼簡單(續)

這時執行緒池中還有一個執行緒在執行,通過執行緒名稱會發現這是新建立的一個執行緒(之前是Thread-0,現在是 Thread-1)。

它的執行緒狀態為 WAITING ,通過堆疊發現是卡在了 CustomThreadPool.java:272 處。

執行緒池沒你想的那麼簡單(續)

就是卡在了從佇列裡獲取任務的地方,由於此時的任務佇列是空的,所以他會一直阻塞在這裡。

看到這裡,之前關注的朋友有沒有似曾相識的感覺。

沒錯,我之前寫過兩篇:

執行緒池相關的問題,當時的討論也非常“激烈”,其實最終的原因和這裡是一模一樣的。

所以就這次簡版的程式碼來看看其中的問題:

執行緒池沒你想的那麼簡單(續)

現在又簡化了一版程式碼我覺得之前還有疑問的朋友這次應該會更加明白。

其實線上程池內部會對執行緒的執行捕獲異常,但它並不會處理,只是用於標記是否執行成功;

一旦執行失敗則會回收掉當前異常的執行緒,然後重新建立一個新的 Worker 執行緒繼續從佇列裡取任務然後執行

所以最終才會卡在從佇列中取任務處。

其實 ThreadPoolExecutor 的異常處理也是類似的,具體的原始碼就不多分析了,在上面兩篇文章中已經說過幾次。

所以我們在使用執行緒池時,其中的任務一定要做好異常處理。

總結

這一波下來我覺得執行緒池搞清楚沒啥問題了,總的來看它內部運用了非常多的多執行緒解決方案,比如:

  • ReentrantLock 重入鎖來保證執行緒寫入的併發安全。
  • 利用等待通知機制來實現執行緒間通訊(執行緒執行結果、等待執行緒池執行完畢等)。

最後也學會了:

  • 標準的執行緒池關閉流程。
  • 如何使用有返回值的執行緒。
  • 執行緒異常捕獲的重要性。

最後本文所有原始碼(結合其中的測試程式碼使用):

https://github.com/crossoverJie/JCSprout/blob/master/src/main/java/com/crossoverjie/concurrent/CustomThreadPool.java

你的點贊與分享是對我最大的支援
執行緒池沒你想的那麼簡單(續)

相關文章