填個坑!再談線程池動態調整那點事。

你好呀,我是歪歪。

前幾天和一個大佬聊天的時候他說自己最近在做線程池的監控,剛剛把動態調整的功能開發完成。

想起我之前寫過這方面的文章,就找出來看了一下:《如何設置線程池參數?美團給出了一個讓面試官虎軀一震的回答。》

然後給我指出了一個問題,我仔細思考了一下,好像確實是留了一個坑。

爲了更好的描述這個坑,我先給大家回顧一下線程池動態調整的幾個關鍵點。

首先,爲什麼需要對線程池的參數進行動態調整呢?

因爲隨着業務的發展,有可能出現一個線程池開始夠用,但是漸漸的被塞滿的情況。

這樣就會導致後續提交過來的任務被拒絕。

沒有一勞永逸的配置方案,相關的參數應該是隨着系統的浮動而浮動的。

所以,我們可以對線程池進行多維度的監控,比如其中的一個維度就是隊列使用度的監控。

當隊列使用度超過 80% 的時候就發送預警短信,提醒相應的負責人提高警惕,可以到對應的管理後臺頁面進行線程池參數的調整,防止出現任務被拒絕的情況。

以後有人問你線程池的各個參數怎麼配置的時候,你先把分爲 IO 密集型和 CPU 密集型的這個八股文答案背完之後。

加上一個:但是,除了這些方案外,我在實際解決問題的時候用的是另外一套方案”。

然後把上面的話複述一遍。

那麼線程池可以修改的參數有哪些呢?

正常來說是可以調整核心線程數和最大線程數的。

線程池也直接提供了其對應的 set 方法:

但是其實還有一個關鍵參數也是需要調整的,那就是隊列的長度。

哦,對了,說明一下,本文默認使用的隊列是 LinkedBlockingQueue

其容量是 final 修飾的,也就是說指定之後就不能修改:

所以隊列的長度調整起來稍微要動點腦筋。

至於怎麼繞過 final 這個限制,等下就說,先先給大家上個代碼。

我一般是不會貼大段的代碼的,但是這次爲什麼貼了呢?

因爲我發現我之前的那篇文章就沒有貼,之前寫的代碼也早就不知道去哪裏了。

所以,我又苦哈哈的敲了一遍...

import cn.hutool.core.thread.NamedThreadFactory;

import java.util.concurrent.BlockingQueue;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;

public class ThreadChangeDemo {

    public static void main(String[] args) {
        dynamicModifyExecutor();
    }

    private static ThreadPoolExecutor buildThreadPoolExecutor() {
        return new ThreadPoolExecutor(2,
                5,
                60,
                TimeUnit.SECONDS,
                new ResizeableCapacityLinkedBlockingQueue<>(10),
                new NamedThreadFactory("why技術"false));
    }

    private static void dynamicModifyExecutor() {
        ThreadPoolExecutor executor = buildThreadPoolExecutor();
        for (int i = 0; i < 15; i++) {
            executor.execute(() -> {
                threadPoolStatus(executor,"創建任務");
                try {
                    TimeUnit.SECONDS.sleep(5);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            });
        }
        threadPoolStatus(executor,"改變之前");
        executor.setCorePoolSize(10);
        executor.setMaximumPoolSize(10);
        ResizeableCapacityLinkedBlockingQueue<Runnable> queue = (ResizeableCapacityLinkedBlockingQueue)executor.getQueue();
        queue.setCapacity(100);
        threadPoolStatus(executor,"改變之後");
    }

    /**
     * 打印線程池狀態
     *
     * @param executor
     * @param name
     */
    private static void threadPoolStatus(ThreadPoolExecutor executor, String name) {
        BlockingQueue<Runnable> queue = executor.getQueue();
        System.out.println(Thread.currentThread().getName() + "-" + name + "-:" +
                "核心線程數:" + executor.getCorePoolSize() +
                " 活動線程數:" + executor.getActiveCount() +
                " 最大線程數:" + executor.getMaximumPoolSize() +
                " 線程池活躍度:" +
                divide(executor.getActiveCount(), executor.getMaximumPoolSize()) +
                " 任務完成數:" + executor.getCompletedTaskCount() +
                " 隊列大小:" + (queue.size() + queue.remainingCapacity()) +
                " 當前排隊線程數:" + queue.size() +
                " 隊列剩餘大小:" + queue.remainingCapacity() +
                " 隊列使用度:" + divide(queue.size(), queue.size() + queue.remainingCapacity()));
    }

    private static String divide(int num1, int num2) {
        return String.format("%1.2f%%", Double.parseDouble(num1 + "") / Double.parseDouble(num2 + "") * 100);
    }
}

當你把這個代碼粘過去之後,你會發現你沒有 NamedThreadFactory 這個類。

沒有關係,我用的是 hutool 工具包裏面的,你要是沒有,可以自定義一個,也可以在構造函數裏面不傳,這不是重點,問題不大。

問題大的是 ResizeableCapacityLinkedBlockingQueue 這個玩意。

它是怎麼來的呢?

在之前的文章裏面提到過:

就是把 LinkedBlockingQueue 粘貼一份出來,修改個名字,然後把 Capacity 參數的 final 修飾符去掉,並提供其對應的 get/set 方法。

感覺非常的簡單,就能實現 capacity 參數的動態變更。

但是,我當時寫的時候就感覺是有坑的。

畢竟這麼簡單的話,爲什麼官方要把它給設計爲 final 呢?

坑在哪裏?

關於 LinkedBlockingQueue 的工作原理就不在這裏說了,都是屬於必背八股文的內容。

主要說一下前面提到的場景中,如果我直接把 final 修飾符去掉,並提供其對應的 get/set 方法,這樣的做法坑在哪裏。

先說一下,如果沒有特殊說明,本文中的源碼都是 JDK 8 版本。

我們看一下這個 put 方法:

主要看這個被框起來的部分。

while 條件裏面的 capacity 我們知道代表的是當前容量。

那麼 count.get 是個什麼玩意呢?

就是當前隊列裏面有多少個元素。

count.get == capacity 就是說隊列已經滿了,然後執行 notFull.await() 把當前的這個 put 操作掛起來。

來個簡單的例子驗證一下:

申請一個長度爲 5 的隊列,然後在循環裏面調用 put 方法,當隊列滿了之後,程序就阻塞住了。

通過 dump 當前線程可以知道主線程確實是阻塞在了我們前面分析的地方:

所以,你想想。如果我把隊列的 capacity 修改爲了另外的值,這地方會感知到嗎?

它感知不到啊,它在等着別人喚醒呢。

現在我們把隊列換成我修改後的隊列驗證一下。

下面驗證程序的思路就是在一個子線程中執行隊列的 put 操作,直到容量滿了,被阻塞。

然後主線程把容量修改爲 100。

上面的程序其實我想要達到的效果是當容量擴大之後,子線程不應該繼續阻塞。

但是經過前面的分析,我們知道這裏並不會去喚醒子線程。

所以,輸出結果是這樣的:

子線程還是阻塞着,所以並沒有達到預期。

所以這個時候我們應該怎麼辦呢?

當然是去主動喚醒一下啦。

也就是修改一下 setCapacity 的邏輯:

public void setCapacity(int capacity) {
    final int oldCapacity = this.capacity;
    this.capacity = capacity;
    final int size = count.get();
    if (capacity > size && size >= oldCapacity) {
        signalNotFull();
    }
}

核心邏輯就是發現如果容量擴大了,那麼就調用一下 signalNotFull 方法:

喚醒一下被 park 起來的線程。

如果看到這裏你覺得你有點懵,不知道 LinkedBlockingQueue 的這幾個玩意是幹啥的:

趕緊去花一小時時間補充一下 LinkedBlockingQueue 相關的知識點。這樣玩意,面試也經常考的。

好了,我們說回來。

修改完我們自定義的 setCapacity 方法後,再次執行程序,就出現了我們預期的輸出:

除了改 setCapacity 方法之外,我在寫文章的時候不經意間還觸發了另外一個答案:

在調用完 setCapacity 方法之後,再次調用 put 方法,也能得到預期的輸出:

我們觀察 put 方法就能發現其實道理是一樣的:

當調用完 setCapacity 方法之後,再次調用 put 方法,由於不滿足標號爲 ① 的代碼的條件,所以就不會被阻塞。

於是可以順利走到標號爲 ② 的地方喚醒被阻塞的線程。

所以也就變相的達到了改變隊列長度,喚醒被阻塞的任務目的。

而究根結底,就是需要執行一次喚醒的操作。

那麼那一種優雅一點呢?

那肯定是第一種把邏輯封裝在 setCapacity 方法裏面操作起來更加優雅。

第二種方式,大多適用於那種“你也不知道爲什麼,反正這樣寫程序就是正常了”的情況。

現在我們知道在線程池裏面動態調整隊列長度的坑是什麼了。

那就是隊列滿了之後,調用 put 方法的線程就會被阻塞住,即使此時另外的線程調用了 setCapacity 方法,改變了隊列長度,如果沒有線程再次觸發 put 操作,被阻塞的線程也不會被喚醒。

是不是?

了不瞭解?

對不對?

這是不對的,朋友們。

看到前面內容,頻頻點頭的朋友,要注意了。

這地方要開始轉彎了。

開始轉彎

線程池裏面往隊列裏面添加對象的時候,用的是 offer 命令,並沒有用 put 命令:

我們看看 offer 命令在幹啥事兒:

隊列滿了之後,直接返回 false,不會出現阻塞的情況。

也就是說,線程池中根本就不會出現我前面說的需要喚醒的情況,因爲根本就沒有阻塞中的線程。

在和大佬交流的過程中,他提到了一個 VariableLinkedBlockingQueue 的東西。

這個類位於 MQ 包裏面,我前面提到的 setCapacity 方法的修改方式就是在它這裏學來的:

同時,項目裏面也用到了它的 put 方法:

所以,它是有可能出現我們前面分析的情況,有需要被喚醒的線程。

但是,你想想,線程池裏面並沒有使用 put 方法,是不是就剛好避免這樣的情況?

是的,確實是。

但是,不夠嚴謹,如果知道有問題了的話,爲什麼要留個坑在這裏呢?

你學 MQ 的 VariableLinkedBlockingQueue 考慮的周全一點,就算 put 方法阻塞的時候也能用,它不香嗎?

寫到這裏其實好像除了讓你熟悉一下 LinkedBlockingQueue 外,似乎是一個沒啥卵用的知識點,

但是,我能讓這個沒有卵用的知識點起到大作用。

因爲這其實是一個小細節。

假設我出去面試,在面試的時候提到動態調整方法的時候,在不經意間拿捏一下這個小細節,即使我沒有真的落地過動態調整,但是我提到這樣的一個小細節,就顯得很真實。

面試官一聽:很不錯,有整體,有局部,應該是假不了。

在 VariableLinkedBlockingQueue 裏面還有幾處細節,拿 put 方法來說:

判斷條件從 count.get() >= capacity 變成了 count.get() = capacity,目的是爲了支持 capacity 由大變小的場景。

這樣的地方還有好幾處,就不一一列舉了。

魔鬼,都在細節裏面。

同學們得好好的拿捏一下。

JDK bug

其實原計劃寫到前面,就打算收尾了,因爲我本來就只是想補充一下我之前沒有注意到的細節。

但是,我手賤,跑到 JDK bug 列表裏面去搜索了一下 LinkedBlockingQueue,想看看還有沒有什麼其他的收穫。

我是萬萬沒想到,確實是有一點意外收穫的。

首先是這一個 bug ,它是在 2019-12-29 被提出來的:

https://bugs.openjdk.java.net/browse/JDK-8236580

看標題的意思也是想要給 LinkedBlockingQueue 賦能,可以讓它的容量進行修改。

加上他下面的場景描述,應該也想要和線程池配合,找到隊列的抓手,下鑽到底層邏輯,聯動監控系統,拉通配置頁面,打出一套動態適應的組合拳。

但是官方並沒有採納這個建議。

回覆裏面說寫 concurrent 包的這些哥們對於在併發類裏面加東西是非常謹慎的。他們覺得給 ThreadPoolExecutor 提供可動態修改的特性會帶來或者已經帶來衆多的 bug 了。

我理解就是簡單一句話:建議還是不錯的,但是我不敢動。併發這塊,牽一髮動全身,不知道會出些什麼幺蛾子。

所以要實現這個功能,還是得自己想辦法。

這裏也就解釋了爲什麼用 final 去修飾了隊列的容量,畢竟把功能縮減一下,出現 bug 的機率也少了很多。

第二個 bug 就有意思了,和我們動態調整線程池的需求非常匹配:

https://bugs.openjdk.java.net/browse/JDK-8241094

這是一個 2020 年 3 月份提出的 bug,描述的是說在更新線程池的核心線程數的時候,會拋出一個拒絕異常。

在 bug 描述的那部分他貼了很多代碼,但是他給的代碼寫的很複雜,不太好理解。

好在 Martin 大佬寫了一個簡化版,一目瞭然,就好理解的多:

這段代碼是幹了個啥事兒呢,簡單給大家彙報一下。

首先 main 方法裏面有個循環,循環裏面是調用了 test 方法,當 test 方法拋出異常的時候循環結束。

然後 test 方法裏面是每次都搞一個新的線程池,接着往線程池裏面提交隊列長度加最大線程數個任務,最後關閉這個線程池。

同時還有另外一個線程把線程池的核心線程數從 1 修改爲 5。

你可以打開前面提到的 bug 鏈接,把這段代碼貼出來跑一下,非常的匪夷所思。

Martin 大佬他也認爲這是一個 BUG.

說實在的,我跑了一下案例,我覺得這應該算是一個 bug,但是經過 Doug Lea 老爺子的親自認證,他並不覺得這是一個 Bug。

主要是這個 bug 確實也有點超出我的認知,而且在鏈接中並沒有明確的說具體原因是什麼,導致我定位的時間非常的長,甚至一度想要放棄。

但是最終定位到問題之後也是長嘆一口:害,就這?沒啥意思。

先看一下問題的表現是怎麼樣的:

上面的程序運行起來後,會拋出 RejectedExecutionException,也就是線程池拒絕執行該任務。

但是我們前面分析了,for 循環的次數是線程池剛好能容納的任務數:

按理來說不應該有問題啊?

這也就是提問的哥們納悶的地方:

他說:我很費解啊,我提交的任務數量根本就不會超過 queueCapacity+maxThreads,爲什麼線程池還拋出了一個 RejectedExecutionException?而且這個問題非常的難以調試,因爲在任務中添加任何形式的延遲,這個問題都不會復現。

他的言外之意就是:這個問題非常的莫名其妙,但是我可以穩定復現,只是每次復現出現問題的時機都非常的隨機,我搞不定了,我覺得是一個 bug,你們幫忙看看吧。

我先不說我定位到的 Bug 的主要原因是啥吧。

先看看老爺子是怎麼說的:

老爺子的觀點簡單來說就是四個字:

老爺子說他沒有說服自己上面的這段程序應該被正常運行成功。

意思就是他覺得拋出異常也是正常的事情。但是他沒有說爲什麼。

一天之後,他又補了一句話:

我先給大家翻譯一下:

他說當線程池的 submit 方法和 setCorePoolSize 或者 prestartAllCoreThreads 同時存在,且在不同的線程中運行的時候,它們之間會有競爭的關係。

在新線程處於預啓動但還沒完全就緒接受隊列中的任務的時候,會有一個短暫的窗口。在這個窗口中隊列還是處於滿的狀態。

解決方案其實也很簡單,比如可以在 setCorePoolSize 方法中把預啓動線程的邏輯拿掉,但是如果是用 prestartAllCoreThreads 方法,那麼還是會出現前面的問題。

但是,不管是什麼情況吧,我還是不確定這是一個需要被修復的問題。

怎麼樣,老爺子的話看起來是不是很懵?

是的,這段話我最開始的時候讀了 10 遍,都是懵的,但是當我理解到這個問題出現的原因之後,我還是不得不感嘆一句:

還是老爺子總結到位,沒有一句廢話。

到底啥原因?

首先我們看一下示例代碼裏面操作線程池的這兩個地方:

修改核心線程數的是一個線程,即 CompletableFuture 的默認線程池 ForkJoinPool 中的一個線程。

往線程池裏面提交任務是另外一個線程,即主線程。

老爺子的第一句話,說的就是這回事:

racing,就是開車,就是開快車,就是與...比賽的意思。

這是一個多線程的場景,主線程和 ForkJoinPool 中的線程正在 race,即可能出現誰先誰後的問題。

接着我們看看 setCorePoolSize 方法幹了啥事:

標號爲 ① 的地方是計算新設置的核心線程數與原核心線程數之間的差值。

得出的差值,在標號爲 ② 的地方進行使用。

也就是取差值和當前隊列中正在排隊的任務數中小的那一個。

比如當前的核心線程數配置就是 2,這個時候我要把它修改爲 5。隊列裏面有 10 個任務在排隊。

那麼差值就是 5-2=3,即標號爲 ① 處的 delta=3。

workQueue.size 就是正在排隊的那 10 個任務。

也就是 Math.min(3,10),所以標號爲 ② 處的 k=3。

含義爲需要新增 3 個核心線程數,去幫忙把排隊的任務給處理一下。

但是,你想新增 3 個就一定是對的嗎?

會不會在新增的過程中,隊列中的任務已經被處理完了,有可能根本就不需要 3 個這麼多了?

所以,循環終止的條件除了老老實實的循環 k 次外,還有什麼?

就是隊列爲空的時候:

同時,你去看代碼上面的那一大段註釋,你就知道,其實它描述的和我是一回事。

好,我們接着看 addWorker 裏面,我想要讓你看到地方:

在這個方法裏面經過一系列判斷後,會走入到 new Worker() 的邏輯,即工作線程。

然後把這個線程加入到 workers 裏面。

workers 就是一個存放工作線程的 HashSet 集合:

你看我框起來的這兩局代碼,從 workers.add(w)t.start()

從加入到集合到真正的啓動,中間還有一些邏輯。

執行中間的邏輯的這一小段時間,就是老爺子說的 “window”。

there's a window while new threads are in the process of being prestarted but not yet taking tasks。

就是在新線程處於預啓動,但尚未接受任務時,會有一個窗口。

這個窗口會發生啥事兒呢?

就是下面這句話:

the queue may remain (transiently) full。

隊列有可能還是滿的,但是隻是暫時的。

接下來我們連起來看:

所以怎麼理解上面被劃線的這句話呢?

帶入一個實際的場景,也就是前面的示例代碼,只是調整一下參數:

這個線程池核心線程數是 1,最大線程數是 2,隊列長度是 5,最多能容納的任務數是 7。

另外有一個線程在執行把核心線程池從 1 修改爲 2 的操作。

假設我們記線程池 submit 提交了 6 個任務,正在提交第 7 個任務的時間點爲 T1。

爲什麼是要強調這個時間點呢?

因爲當提交第 7 個任務的時候,就需要去啓用非核心線程數了。

具體的源碼在這裏:

java.util.concurrent.ThreadPoolExecutor#execute

也就是說此時隊列滿了, workQueue.offer(command) 返回的是 fasle。因此要走到 addWorker(command, false) 方法中去了。

代碼走到 1378 行這個時間點,是 T1。

如果 1378 行的 addWorker 方法返回 false,說明添加工作線程失敗,拋出拒絕異常。

前面示例程序拋出拒絕異常就是因爲這裏返回了 fasle。

那麼問題就變成了:爲什麼 1378 行中的 addWorker 執行後返回了 false 呢?

因爲當前不滿足這個條件了 wc >= (core ? corePoolSize : maximumPoolSize)

wc 就是當前線程池,正在工作的線程數。

把我們前面的條件帶進去,就是這樣的 wc >=(false?2:2)

即 wc=2。

爲什麼會等於 2,不應該是 1 嗎?

多的哪一個是哪裏來的呢?

真相只有一個:恰好此時 setCorePoolSize 方法中的 addWorker 也執行到了 workers.add(w),導致 wc 從 1 變成了 2。

撞車了,所以拋出拒絕異常。

那麼爲什麼大多數情況下不會拋出異常呢?

因爲從 workers.add(w)t.start()這個時間窗口,非常的短暫。

大多數情況下,setCorePoolSize 方法中的 addWorker 執行了後,就會理解從隊列裏面拿一個任務出來執行。

而這個情況下,另外的任務通過線程池提交進來後,發現隊列還有位子,就放到隊列裏面去了,根本不會去執行 addWorker 方法。

道理,就是這樣一個道理。

這個多線程問題確實是比較難復現,我是怎麼定位到的呢?

加日誌。

源碼裏面怎麼加日誌呢?

我不僅搞了一個自定義隊列,還把線程池的源碼粘出來了一份,這樣就可以加日誌了:

另外,其實我這個定位方案也是很不嚴謹的。

調試多線程的時候,最好是不要使用 System.out.println,有坑!

場景

我們再回頭看看老爺子給出的方案:

其實它給了兩個。

第一個是拿掉 setCorePoolSize 方法中的 addworker 的邏輯。

第二個是說原程序中,即提問者給的程序中,使用的是 prestartAllCoreThreads 方法,這個裏面必須要調用 addWorker 方法,所以還是有一定的機率出現前面的問題。

但是,老爺子不明白爲什麼會這樣寫?

我想也許他是沒有想到什麼合適的場景?

其實前面提到的這個 Bug,其實在動態調整的這個場景下,還是有可能會出現的。

雖然,出現的概率非常低,條件也非常苛刻。

但是,還是有機率出現的。

萬一出現了,當同事都在摳腦殼的時候,你就說:這個嘛,我見過,是個 Bug。不一定每次都出現的。

這又是一個你可以拿捏的小細節。

但是,如果你在面試的時候遇到這個問題了,這屬於一個傻逼問題。

毫無意義。

屬於,面試官不知道在哪看到了一個感覺很厲害的觀點,一定要展現出自己很厲害的樣子。

但是他不知道的是,這個題:

最後說一句

好了,看到了這裏了,安排一個點贊吧。寫文章很累的,需要一點正反饋。

給各位讀者朋友們磕一個了:

本文已收錄自個人博客,歡迎大家來玩:

https://www.whywhy.vip/

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