併發編程實戰學習筆記(六)——線程池的使用

任務與執行策略之間的隱性耦合

依賴性任務

當在線程池中執行獨立的任務時,可以隨意地改變線程池的大小和配置,這些修改只會對執行性能產生影響。如果提交給線程池的任務需要依賴其它的任務,那麼就隱含地給執行策略帶來了約束,此時必須小心地維持這些執行策略以避免產生活躍性問題“線程飢餓死鎖”。

使用線程封閉機制的任務

如果將Executor從單線程環境改爲線程池環境,那麼將會失去線程安全性。

對響應時間敏感的任務

如果將一個運行時間較長的任務提交到單線程的Executor中,或者將多個運行時間較長的任務提交到一個只包含少量線程的線程池中,那麼將降低由該Executor管理的服務的響應性。

使用ThreadLocal的任務

ThreadLocal使每個線程都可以擁有某個變量的一個私有“版本”。然而,只要條件允許,Executor可以自由地重用這些線程。只有當線程本地值的生命受限於任務的生命週期時,在線程池的線程中使用ThreadLocal纔有意義,而在線程池中不應該使用ThreadLocal在任務之間傳遞值。

結論

在一些任務中,需要擁有或排除某種特定的執行策略。如果某些任務依賴於其它的任務,那麼會要求線程池足夠大,從而確保它們依賴任務不會被放入等待隊列中或被拒絕,而採用線程封閉機制的任務需要串行執行。通過將這些需求寫入文檔,將來的代碼維護人員就不會由於使用了某種不合適的執行策略而破壞安全性或活躍性。

線程飢餓死鎖

定義

在線程池中,如果任務依賴於其它任務,那麼可能產生死鎖。在單線程的Executor中,如果一個任務將另一個任務提交到同一個Executor,並且等待這個被提交任務的結題,那麼通常會引發死鎖。如果所有正在執行任務的線程都由於等待其它仍處於工作隊列中的任務而阻塞,那會發生同樣的問題,這種現象被稱爲線程飢餓死鎖。

可能觸發的兩種情況

  • 提交有依賴性的executor任務,都需要注意有可能會產生飢餓死鎖
  • 除了線程池大小的顯式限制外,其它資源上的約束而存在一些隱匿限制,有可能間接觸發產生類似的“飢餓”。如每個線程都需要一個JDBC連接池,那線程池就好像只有10個線程。因爲超過10個任務時,新的任務需要等待其它任務釋放連接。

運行時間較長的任務使用線程池注意事項

有限線程池線程可能會被執行時間長任務佔用過長時間,最終導致執行時間短的任務也被拉長了“執行”時間。可以考慮限定任務等待資源的時間,而不要無限制地等待。

線程池大小考慮因素

  • 分析計算環境、資源預算和任務的特性。在部署的系統中有多少個CPU?多大的內存?任務是計算密集型、I/O密集型還是二者皆可。
  • 對於計算密集型的任務,在擁有N(CPU)個處理器的系統上,當線程池的大小爲N+1時,通常能實現最優的利用率(即使當計算密集型的線程偶爾由於頁缺失故障或者其它原因而暫停時,這個“額外”的線程也能確保CPU的時鐘週期不會被浪費)
  • 對於包含I/O操作或者其它阻塞操作的任務,由於線程並不會一直執行,因此線程池的規模應該更大。
  • 線程池資源並不是唯一影響線程池大小的資源,還包括內存、文件句柄、套接字句柄和數據庫連接等。

線程池中線程的創建與銷燬

前記,基本邏輯

會依據配置參數,自動生成需要的線程以及銷燬富餘的線程,以提高系統資源的利用率

基本大小(Core Pool Size)、最大大小(Maximum Pool Size)以及存活時間等因素共同負責線程的創建與銷燬。

基本大小也就是線程池的目標大小,即在沒有任務執行時(初期線程並不啓動,而是等到有任務提交時才啓動,除非調用prestartAllCoreThreads)線程池的大小,並且只有在工作隊列滿了的情況下才會創建超出這個數量的線程。

線程池的最大大小表示可同時活動的線程數量的上限。如果某個線程的空閒時間超過了存活時間,那麼將標記爲可回收的,並且當線程池的當前大小超過了基本大小時,這個線程將終止。

fixedthreadpool中線程爲什麼不會超時?

因爲基本大小與最大大小一致,按照上述銷燬邏輯,是不會終止線程的

cachedthreadpool是如何實現隊列未滿時就開始創建線程的

通過看代碼發現,execute函數調用的時候,會判斷當前是否有空閒的線程存在,如果沒有,就會創建一個新線程

高併發情況下線程池可能存在的問題

問題一:如果使用cachedthreadpool,無限制創建線程,那麼將導致不穩定性,比如達到最高線程允許數量,內存被用光等而報錯。

解決辦法:這種情況可以採用固定大小的線程池(而不是每收到一個請求就創建一個新線程)來解決這個問題。

問題二:高負載時,儘管使用固定線程池,仍可能因爲無限制任務隊列而耗盡資源,只是出現問題的概率較小。如果新請求的到達速率超過了線程池的處理速率,那麼新到來的請求將被累積起來。

解決辦法:使用有界隊列可以防止資源耗盡,但也因此必須要考慮飽和策略。因爲默認的中止策略可能不是我們想要的,詳情參考下面飽和策略

基本的任務排隊方法有3種


  • 無界隊列 在fixedthreadpool和singlethreadpool中有使用LinkedBlockedQueue
  • 有界隊列 LinkedBlockedQueue和ArrayBlockedQueue都支持
  • 同步移交 如SynchronousQueue,在cachedthreadpool中使用

同步移交隊列使用時有一定的限制,只有當線程池是無界的或者可以拒絕任務時,SynchronousQueue纔有實際價值。

飽和策略

ThreadPoolExecutor的飽和策略可以通過調用setRejectedExecutionHandler來修改


  • 中止(Abort)策略是默認的飽和策略,該策略將拋出未檢查的RejectedExecutionException。
  • 拋棄(Discard)策略會悄悄拋棄該任務。
  • 拋棄最舊的(Discard-Oldest)策略則會拋棄下一個將被執行的任務,然後嘗試重新提交新的任務。(如果工作隊列是一個優先隊列,那麼“拋棄最舊的”策略將導致拋棄優先級最高的任務,因此最好不要將“拋棄最舊的”飽和策略和優先級隊列放在一起使用)
  • 調用者運行(Caller-Runs)策略實現了一種調節機制,該策略既不會拋棄任務,也不會拋出異常,而是將某些任務回退到調用者。當線程池中所有線程都被佔用,並且工作隊列被填滿後,下一個任務會在調用executor時在主線程中執行。

當服務器過載時,這種過載情況會逐漸向外蔓延開來——從線程池到工作隊列到應用程序再到TCP層,最終達到客戶端,導致服務器在高負載下實現一種平緩的性能降低。
ThreadPoolExecutor executor = new ThreadPoolExecutor(N_THREADS,N_THREADS,0L,TimeUnit.MILLISECONDS,new LinkedBlockingQueue<Runnable>(CAPACITY));
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy);

就像YII框架一樣,JDK類都提供了默認的行爲,但幾乎所有東西都是可定製的。線程池的任務隊列,線程創建與銷燬,擴展與伸縮,飽和策略都是定製可配的。

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