看起來是執行緒池的BUG,但是我認為是原始碼設計不合理。

2022-07-11 15:01:45

你好呀,我是歪歪。

前幾天看到一個 JDK 執行緒池的 BUG,我去了解了一下,摸清楚了它的癥結所在之後,我覺得這個 BUG 是屬於一種執行緒池方法設計不合理的地方,而且官方在知道這個 BUG 之後表示:確實是個 BUG,但是我就不修復了吧,你就當這是一個 feature 吧。

在帶你細嗦這個 BUG 之前,我先問一個問題:

JDK 自帶的執行緒池拒絕策略有哪些?

這玩意,老八股文了,存在的時間比我從業的時間都長,得張口就來:

  • AbortPolicy:丟棄任務並丟擲 RejectedExecutionException 異常,這是預設的策略。
  • DiscardOldestPolicy:丟棄佇列最前面的任務,執行後面的任務
  • CallerRunsPolicy:由呼叫執行緒處理該任務
  • DiscardPolicy:也是丟棄任務,但是不丟擲異常,相當於靜默處理。

這次的這個 BUG 觸發條件之一,就藏著在這個 DiscardPolicy 裡面。

但是你一去看原始碼,這個玩意就是個空方法啊,這能有什麼 BUG?

它錯就錯在是一個空方法,把異常給靜默處理了。

別急,等我慢慢給你擺。

啥BUG啊?

BUG 對應的連結是這個:

https://bugs.openjdk.org/browse/JDK-8286463

標題大概就是說:噢,我的老夥計們,聽我說,我發現執行緒池的拒絕策略 DiscardPolicy 遇到 invokerAll 方法的時候,可能會導致執行緒一直阻塞哦。

然後在 BUG 的描述部分主要先注意這兩段:

這兩段透露出兩個訊息:

  • 1.這個 BUG 之前有人提出來過。
  • 2.Doug 和 Martin 這兩位也知道這個 BUG,但是他們覺得使用者可以通過編碼的方式避免永遠阻塞的問題。

所以我們還得先去這個 BUG 最先出現的地方看一下。也就是這個連結:

https://bugs.openjdk.org/browse/JDK-8160037

從標題上來看,這兩個問題非常的相似,都有 invokerAll 和 block,但是觸發的條件不一樣。

一個是 DiscardPolicy 拒絕策略,一個是 shutdownNow 方法。

所以我的策略是先帶你先把這個 shutdownNow 方法嗦明白了,這樣你就能更好的理解 DiscardPolicy 帶來的問題。

本質上,它們說的是一回事兒。

現象

在 shutdownNow 相關的這個 BUG 描述裡面,提問者給到了他的測試用例,我稍微改改,就拿來就用了。

https://bugs.openjdk.org/browse/JDK-8160037

程式碼貼在這裡,你也可以那到你本地跑一下:

public class MainTest {

    public static void main(String[] args) throws InterruptedException {
        
        List<Callable<Void>> tasks = new ArrayList<>();
        for (int i = 0; i < 10; i++) {
            int finalI = i;
            tasks.add(() -> {
                System.out.println("callable "+ finalI);
                Thread.sleep(500);
                return null;
            });
        }

        ExecutorService executor = Executors.newFixedThreadPool(2);
        Thread executorInvokerThread = new Thread(() -> {
            try {
                executor.invokeAll(tasks);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("invokeAll returned");
        });
        executorInvokerThread.start();
    }
}

然後給大家解釋一下測試程式碼是在幹啥事兒。

首先標號為 ① 的地方,是往 list 裡面塞了 10 個 callable 型別的任務。

搞這麼多工幹啥呢?

肯定是要往執行緒池裡面扔,對吧。

所以,在標號為 ② 的地方,搞了一個執行緒和核心執行緒數是 2 的執行緒池。線上程裡面呼叫了執行緒池的 invokerAll 方法:

這個方法是幹啥的?

Executes the given tasks, returning a list of Futures holding their status and results when all complete.

執行給定的任務集合,在所有任務完成後返回一個包含其狀態和結果的 Futures 列表。

也就是說,當執行緒啟動後,執行緒池會把 list 裡面的任務一個個的去執行,執行完成後返回一個 Futures 列表。

我們寫程式碼的時候拿著這個列表就能知道這一批任務是否都執行完成了。

但是,朋友們,但是啊,注意一下,你看我的案例裡面根本就不關心 invokerAll 方法的返回值。

關心的是在 invokerAll 方法執行完成後,輸出的這一句話:

invokeAll returned

好,現在你來說這個程式跑起來有什麼毛病?

你肯定看不出來對不對?

我也看不出來,因為它根本就沒有任何毛病,程式可以正常執行結束:

接著,我把程式修改為這樣,新增標號為 ③ 的這幾行程式碼:

這裡呼叫的是執行緒池的 shutdown 方法,目的是想等執行緒池把任務處理完成後,讓程式退出。

來,你又說說這個程式跑起來有什麼毛病?

你肯定又沒有看不來對不對?

我也沒有,因為它根本就沒有任何毛病,程式可以正常執行結束:

好,接下來,我又要開始變形了。

程式變成這樣:

注意我這裡用的是 shutdownNow 方法,意思就是我想立即關閉前面的那個執行緒池,然後讓整個程式退出。

那麼這個程式有什麼問題呢?

它是真的有問題,肉眼真不好看出來,但是我們可以先看一下執行結果:

結果還是很好觀察的。

沒有輸出 「invokeAll returned」,程式也沒有退出。

那麼問題就來了:你說這是不是 BUG ?

咱先不管原因是啥,從現象上看,這妥妥的是 BUG 了吧?

我都呼叫 shutdownNow 了,想的就是立馬關閉執行緒池,然後讓整個程式退出,結果任務確實是沒有執行了,但是程式也並沒有退出啊,和我們預期的不符。

所以,大膽一點,這就是一個 BUG!

再來一個關於 shutdownNow 和 shutdown 方法輸出對比圖,更直觀:

至於這兩個方法之間有什麼區別,我就不講了,你要是不知道就去網上翻翻,背一下。

反正現在 BUG 已經能穩定復現了。

接下來就是找出根因了。

根因

根因怎麼找呢?

你先想想這個問題:程式應該退出卻沒有退出,是不是說明還有執行緒正在執行,準確的說是還有非守護執行緒正在執行?

對了嘛,想到這裡就好辦了嘛。

看執行緒堆疊嘛。

怎麼看?

照相機啊,朋友們。我們的老夥計了,之前的文章裡面經常露面,就它:

你就這麼輕輕的一點,就能看到有個執行緒它不對勁:

它在 WAITING 狀態,而導致它進入這個狀態的程式碼通過堆疊資訊,一眼就能定位到,就是 invokeAll 方法的 244 行,也就是這一行程式碼:

at java.util.concurrent.AbstractExecutorService.invokeAll(AbstractExecutorService.java:244)

既然問題出在 invokeAll 這個方法裡面,那就得理解這個方法在幹啥了。

原始碼也不復雜,主要關注我框起來的這部分:

標號為 ① 的地方,是把傳入進來的任務封裝為一個 Future 物件,先放到一個 List 裡面,然後呼叫 execute 方法,也就是扔到執行緒池裡面去執行。

這個操作特別像是直接呼叫執行緒池的 submit() 方法,我給你對比一下:

標號為 ② 的地方,就是迴圈前面放 Future 的 List,如果 Future 沒有執行完成,就呼叫 Future 的 get 方法,阻塞等待結果。

從堆疊資訊上看,執行緒就阻塞在 Future 的 get 方法這裡,說明這個 Future 一直沒有被執行。

為什麼沒有被執行?

好,我們回到測試程式碼的這個地方:

10 個任務,往核心執行緒數是 2 的執行緒池裡面扔。

是不是有兩個可以被執行緒池裡面的執行緒執行,剩下的 8 個進入到佇列裡面?

好,我問你:呼叫 shutdownNow 之後,工作執行緒是不是直接就給乾沒了?剩下的 8 個是不是沒有資源去執行了?

話說回來,哪怕只有 1 個任務沒有被執行呢?invokeAll 方法裡面的 future.get() 是不是也得阻塞?

但是,朋友們,但是啊,就在 BUG 如此清晰的情況下,上面的這個案例居然被官方給推翻了。

怎麼回事呢?

帶你看一下官方大佬的回覆。

哦,對不起,不是大佬,是官方巨佬 Martin 和 Doug 的回覆:

Martin 說:老鐵,我看了你的程式碼,感覺沒毛病啊?你聽我說,shutdownNow 方法返回了一個 List 列表,裡面放的就是還沒有被執行任務。所以你還得拿著 shutdownNow 的返回搞一些事情才行。

Doug 說:Martin 說的對。額外說一句:

that's why they are returned。

they 指的就是這個 list。也就是說老爺子寫程式碼的時候是考慮到這個情況了的,所以把沒有執行的任務都返給了呼叫者。

好吧,shutdownNow 方法是有返回值的,我之前居然沒有注意到這個細節:

但是你仔細看這個返回值,是個 list 裡面裝的 Runnable,它不是 Future,我就不能呼叫 future.cancel() 方法。

所以拿到這個返回值之後,我應該怎麼取消任務呢?

這個問題問得好啊。因為提問者也有這樣的疑問:

他在看到巨佬們說要對返回值做操作之後,一臉懵逼的回覆說:哥老倌些,shutdownNow 方法返回的是一個List。至少對我來說,我不知道應該這麼去取消這些任務。是不是應該在檔案裡面描述一下哦?

Martin 老哥覺得這個返回確實有點迷惑性,他做了如下回復:

執行緒池提交任務有兩種方式。

如果你用 execute() 方法提交 Runnable 任務,那麼 shutdownNow 返回的是未被執行的 Runnable 的列表。

如果你用 submit() 方法提交 Runnable 任務,那麼會被封裝為一個 FutureTask 物件,所以呼叫 shutdownNow 方法返回的是未被執行的 FutureTask 的列表:

也就是說 shutdownNow 方法返回的 List 集合,裡面裝的既可能是 Runnable,也可能是 FutureTask,取決於你往執行緒池裡面扔任務的時候呼叫的什麼方法。

FutureTask 是 Runnable 的子類:

所以,基於 Martin 老哥的說法和他提供的程式碼,我們可以把測試用例修改為這樣:

遍歷 shutdownNow 方法返回的 List 集合,然後判斷是否 Future,如果是則強轉為 Future,接著呼叫其 cancel 方法。

這樣,程式就能正常執行結束。

這樣看來,好像也確實不是一個 BUG,可以通過編碼來避免它。

反轉

但是,朋友們,但是啊,前面都是我的鋪墊,接下來劇情開始反轉了。

我們回到這個連結中:

https://bugs.openjdk.org/browse/JDK-8286463

這個連結裡面提到了 DiscardPolicy 這個執行緒池拒絕策略。

只要我稍微的把我們的 Demo 程式改變一點點,觸發執行緒的 DiscardPolicy 拒絕策略,前面這個 bug 就真的是一個繞不過去的 bug 了。

應該怎麼改變呢?

很簡單,換個執行緒池就可以了:

把我們之前這個核心執行緒數為 2,佇列長度無限長的執行緒池替換為一個自定義執行緒池。

這個自定義執行緒池的核心執行緒數、最大執行緒數、佇列長度都是 1,採用的執行緒拒絕策略是 DiscardPolicy。

其他的地方程式碼都不動,整個程式碼就變成了這樣,我把程式碼貼出來給你看看,方便你直接執行:

public class MainTest {

    public static void main(String[] args) throws InterruptedException {

        List<Callable<Void>> tasks = new ArrayList<>();
        for (int i = 0; i < 10; i++) {
            int finalI = i;
            tasks.add(() -> {
                System.out.println("callable " + finalI);
                Thread.sleep(500);
                return null;
            });
        }
        ExecutorService executor = new ThreadPoolExecutor(
                1,
                1,
                1,
                TimeUnit.SECONDS,
                new ArrayBlockingQueue<>(1),
                new ThreadPoolExecutor.DiscardPolicy()
        );
//        ExecutorService executor = Executors.newFixedThreadPool(2);
        Thread executorInvokerThread = new Thread(() -> {
            try {
                executor.invokeAll(tasks);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("invokeAll returned");
        });
        executorInvokerThread.start();

        Thread.sleep(800);
        System.out.println("shutdown");
        List<Runnable> runnables = executor.shutdownNow();
        for (Runnable r : runnables) {
            if (r instanceof Future) ((Future<?>)r).cancel(false);
        }
        System.out.println("Shutdown complete");
    }
}

然後我們先把程式執行起來看結果:

誒,怎麼回事?

我明明處理了 shutdownNow 的返回值呢,怎麼程式又沒有輸出 「invokeAll returned」 了,又阻塞在 invokeAll 方法上了?

就算我們不知道為什麼程式沒有停下來,但是從表現上看,這玩意肯定是 bug 了吧?

接下來我帶你分析一下為什麼會出現這個現象。

首先我問你在我們的案例裡面,這個執行緒池最多能容納幾個任務?

是不是最多隻能接收 2 個任務?

最多隻能接收 2 個任務,是不是說明我有 8 個任務是處理不了的,需要執行執行緒池的拒絕策略?

但是我們的拒絕策略是什麼?

是 DiscardPolicy,它的實現是這樣的,也就是靜默處理,丟棄任務,也不丟擲異常:

好,到這裡你又接著想,shutdownNow 返回的是什麼東西,是不是執行緒池裡面還沒來得及執行的任務,也就是佇列裡面的任務?

但是佇列裡面最多也就一個任務,返回回來給你取消了也沒用。

所以,這個案例和處不處理 shutdownNow 的返回值沒有關係。

關鍵的是被拒絕的這 8 個任務,或者說關鍵是觸發了 DiscardPolicy 拒絕策略。

觸發一次和觸發多次的效果都是一樣的,在我們這個自定義執行緒池加 invokeAll 方法這個場景下,只要有任何一個任務被靜默處理了,就算玩蛋。

為什麼這樣說呢?

我們先看看預設的執行緒池拒絕策略 AbortPolicy 的實現方式:

被拒絕執行之後,它是會丟擲異常,然後執行 finally 方法,呼叫 cancel,接著在 invokeAll 方法裡面會被捕捉到,所以不會阻塞:

如果是靜默處理,你沒有任何地方讓這個被靜默處理的 Future 丟擲異常,也沒用任何地方能呼叫它的 cancel 方法,所以這裡就會一直阻塞。

所以,這就是 BUG。

那麼針對這個 BUG,官方是怎麼回覆呢?

Martin 巨佬回覆說:我覺得吧,應該在檔案上說明一下,DiscardPolicy 這個拒絕策略,在真實的場景中很少使用,不建議大家使用。要不,你把它當作一個 feature?

我覺得言外之意就是:我知道這是一個 BUG 了,但是你非得用 DiscardPolicy 這個不會在實際編碼中使用的拒絕策略來說事兒,我覺得你是故意來卡 BUG 的。

我對於這個回覆是不滿意的。

Martin 老哥是有所不知,我們面試的時候有一個八股文環節,其中的一個老八股題是這樣的:

你有沒有自定義過執行緒池拒絕策略?

如果有一些大聰明,在自定義執行緒池拒絕策略的時候,寫出了一個花裡胡哨的,但是又等效於 DiscardPolicy 的拒絕策略。

也就是又沒放進佇列,又沒丟擲異常,不管你程式碼寫的多花哨,一樣的是有這個問題。

所以,我覺得還是 invokeAll 方法的設計問題,一個不能在呼叫執行緒之外被其他執行緒存取的 Future 就不應該被設計出來。

這違背了 Future 這個物件的設計理論。

所以我才說這是 BUG,也是設計問題。

什麼,你問我應該怎麼設計?

對不起,無可奉告。