動態執行緒池在轉轉平臺的實踐

陶然陶然發表於2023-04-19

  作為一名網際網路程式設計師,經常需要面對高併發的場景,為了更好地提高系統的吞吐量和響應速度,我們通常採用併發程式設計。而執行緒池技術也是Java併發程式設計中的一個重要組成部分。本文將分享我的Java執行緒池使用經歷,以及Java執行緒池在轉轉平臺的實踐。

   1.初識執行緒池

  執行緒池是一種常見的多執行緒併發程式設計技術,它將多個執行緒組織在一起,以便能夠更有效地管理和控制它們的執行。執行緒池中的每個執行緒都可以被重複利用,避免了頻繁地建立和銷燬執行緒所帶來的開銷,同時還可以限制系統中的執行緒數量,從而避免了資源的浪費和競爭。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,設定完畢上線且能夠正常執行,就這樣我與執行緒池的相遇如此簡單的結束了。

   2.最佳化與實踐

  轉眼間來到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並過載對應的方法增加監控程式碼,來進行監控資料資料上報。

  對執行緒池的監控主要是對工作執行緒和工作佇列進行監控,因此我們整理如下監控指標:

  最終監控報警效果:

   3.總結

  動態執行緒池自在轉轉平臺應用以來,我們透過日常監控及時發現潛在問題,透過自動容災應對突發流量,透過壓測調優提升執行緒池效能,為轉轉平臺服務在多年的618、雙十一活動中保駕護航,未出現一次因執行緒池導致的線上事故。希望本文能夠幫助到遇到同樣問題的同學們。

來自 “ 轉轉技術 ”, 原文作者:武翱;原文連結:http://server.it168.com/a2023/0419/6799/000006799649.shtml,如有侵權,請聯絡管理員刪除。

相關文章