隨着計算機行業的飛速發展,摩爾定律逐漸失效,多核CPU成爲主流。使用多線程並行計算逐漸成爲開發人員提高服務器性能的基本武器。J.U.C提供的線程池:ThreadPoolExecutor類,幫助開發人員管理線程並方便地執行並行任務。瞭解併合理使用線程池,是一個開發人員必修的基本功。前端
本文開篇簡述線程池概念和用途,接着結合線程池的源碼,幫助讀者領略線程池的設計思路,最後迴歸實踐,經過案例講述使用線程池遇到的問題,並給出了一種動態化線程池解決方案。算法
線程池(Thread Pool)是一種基於池化思想管理線程的工具,常常出如今多線程服務器中,如MySQL。數據庫
線程過多會帶來額外的開銷,其中包括建立銷燬線程的開銷、調度線程的開銷等等,同時也下降了計算機的總體性能。線程池維護多個線程,等待監督管理者分配可併發執行的任務。這種作法,一方面避免了處理任務時建立銷燬線程開銷的代價,另外一方面避免了線程數量膨脹致使的過度調度問題,保證了對內核的充分利用。編程
而本文描述線程池是JDK中提供的ThreadPoolExecutor類。後端
固然,使用線程池能夠帶來一系列好處:緩存
線程池解決的核心問題就是資源管理問題。在併發環境下,系統不可以肯定在任意時刻中,有多少任務須要執行,有多少資源須要投入。這種不肯定性將帶來如下若干問題:安全
爲解決資源分配這個問題,線程池採用了「池化」(Pooling)思想。池化,顧名思義,是爲了最大化收益並最小化風險,而將資源統一在一塊兒管理的一種思想。服務器
Pooling is the grouping together of resources (assets, equipment, personnel, effort, etc.) for the purposes of maximizing advantage or minimizing risk to the users. The term is used in finance, computing and equipment management.——wikipedia微信
「池化」思想不只僅能應用在計算機領域,在金融、設備、人員管理、工做管理等領域也有相關的應用。網絡
在計算機領域中的表現爲:統一管理IT資源,包括服務器、存儲、和網絡資源等等。經過共享資源,使用戶在低投入中獲益。除去線程池,還有其餘比較典型的幾種使用策略包括:
在瞭解完「是什麼」和「爲何」以後,下面咱們來一塊兒深刻一下線程池的內部實現原理。
在前文中,咱們瞭解到:線程池是一種經過「池化」思想,幫助咱們管理線程而獲取併發性的工具,在Java中的體現是ThreadPoolExecutor類。那麼它的的詳細設計與實現是什麼樣的呢?咱們會在本章進行詳細介紹。
Java中的線程池核心實現類是ThreadPoolExecutor,本章基於JDK 1.8的源碼來分析Java線程池的核心設計與實現。咱們首先來看一下ThreadPoolExecutor的UML類圖,瞭解下ThreadPoolExecutor的繼承關係。
ThreadPoolExecutor實現的頂層接口是Executor,頂層接口Executor提供了一種思想:將任務提交和任務執行進行解耦。用戶無需關注如何建立線程,如何調度線程來執行任務,用戶只需提供Runnable對象,將任務的運行邏輯提交到執行器(Executor)中,由Executor框架完成線程的調配和任務的執行部分。ExecutorService接口增長了一些能力:(1)擴充執行任務的能力,補充能夠爲一個或一批異步任務生成Future的方法;(2)提供了管控線程池的方法,好比中止線程池的運行。AbstractExecutorService則是上層的抽象類,將執行任務的流程串聯了起來,保證下層的實現只需關注一個執行任務的方法便可。最下層的實現類ThreadPoolExecutor實現最複雜的運行部分,ThreadPoolExecutor將會一方面維護自身的生命週期,另外一方面同時管理線程和任務,使二者良好的結合從而執行並行任務。
ThreadPoolExecutor是如何運行,如何同時維護線程和執行任務的呢?其運行機制以下圖所示:
線程池在內部實際上構建了一個生產者消費者模型,將線程和任務二者解耦,並不直接關聯,從而良好的緩衝任務,複用線程。線程池的運行主要分紅兩部分:任務管理、線程管理。任務管理部分充當生產者的角色,當任務提交後,線程池會判斷該任務後續的流轉:(1)直接申請線程執行該任務;(2)緩衝到隊列中等待線程執行;(3)拒絕該任務。線程管理部分是消費者,它們被統一維護在線程池內,根據任務請求進行線程的分配,當線程執行完任務後則會繼續獲取新的任務去執行,最終當線程獲取不到任務的時候,線程就會被回收。
接下來,咱們會按照如下三個部分去詳細講解線程池運行機制:
線程池運行的狀態,並非用戶顯式設置的,而是伴隨着線程池的運行,由內部來維護。線程池內部使用一個變量維護兩個值:運行狀態(runState)和線程數量 (workerCount)。在具體實現中,線程池將運行狀態(runState)、線程數量 (workerCount)兩個關鍵參數的維護放在了一塊兒,以下代碼所示:
private final AtomicInteger ctl = new AtomicInteger(ctlOf(RUNNING, 0));
ctl
這個AtomicInteger類型,是對線程池的運行狀態和線程池中有效線程的數量進行控制的一個字段, 它同時包含兩部分的信息:線程池的運行狀態 (runState) 和線程池內有效線程的數量 (workerCount),高3位保存runState,低29位保存workerCount,兩個變量之間互不干擾。用一個變量去存儲兩個值,可避免在作相關決策時,出現不一致的狀況,沒必要爲了維護二者的一致,而佔用鎖資源。經過閱讀線程池源代碼也能夠發現,常常出現要同時判斷線程池運行狀態和線程數量的狀況。線程池也提供了若干方法去供用戶得到線程池當前的運行狀態、線程個數。這裏都使用的是位運算的方式,相比於基本運算,速度也會快不少。
關於內部封裝的獲取生命週期狀態、獲取線程池線程數量的計算方法如如下代碼所示:
private static int runStateOf(int c) { return c & ~CAPACITY; } //計算當前運行狀態 private static int workerCountOf(int c) { return c & CAPACITY; } //計算當前線程數量 private static int ctlOf(int rs, int wc) { return rs | wc; } //經過狀態和線程數生成ctl
ThreadPoolExecutor的運行狀態有5種,分別爲:
其生命週期轉換以下入所示:
2.3.1 任務調度
任務調度是線程池的主要入口,當用戶提交了一個任務,接下來這個任務將如何執行都是由這個階段決定的。瞭解這部分就至關於瞭解了線程池的核心運行機制。
首先,全部任務的調度都是由execute方法完成的,這部分完成的工做是:檢查如今線程池的運行狀態、運行線程數、運行策略,決定接下來執行的流程,是直接申請線程執行,或是緩衝到隊列中執行,亦或是直接拒絕該任務。其執行過程以下:
其執行流程以下圖所示:
2.3.2 任務緩衝
任務緩衝模塊是線程池可以管理任務的核心部分。線程池的本質是對任務和線程的管理,而作到這一點最關鍵的思想就是將任務和線程二者解耦,不讓二者直接關聯,才能夠作後續的分配工做。線程池中是以生產者消費者模式,經過一個阻塞隊列來實現的。阻塞隊列緩存任務,工做線程從阻塞隊列中獲取任務。
阻塞隊列(BlockingQueue)是一個支持兩個附加操做的隊列。這兩個附加的操做是:在隊列爲空時,獲取元素的線程會等待隊列變爲非空。當隊列滿時,存儲元素的線程會等待隊列可用。阻塞隊列經常使用於生產者和消費者的場景,生產者是往隊列裏添加元素的線程,消費者是從隊列裏拿元素的線程。阻塞隊列就是生產者存放元素的容器,而消費者也只從容器裏拿元素。
下圖中展現了線程1往阻塞隊列中添加元素,而線程2從阻塞隊列中移除元素:
使用不一樣的隊列能夠實現不同的任務存取策略。在這裏,咱們能夠再介紹下阻塞隊列的成員:
2.3.3 任務申請
由上文的任務分配部分可知,任務的執行有兩種可能:一種是任務直接由新建立的線程執行。另外一種是線程從任務隊列中獲取任務而後執行,執行完任務的空閒線程會再次去從隊列中申請任務再去執行。第一種狀況僅出如今線程初始建立的時候,第二種是線程獲取任務絕大多數的狀況。
線程須要從任務緩存模塊中不斷地取任務執行,幫助線程從阻塞隊列中獲取任務,實現線程管理模塊和任務管理模塊之間的通訊。這部分策略由getTask方法實現,其執行流程以下圖所示:
getTask這部分進行了屢次判斷,爲的是控制線程的數量,使其符合線程池的狀態。若是線程池如今不該該持有那麼多線程,則會返回null值。工做線程Worker會不斷接收新任務去執行,而當工做線程Worker接收不到任務的時候,就會開始被回收。
2.3.4 任務拒絕
任務拒絕模塊是線程池的保護部分,線程池有一個最大的容量,當線程池的任務緩存隊列已滿,而且線程池中的線程數目達到maximumPoolSize時,就須要拒絕掉該任務,採起任務拒絕策略,保護線程池。
拒絕策略是一個接口,其設計以下:
public interface RejectedExecutionHandler { void rejectedExecution(Runnable r, ThreadPoolExecutor executor); }
用戶能夠經過實現這個接口去定製拒絕策略,也能夠選擇JDK提供的四種已有拒絕策略,其特色以下:
2.4 Worker線程管理
2.4.1 Worker線程
線程池爲了掌握線程的狀態並維護線程的生命週期,設計了線程池內的工做線程Worker。咱們來看一下它的部分代碼:
private final class Worker extends AbstractQueuedSynchronizer implements Runnable{ final Thread thread;//Worker持有的線程 Runnable firstTask;//初始化的任務,能夠爲null }
Worker這個工做線程,實現了Runnable接口,並持有一個線程thread,一個初始化的任務firstTask。thread是在調用構造方法時經過ThreadFactory來建立的線程,能夠用來執行任務;firstTask用它來保存傳入的第一個任務,這個任務能夠有也能夠爲null。若是這個值是非空的,那麼線程就會在啓動初期當即執行這個任務,也就對應核心線程建立時的狀況;若是這個值是null,那麼就須要建立一個線程去執行任務列表(workQueue)中的任務,也就是非核心線程的建立。
Worker執行任務的模型以下圖所示:
線程池須要管理線程的生命週期,須要在線程長時間不運行的時候進行回收。線程池使用一張Hash表去持有線程的引用,這樣能夠經過添加引用、移除引用這樣的操做來控制線程的生命週期。這個時候重要的就是如何判斷線程是否在運行。
Worker是經過繼承AQS,使用AQS來實現獨佔鎖這個功能。沒有使用可重入鎖ReentrantLock,而是使用AQS,爲的就是實現不可重入的特性去反應線程如今的執行狀態。
1.lock方法一旦獲取了獨佔鎖,表示當前線程正在執行任務中。
2.若是正在執行任務,則不該該中斷線程。
3.若是該線程如今不是獨佔鎖的狀態,也就是空閒的狀態,說明它沒有在處理任務,這時能夠對該線程進行中斷。
4.線程池在執行shutdown方法或tryTerminate方法時會調用interruptIdleWorkers方法來中斷空閒的線程,interruptIdleWorkers方法會使用tryLock方法來判斷線程池中的線程是不是空閒狀態;若是線程是空閒狀態則能夠安全回收。
在線程回收過程當中就使用到了這種特性,回收過程以下圖所示:
2.4.2 Worker線程增長
增長線程是經過線程池中的addWorker方法,該方法的功能就是增長一個線程,該方法不考慮線程池是在哪一個階段增長的該線程,這個分配線程的策略是在上個步驟完成的,該步驟僅僅完成增長線程,並使它運行,最後返回是否成功這個結果。addWorker方法有兩個參數:firstTask、core。firstTask參數用於指定新增的線程執行的第一個任務,該參數能夠爲空;core參數爲true表示在新增線程時會判斷當前活動線程數是否少於corePoolSize,false表示新增線程前須要判斷當前活動線程數是否少於maximumPoolSize,其執行流程以下圖所示:
2.4.3 Worker線程回收
線程池中線程的銷燬依賴JVM自動的回收,線程池作的工做是根據當前線程池的狀態維護必定數量的線程引用,防止這部分線程被JVM回收,當線程池決定哪些線程須要回收時,只須要將其引用消除便可。Worker被建立出來後,就會不斷地進行輪詢,而後獲取任務去執行,核心線程能夠無限等待獲取任務,非核心線程要限時獲取任務。當Worker沒法獲取到任務,也就是獲取的任務爲空時,循環會結束,Worker會主動消除自身在線程池內的引用。
try { while (task != null || (task = getTask()) != null) { //執行任務 } } finally { processWorkerExit(w, completedAbruptly);//獲取不到任務時,主動回收本身 }
線程回收的工做是在processWorkerExit方法完成的。
事實上,在這個方法中,將線程引用移出線程池就已經結束了線程銷燬的部分。但因爲引發線程銷燬的可能性有不少,線程池還要判斷是什麼引起了此次銷燬,是否要改變線程池的現階段狀態,是否要根據新狀態,從新分配線程。
2.4.4 Worker線程執行任務
在Worker類中的run方法調用了runWorker方法來執行任務,runWorker方法的執行過程以下:
1.while循環不斷地經過getTask()方法獲取任務。
2.getTask()方法從阻塞隊列中取任務。
3.若是線程池正在中止,那麼要保證當前線程是中斷狀態,不然要保證當前線程不是中斷狀態。
4.執行任務。
5.若是getTask結果爲null則跳出循環,執行processWorkerExit()方法,銷燬線程。
執行流程以下圖所示:
在當今的互聯網業界,爲了最大程度利用CPU的多核性能,並行運算的能力是不可或缺的。經過線程池管理線程獲取併發性是一個很是基礎的操做,讓咱們來看兩個典型的使用線程池獲取併發性的場景。
場景1:快速響應用戶請求
描述:用戶發起的實時請求,服務追求響應時間。好比說用戶要查看一個商品的信息,那麼咱們須要將商品維度的一系列信息如商品的價格、優惠、庫存、圖片等等聚合起來,展現給用戶。
分析:從用戶體驗角度看,這個結果響應的越快越好,若是一個頁面半天都刷不出,用戶可能就放棄查看這個商品了。而面向用戶的功能聚合一般很是複雜,伴隨着調用與調用之間的級聯、多級級聯等狀況,業務開發同窗每每會選擇使用線程池這種簡單的方式,將調用封裝成任務並行的執行,縮短整體響應時間。另外,使用線程池也是有考量的,這種場景最重要的就是獲取最大的響應速度去知足用戶,因此應該不設置隊列去緩衝併發任務,調高corePoolSize和maxPoolSize去儘量創造多的線程快速執行任務。
場景2:快速處理批量任務
描述:離線的大量計算任務,須要快速執行。好比說,統計某個報表,須要計算出全國各個門店中有哪些商品有某種屬性,用於後續營銷策略的分析,那麼咱們須要查詢全國全部門店中的全部商品,而且記錄具備某屬性的商品,而後快速生成報表。
分析:這種場景須要執行大量的任務,咱們也會但願任務執行的越快越好。這種狀況下,也應該使用多線程策略,並行計算。但與響應速度優先的場景區別在於,這類場景任務量巨大,並不須要瞬時的完成,而是關注如何使用有限的資源,儘量在單位時間內處理更多的任務,也就是吞吐量優先的問題。因此應該設置隊列去緩衝併發任務,調整合適的corePoolSize去設置處理任務的線程數。在這裏,設置的線程數過多可能還會引起線程上下文切換頻繁的問題,也會下降處理任務的速度,下降吞吐量。
線程池使用面臨的核心的問題在於:線程池的參數並很差配置。一方面線程池的運行機制不是很好理解,配置合理須要強依賴開發人員的我的經驗和知識;另外一方面,線程池執行的狀況和任務類型相關性較大,IO密集型和CPU密集型的任務運行起來的狀況差別很是大,這致使業界並無一些成熟的經驗策略幫助開發人員參考。
關於線程池配置不合理引起的故障,公司內部有較多記錄,下面舉一些例子:
Case1:2018年XX頁面展現接口大量調用降級:
事故描述:XX頁面展現接口產生大量調用降級,數量級在幾十到上百。
事故緣由:該服務展現接口內部邏輯使用線程池作並行計算,因爲沒有預估好調用的流量,致使最大核心數設置偏小,大量拋出RejectedExecutionException,觸發接口降級條件,示意圖以下:
Case2:2018年XX業務服務不可用S2級故障
事故描述:XX業務提供的服務執行時間過長,做爲上游服務總體超時,大量下游服務調用失敗。
事故緣由:該服務處理請求內部邏輯使用線程池作資源隔離,因爲隊列設置過長,最大線程數設置失效,致使請求數量增長時,大量任務堆積在隊列中,任務執行時間過長,最終致使下游服務的大量調用超時失敗。示意圖以下:
業務中要使用線程池,而使用不當又會致使故障,那麼咱們怎樣才能更好地使用線程池呢?針對這個問題,咱們下面延展幾個方向:
1. 可否不用線程池?
回到最初的問題,業務使用線程池是爲了獲取併發性,對於獲取併發性,是否能夠有什麼其餘的方案呢替代?咱們嘗試進行了一些其餘方案的調研:
綜合考慮,這些新的方案都能在某種狀況下提高並行任務的性能,然而本次重點解決的問題是如何更簡易、更安全地得到的併發性。另外,Actor模型的應用實際上甚少,只在Scala中使用普遍,協程框架在Java中維護的也不成熟。這三者現階段都不是足夠的易用,也並不能解決業務上現階段的問題。
2. 追求參數設置合理性?
有沒有一種計算公式,可以讓開發同窗很簡易地計算出某種場景中的線程池應該是什麼參數呢?
帶着這樣的疑問,咱們調研了業界的一些線程池參數配置方案:
調研了以上業界方案後,咱們並無得出通用的線程池計算方式。併發任務的執行狀況和任務類型相關,IO密集型和CPU密集型的任務運行起來的狀況差別很是大,但這種佔比是較難合理預估的,這致使很難有一個簡單有效的通用公式幫咱們直接計算出結果。
3. 線程池參數動態化?
儘管通過謹慎的評估,仍然不可以保證一次計算出來合適的參數,那麼咱們是否能夠將修改線程池參數的成本降下來,這樣至少能夠發生故障的時候能夠快速調整從而縮短故障恢復的時間呢?基於這個思考,咱們是否能夠將線程池的參數從代碼中遷移到分佈式配置中心上,實現線程池參數可動態配置和即時生效,線程池參數動態化先後的參數修改流程對好比下:
基於以上三個方向對比,咱們能夠看出參數動態化方向簡單有效。
3.3.1 總體設計
動態化線程池的核心設計包括如下三個方面:
3.3.2 功能架構
動態化線程池提供以下功能:
動態調參:支持線程池參數動態調整、界面化操做;包括修改線程池核心大小、最大核心大小、隊列長度等;參數修改後及時生效。
任務監控:支持應用粒度、線程池粒度、任務粒度的Transaction監控;能夠看到線程池的任務執行狀況、最大任務執行時間、平均任務執行時間、95/99線等。
負載告警:線程池隊列任務積壓到必定值的時候會經過大象(美團內部通信工具)告知應用開發負責人;當線程池負載數達到必定閾值的時候會經過大象告知應用開發負責人。
操做監控:建立/修改和刪除線程池都會通知到應用的開發負責人。
操做日誌:能夠查看線程池參數的修改記錄,誰在何時修改了線程池參數、修改前的參數值是什麼。
權限校驗:只有應用開發負責人才可以修改應用的線程池參數。
參數動態化
JDK原生線程池ThreadPoolExecutor提供了以下幾個public的setter方法,以下圖所示:
JDK容許線程池使用方經過ThreadPoolExecutor的實例來動態設置線程池的核心策略,以setCorePoolSize爲方法例,在運行期線程池使用方調用此方法設置corePoolSize以後,線程池會直接覆蓋原來的corePoolSize值,而且基於當前值和原始值的比較結果採起不一樣的處理策略。對於當前值小於當前工做線程數的狀況,說明有多餘的worker線程,此時會向當前idle的worker線程發起中斷請求以實現回收,多餘的worker在下次idel的時候也會被回收;對於當前值大於原始值且當前隊列中有待執行任務,則線程池會建立新的worker線程來執行隊列任務,setCorePoolSize具體流程以下:
線程池內部會處理好當前狀態作到平滑修改,其餘幾個方法限於篇幅,這裏不一一介紹。重點是基於這幾個public方法,咱們只須要維護ThreadPoolExecutor的實例,而且在須要修改的時候拿到實例修改其參數便可。基於以上的思路,咱們實現了線程池參數的動態化、線程池參數在管理平臺可配置可修改,其效果圖以下圖所示:
用戶能夠在管理平臺上經過線程池的名字找到指定的線程池,而後對其參數進行修改,保存後會實時生效。目前支持的動態參數包括核心數、最大值、隊列長度等。除此以外,在界面中,咱們還能看到用戶能夠配置是否開啓告警、隊列等待任務告警閾值、活躍度告警等等。關於監控和告警,咱們下面一節會對齊進行介紹。
線程池監控
除了參數動態化以外,爲了更好地使用線程池,咱們須要對線程池的運行情況有感知,好比當前線程池的負載是怎麼樣的?分配的資源夠不夠用?任務的執行狀況是怎麼樣的?是長任務仍是短任務?基於對這些問題的思考,動態化線程池提供了多個維度的監控和告警能力,包括:線程池活躍度、任務的執行Transaction(頻率、耗時)、Reject異常、線程池內部統計信息等等,既能幫助用戶從多個維度分析線程池的使用狀況,又能在出現問題第一時間通知到用戶,從而避免故障或加速故障恢復。
線程池負載關注的核心問題是:基於當前線程池參數分配的資源夠不夠。對於這個問題,咱們能夠從事前和事中兩個角度來看。事前,線程池定義了「活躍度」這個概念,來讓用戶在發生Reject異常以前可以感知線程池負載問題,線程池活躍度計算公式爲:線程池活躍度 = activeCount/maximumPoolSize。這個公式表明當活躍線程數趨向於maximumPoolSize的時候,表明線程負載趨高。事中,也能夠從兩方面來看線程池的過載斷定條件,一個是發生了Reject異常,一個是隊列中有等待任務(支持定製閾值)。以上兩種狀況發生了都會觸發告警,告警信息會經過大象推送給服務所關聯的負責人。
在傳統的線程池應用場景中,線程池中的任務執行狀況對於用戶來講是透明的。好比在一個具體的業務場景中,業務開發申請了一個線程池同時用於執行兩種任務,一個是發消息任務、一個是發短信任務,這兩類任務實際執行的頻率和時長對於用戶來講沒有一個直觀的感覺,極可能這兩類任務不適合共享一個線程池,可是因爲用戶沒法感知,所以也無從優化。動態化線程池內部實現了任務級別的埋點,且容許爲不一樣的業務任務指定具備業務含義的名稱,線程池內部基於這個名稱作Transaction打點,基於這個功能,用戶能夠看到線程池內部任務級別的執行狀況,且區分業務,任務監控示意圖以下圖所示:
用戶基於JDK原生線程池ThreadPoolExecutor提供的幾個public的getter方法,能夠讀取到當前線程池的運行狀態以及參數,以下圖所示:
動態化線程池基於這幾個接口封裝了運行時狀態實時查看的功能,用戶基於這個功能能夠了解線程池的實時狀態,好比當前有多少個工做線程,執行了多少個任務,隊列中等待的任務數等等。效果以下圖所示:
面對業務中使用線程池遇到的實際問題,咱們曾回到支持併發性問題自己來思考有沒有取代線程池的方案,也曾嘗試着去追求線程池參數設置的合理性,但面對業界方案具體落地的複雜性、可維護性以及真實運行環境的不肯定性,咱們在前兩個方向上可謂「舉步維艱」。最終,咱們回到線程池參數動態化方向上探索,得出一個且能夠解決業務問題的方案,雖然本質上仍是沒有逃離使用線程池的範疇,可是在成本和收益之間,算是取得了一個很好的平衡。成本在於實現動態化以及監控成本不高,收益在於:在不顛覆原有線程池使用方式的基礎之上,從下降線程池參數修改的成本以及多維度監控這兩個方面下降了故障發生的機率。但願本文提供的動態化線程池思路能對你們有幫助。
美團到店綜合研發中心長期招聘前端、後端、數據倉庫、機器學習/數據挖掘算法工程師,歡迎感興趣的同窗發送簡歷到:tech@meituan.com(郵件標題註明:美團到店綜合研發中心-上海)
閱讀更多技術文章,請掃碼關注微信公衆號-美團技術團隊!