動態線程池在轉轉平臺的實踐
作為一名互聯網程序員,經常需要面對高并發的場景,為了更好地提高系統的吞吐量和響應速度,我們通常采用并發編程。而線程池技術也是Java并發編程中的一個重要組成部分。本文將分享我的Java線程池使用經歷,以及Java線程池在轉轉平臺的實踐。
一.初識線程池
線程池是一種常見的多線程并發編程技術,它將多個線程組織在一起,以便能夠更有效地管理和控制它們的執行。線程池中的每個線程都可以被重復利用,避免了頻繁地創建和銷毀線程所帶來的開銷,同時還可以限制系統中的線程數量,從而避免了資源的浪費和競爭。2019年剛參加工作時,我第一次使用線程池是在處理用戶請求,該請求需要聚合多個服務的數據,然后返回給用戶。調用的服務均比較耗時,如果串行的去調用那么系統的響應時間就會非常長。所以,我決定使用多線程來并行執行這個聚合操作,因此也引入了線程池。在Java中線程池是通過java.util.concurrent包提供的ThreadPoolExecutor類來實現的。通過創建ThreadPoolExecutor對象并設置其參數,線程池運行大致分為4個階段大致如下圖:
對于剛接觸Java線程池的同學,遇到的第一個問題就是如何合理地設置線程池參數,以最大限度地發揮線程池的性能,避免線程池滿載或資源浪費的問題。通過互聯網我們能收集到各類設置線程池參數的建議:
- corePoolSize:線程池的核心線程數應該根據應用程序的負載和硬件資源進行調整。一般來說,它應該設置為處理當前負載的最大線程數。如果線程數太少,可能會導致請求排隊,降低響應速度;如果線程數太多,可能會消耗過多的系統資源。
- maximumPoolSize:最大線程數應該設置為系統能夠支持的最大線程數,通常不宜過大。這可以避免系統因線程數過多而導致的性能下降和資源浪費。
- keepAliveTime:該參數設置空閑線程的最長存活時間。如果線程池中的線程超過了corePoolSize,且處于空閑狀態的時間超過了keepAliveTime,這些線程將被終止。這個時間需要根據應用程序的負載和硬件資源進行調整。如果keepAliveTime設置太短,可能會導致線程頻繁創建和銷毀,影響性能;如果設置太長,可能會消耗過多的系統資源。
- workQueue:工作隊列用于存儲等待執行的任務。應該根據應用程序的負載和硬件資源選擇適當的隊列類型,比如ArrayBlockingQueue或LinkedBlockingQueue。如果隊列長度太小,可能會導致請求排隊,降低響應速度;如果隊列長度太大,可能會消耗過多的系統資源。
- rejectedExecutionHandler:拒絕策略用于處理當工作隊列已滿,無法接受新任務時的情況。可以選擇一些預定義的策略,比如AbortPolicy、CallerRunsPolicy、DiscardOldestPolicy或DiscardPolicy。需要根據實際情況選擇最合適的拒絕策略,以避免任務丟失或長時間阻塞。
總之,合理地設置線程池的參數需要程序員對線程池運行原理有足夠的了解,并且有對應用程序的負載調優和硬件資源調優的經驗,顯然這是非常困難得。因此我最終選擇中庸的配置方法,根據IO密集型來設置線程數為CPUs*2,根據平均任務時長與QPS來預估隊列長度為1000,設置完畢上線且能夠正常運行,就這樣我與線程池的相遇如此簡單的結束了。
二.優化與實踐
轉眼間來到2021年,隨著業務發展App使用的人數越來越多,對服務性能的要求也越來越高。因此我們在618前對服務進行全鏈路壓測,在壓測中線程池出現以下問題:
- 線程池大小不足:線程池大小不足可能導致請求無法得到處理,進而影響系統性能。
- 線程池大小過大:線程池大小過大可能會導致系統資源消耗過度,影響系統的穩定性和性能。
- 隊列滿了:如果任務隊列滿了,新的請求將被拒絕,可能會導致請求失敗。
- 任務執行時間過長:任務執行時間過長,影響線程池中其他任務的執行,進而影響系統性能。
- 線程池互擾:服務中存在多個線程池,其中一個線程池占用資源過的,造成其它線程池性能下降。
對這些問題進行復盤可以發現在實際應用中,即使是微服務架構的同一個模塊中由于業務的復雜性也需要引入多個線程池來進行業務隔離,而不同的業務場景也需要對線程池參數進行不同的設置。比如用戶請求場景需要更大的核心線程數來進行快速響應,數據導出場景需要更大的隊列來緩解大量的導出任務,突發流量場景需要更大的最大線程數和任務隊列等等。而為了找到合適各場景的參數值,我們需要重復進行壓測、調整參數、上線的過程,消耗大量的人力物力。最終我們將遇到的問題歸納為兩方面:
- 線程池參數調整依賴代碼上線,非常耗時
- 線程池運行情況黑盒,無法準確的進行調優
為解決這些問題我們設計并實現一套可動態調整可監控的線程池,具體設計與實現如下。
2.1 整體架構
動態線程池主要包含客戶端、監控平臺、配置后臺三部分:
- 客戶端部分是線程池主體部分,動態線程池通過繼承ThreadPoolExecutor來實現,保留了Java原生線程池所有的能力,并為業務服務提供線程池創建、注冊、預熱和參數更新的能力。
- 配置后臺主要負責管理線程池配置修改及配置下發,可對線程池核心參數corePoolSize、maximumPoolSize、workQueueCapacity進行動態修改,無需業務服務上線。為了能夠在線程池出現異常時自動切換備用參數方案,我們最終采用配置后臺為實現方案。如無此需求可使用Apollo,Nacos等配置中心實現成本更小。
- 監控報警平臺主要負責線程池運行狀態的監控,可對線程池的線程池活躍度,隊列飽和度,隊列阻塞耗時進行監控和報警。使得程序員能夠對線程池的運行情況進行直觀的觀察。
2.2 動態參數實現
動態參數調整主要依賴ThreadPoolExecutor提供的如下的set方法:
綜合考慮需求和風險我們最終選擇使用set方法實現對corePoolSize,maximumPoolSize的動態調整,setCorePoolSize和setMaximumPoolSize方法能夠直接對當前線程池進行賦值,并且能夠自動調整線程數。若當前值大于修改值,通過標記中斷的方式回收多余線程。若當前值小于修改值,setMaximumPoolSize值進行賦值不操作線程,setCorePoolSize會取排隊的任務數和修改差值的最小值,來新增對應數量的核心線程數。可以看出set方法能夠平穩的進行參數的修改。這樣解決了線程數的動態調整問題,但ThreadPoolExecutor不提供對工作隊列的動態調整。重新回顧訴求我們只是想要能夠調整工作隊列的大小而不是替換線程池的工作隊列,因此我們基于LinkedBlockingQueue實現長度可調的工作隊列。最終實現效果如下圖:
2.3 線程池監控實現
同樣的線程池監控也依賴于ThreadPoolExecutor提供的如下的get方法:
通過這些get方法可以實時的獲取到線程池的運行數據,將這些數據上報監控與報警平臺便可讓程序員實時查看具體數據。具體的實現方式可以分為兩種:
- 通過重寫ThreadPoolExecutor中的beforeExecute(),afterExecute()方法,在任務執行前后上報數據,便可完成監控。
- 通過繼承ThreadPoolExecutor并重載對應的方法增加監控代碼,來進行監控數據數據上報。
對線程池的監控主要是對工作線程和工作隊列進行監控,因此我們整理如下監控指標:
指標 | 方案 | 作用 |
線程池活躍度 | activeCount /maximumPoolSize | 用于描述線程池負載情況 |
隊列飽和度 | queueSize / queueCapacity | 用戶描述工作隊列負載情況 |
任務阻塞阻塞時間 | executeStartTime-inQueueTime | 用戶描述任務排隊情況 |
最終監控報警效果:
3.總結
動態線程池自在轉轉平臺應用以來,我們通過日常監控及時發現潛在問題,通過自動容災應對突發流量,通過壓測調優提升線程池性能,為轉轉平臺服務在多年的618、雙十一活動中保駕護航,未出現一次因線程池導致的線上事故。希望本文能夠幫助到遇到同樣問題的同學們。
關于作者
武翱,轉轉-平臺技術部-后端開發。