今天,說一說線程池 “動態更新”
本文轉載自微信公眾號「龍臺的技術筆記」,作者龍臺 。轉載本文請聯系龍臺的技術筆記公眾號。
線程池(Thread Pool)是一種基于 池化思想管理線程的工具。使用線程池可以 減少創建銷毀線程的開銷,避免線程過多導致系統資源耗盡
目前線程池被廣泛應用于業務系統,但是業界內對線程池 初始化參數并沒有很好的標準。線上環境的線程池因為業務特殊性遇到一些痛點,進而引發了小編對于線程池使用的一些思考
線上配置不能合理評估
最大的痛點就是無法正確評估線程池關鍵參數的配置。比如核心線程數、最大線程數、阻塞隊列大小等,一旦上線參數就無法更改
設想一下,當你興致勃勃的對業務使用了線程池之后,有沒有考慮過這幾種場景
- 核心線程過小,阻塞隊列過小,最大線程過小,導致接口頻繁拋出拒絕策略異常
- 核心線程過小,阻塞隊列過小,最大線程過大,導致線程調度開銷增大,處理速度下降。如果遇到周期性突發流量,更是如此
- 核心線程過小,阻塞隊列過大,導致任務堆積,接口響應或者程序執行時間拉長
- 核心線程過大,導致線程池內空閑線程過多,過多的占用系統資源,造成資源浪費
上面的某些場景,受其它參數的影響,并不是絕對成立
曾經這么考慮過,提前計算好線程池的各項參數不就 OK 了么,要什么動態?
這里說一下,大多數的業務場景下,線程池參數最好的情況是大差不差。什么意思呢,就是當業務運行中時,線程池有少量的資源浪費或者觸發少量的拒絕任務
但是,有些業務的波動并不是可以預測的。比如說有一家開飯店的老板,周一到周四客人并不多,所以平常也沒備那么多的菜,湊巧來了一個旅游團來吃飯,飯店存量也就捉襟見肘了,而這種突發情況并不可預估
如果業務系統遇到上述情況,可能需要根據突來的流量重新預估線程池的參數,將系統重新進行發布并查看當前線程池的參數是否合理,如果不合理極有可能還要再來一遍流程
而動態線程池要做的就是將 參數的修改與系統的發布進行隔離,流程圖如下
沒有合理的監控
上面提到出現的參數不合理場景如何發現呢,那就是 線程池運行時監控
如果可以知道一部分線程池運行時指標,可以極大程度上的預防上述問題,這里舉一些例子
- 監控業務線程池的 當前負載以及峰值負載
- 監控線程池在不同時間段 核心線程、最大線程、活躍線程數量指標
- 監控線程 池阻塞隊列相關指標,判斷是否有任務積壓的風險
- 監控線程任務在 運行時拋出的異常數量,診斷投遞的任務是否“健康”
- 監控線程池執行 拒絕策略執行的次數,確定線程池參數是否合理
如果監控搭配上合理的報警信息,可以極大程度上避免開發對于線上業務的后知后覺,有效預防一些問題以及提高業務 BUG 的修復速度
上述關于線程池動態參數、監控以及預警等思考,源自于美團技術博客 《Java 線程池實現原理及其在美團業務中的實踐》[1]
如何動態更新參數
動態設置線程池參數涉及到兩個問題,都有哪些參數可以動態更新?使用什么方式動態更新?
這里先列舉下原聲線程池 API 支持修改的參數集合,然后梳理看看支持修改后有什么好處
CorePoolSize(核心線程數量)
線程池中空閑時存在最小的線程數量。可以通過 #setCorePoolSize 修改線程池核心線程數量,流程圖如下
相對于其它幾個動態參數,核心線程數的動態設置流程還算復雜一些
- 判斷設置的 new corePoolSize 必須大于 0,否則拋出異常
- 直接替換線程池的 corePoolSize 為 new corePoolSize
- 判斷線程池的工作線程是否大于 new corePoolSize,條件如果成立則執行中斷多余空閑的線程
- 如果上述條件不成立,判斷 corePoolSize 是否小于 new corePoolSize,如果小于說明需要創建新的核心線程
關于第四步,有一個小知識點,線程池作者為了保證線程資源不浪費而做出的優化
執行第四步時通過注釋得知,并不知道需要創建多少線程,而為了保證線程資源不會被浪費,這里會依據 workQueue#size 和 delata 來計算出需要創建的線程數量 k
Math#min 會返回兩個值中小的那個,小編想到了三種情況,我們這里來假設下
- 假設 workQueue#size == 0,那么 k 也等于零,證明并沒有阻塞住的任務需要執行,k-- > 0 表達式并不成立,就不會執行 #addWorker
- 假設 workQueue#size > 0 && < delta,此時任務隊列里有待執行任務。k-- > 0 表達式成立,一般情況會創建 workQueue#size 個新核心線程,二般情況下是線程池里其它線程把 workQueue 的任務清了,就會跳出創建流程
- 假設 workQueue#size > 0 && > delta,這種情況最多會創建 delata 個新核心線程
這里也給我們一個啟發,寫代碼不能只顧著自掃門前雪,而是要從全局的角度去思考代碼有沒有可以提升的空間
小編問了下自己,核心線程在沒任務時是不會被回收的,如果核心線程數設置的太大,過去了峰值期豈不是屬于資源浪費,難道還要自己再把數量調整回來么
我們可以在創建線程池時通過設置一個參數控制。allowsCoreThreadTimeOut 默認為 False,即核心線程即使在空閑時也保持活動狀態。如果為 True,核心線程使用 keepAliveTime 來超時等待工作
核心線程動態的坑
有一個很重要的點需要注意,核心線程數設置時可能失效。比如說,最大線程數為 5,當前線程池內活躍線程數為 5,此時設置核心線程數為 10 的話,一定是不生效的,Why?
先假設線程池的運行時狀態如下,核心線程為 3,最大線程是 5,線程池內活躍線程為 5,此時調用 #setCorePoolSize 動態設置核心線程數為 10
- 執行完上述操作之后,調用 #execute 向線程池發起任務執行,內部處理邏輯如下
- 判斷當前線程池核心數為 10,當前工作線程為 5,那么會 發起 #addWorker 添加線程
- #addWorker 會對 工作線程數量 + 1,此時真正意義上并不算此 Worker 添加到線程池
- 接下來會創建線程的包裝類 Worker 并執行 Start,因為 Worker 本身持有線程對象,Start 也是操作線程去執行任務
獲取任務 #getTask 有一步操作是動態修改核心線程數不生效的原因,那就是在真正獲取隊列中任務執行時會先 判斷當前的工作線程數量是否大于最大線程
因為上面對工作線程有 +1 的操作,所以池內工作線程數是 6,條件判斷表達式成立,接下來會對 工作線程數量執行 -1 操作,并銷毀此 Worker
這里貼一下線程池獲取隊列任務 #getTask 的代碼片段,大家粗略看一下
既然已經知道問題出在哪里,應該如何去解決動態設置失效呢
其實辦法很簡單,那就是在設置核心線程的時候,同時設置最大線程數就可以。只要工作線程不大于最大線程數,那么動態設置就是有效的
本小節參考自 如何設置線程池參數?美團給出了一個讓面試官虎軀一震的回答[2]
MaximumPoolSize(最大線程數)
表示線程池中可以創建的最大線程數。通過 #setMaximumPoolSize 重新設置最大線程數,修改邏輯如下
線程池中設置最大線程數的源碼比較簡單,并不包含復雜的邏輯,流程如下
- 判斷 new maximumPoolSize 參數是否正確,不滿足條件則拋出異常終止流程
- 設置 new maximumPoolSize 替換線程池最大線程數
- 如果線程池工作線程大于 new maximumPoolSize,則對多余 Worker 發起中斷流程
ThreadFactory(線程工廠)
線程工廠的功能是為線程池創建線程,線程創建時可以設置自定義線程 名稱前綴(重要)、設置是否 daemon 線程、線程 priority 優先級以及線程未捕獲異常的處理方式
雖然線程工廠可以在運行后重新設置參數,但是并不建議這么做。因為已經運行的線程不會因為被銷毀,如果之前運行的線程不被銷毀,一個線程池中極有可能出現兩種不同語義的線程
示例代碼中創建了一個線程池,并指定了線程工廠前綴名稱 before。對線程池運行任務使其內部擁有 before 工廠創建線程
之后新創建一個 after 線程工廠,進行替換線程池內部工廠,并運行任務創建最大線程數,我們可以查看下日志
不出所料兩個線程工廠創建的線程各自為戰,并且如果沒有特殊操作,這種情況會一直持續下去 。所以綜上所述,并不建議業務中對線程工廠修改,不然坑的都是自己人~
其它參數
剩余兩個動態調整的參數較為簡單,就不一一舉例說明了,大家看下源碼即可
- KeepAliveTime
- RejectedExecutionHandler
還有一個很重要的參數需要動態更新,那就是 阻塞隊列的大小。可能有的小伙伴就會問了,為什么不直接替換阻塞隊列呢?
其實可以實現直接替換阻塞隊列,但是如果直接替換會引發出很多的問題,舉個最直接的例子,原隊列中的堆積任務不好處理,修改容量就能解決問題的事情,沒必要復雜化。所以在做動態時,考慮的只是阻塞隊列的大小而不是替換
這里以 LinkedBlockingQueue 為例,隊列在源碼中并沒有提供修改隊列大小的方法,因為代表隊列大小的變量 capacity 被 final 關鍵字修飾
大家可以考慮下,基于這種 final 修飾的情況,應該如何去擴展阻塞隊列的容量修改
動態的阻塞隊列
線程池中是以 生產者消費者模式,通過一個阻塞隊列來緩存任務,工作線程從阻塞隊列中獲取任務。工作隊列的接口是阻塞隊列(BlockingQueue),在隊列為空時,獲取元素的線程會 等待隊列變為非空,當隊列滿時,存儲元素的線程會 等待隊列可用
阻塞隊列動態設置隊列大小,有很多種操作方式。可以按照原邏輯不變加一些擴展,也可以在特定方法上進行重寫,實現方式并不固定。下面說幾種可以實現動態阻塞隊列功能的方案
- 復制阻塞隊列源代碼實現,添加 #set 方法使 capacity 可變
- 繼承阻塞隊列,并在原基礎上重寫核心方法
- 繼承阻塞隊列,反射動態修改 capacity
如果不需要重寫原阻塞隊列獲得額外的功能,小編更傾向于第一種,代碼上會更簡潔一些,并且穩定
復制阻塞隊列
這一種方式簡單粗暴,直接把 LinkedBlockingQueue 代碼復制出來一份,改個新名字 ResizableCapacityLinkedBlockIngQueue,然后把 capacity 所修飾的 final 關鍵字去掉,再加上一個 #setCapacity 方法
重寫核心方法
網上大多數博主使用的都是上述復制阻塞隊列的方式,后來和兩位大佬討論阻塞隊列的動態,然后從 GitHub 上發現了一位國外程序員的版本,通過信號量的方式控制阻塞隊列的大小,《GitHub LinkedBQ 信號量實現》[3]
隊列中包含阻塞 隊列的大小以及自實現的信號量。每次進行調整阻塞隊列大小的同時也對信號量進行增減
反射修改 Capacity
通過反射的方式同樣可以達到阻塞隊列動態修改的功能。在修改之前,有考慮過這種方式 會不會存在線程不安全的問題,對此使用 Jmeter 線程組和修改 capacity 交替操作,進行了幾輪測試,測試的結論是 不存在線程安全問題
對于使用反射修改阻塞隊列大小,小編是不推薦的。首先這種硬編碼的方式并不優雅,其次并不能百分百保證能兼容后續 JDK 版本
綜合考慮,雖然反射修改 capacity 可以達到理想中的效果,但是不建議這么做
總結一下
在文章中,小編總結了業界內使用線程池普遍存在的情況
- 線程池的 參數無法執行快速動態調整
- 沒有合理的監控進而導致 失去主動權 以及 有效預防潛在問題
基于第一種動態參數調整的問題寫下了動態線程池系列的第一篇文章。是的,后面會有更多的動態線程池文章,包括不限于以下幾個 IDEA
- 線程池實時監控如何實現,歷史指標數據 如何匯總 Admin 展示
- 對接不同平臺的報警消息,達到 可配置、優雅的一發多收效果
- 動態線程池可不可以 對標配置中心,對接 Server 端統一管理觸發參數修改
上面這些功能,其實在美團的動態線程池里都有實現,奈何并沒有開源。而自己項目中確實存在這方面的痛點,所以只有 重復造個輪子
最初,花了大概三天時間寫出了一個依賴中間件的版本,并可以 完成對接平臺化的動態線程池。可能是因為太簡單了,覺得是不是缺了點什么。后來在一個睡不著的晚上腦洞大開,如果不依賴中間件是不是也行?
繼而就有了動態線程池 DTP(Dynamic-ThreadPool)項目,并且給項目 groupId 起名 io 開頭。目前而言的話,DTP 僅作為小編鍛煉開發能力以及產品意識的項目,每天的代碼開發時間集中在下班和周六天
DTP 項目分為兩個主體,Server 和 SpringBoot Starter,Server 端作為所有客戶端線程池的注冊中心以及歷史指標數據存儲,供 Admin 調取展示,Starter 會作為 Jar 被客戶端所依賴與 Server 端交互
目前 DTP 已實現 Server 端和 Client 端的 動態參數更新交互,源碼實現參考借鑒了 Nacos 2.0 之前的 長輪詢和事件監聽機制
既然選擇了不依賴中間件,那么問題也就顯而易見了,線上環境的單點問題。因為一旦部署集群,數據在各節點之間無法流轉廣播,這一塊后面可能會 參考 Eureka 做分布式的 AP 模型
最后的最后,看了項目感覺還不錯,辛苦小伙伴點個 ?? Star,祝好。
代碼還在持續更新,源碼地址:https://github.com/longtai94/dynamic-threadpool[4]
參考資料
[1]《Java線程池實現原理及其在美團業務中的實踐》: https://tech.meituan.com/2020/04/02/java-pooling-pratice-in-meituan.html
[2]如何設置線程池參數?美團給出了一個讓面試官虎軀一震的回答: https://cloud.tencent.com/developer/article/1615007
[3]《GitHub LinkedBQ 信號量實現》: https://sourl.cn/7Uvw88
[4]點擊閱讀原文跳轉 GitHub: https://github.com/longtai94/dynamic-threadpool