面試官:高併發下,你怎麼選擇最優的線程數?

爲了加快程序處理速度,我們會將問題分解成若干個併發執行的任務。並且創建線程池,將任務委派給線程池中的線程,以便使它們可以併發的執行。在高併發的情況下采用線程池,可以有效降低線程創建釋放的時間花銷及資源開銷,如不使用線程池,有可能造成系統創建大量線程而導致消耗完系統內存以及“過度切換”(在JVM中採用的處理機制爲時間的輪轉,減少了線程間的相互切換) 。

但是有一個很大的問題擺在我們面前,即我們希望儘可能多地創建任務,但由於資源所限我們又不能創建過多的線程。那麼在高併發的情況下,我們怎麼選擇最優的線程數量呢?選擇原則又是什麼呢?

一、理論分析

關於如何計算併發線程數,有兩種說法。

第一種,《Java Concurrency in Practice》即《java併發編程實踐》8.2節 170頁

對於計算密集型的任務,一個有Ncpu個處理器的系統通常通過使用一個Ncpu + 1個線程的線程池來獲得最優的利用率(計算密集型的線程恰好在某時因爲發生一個頁錯誤或者因其他原因而暫停,剛好有一個“額外”的線程,可以確保在這種情況下CPU週期不會中斷工作)。

對於包含了 I/O和其他阻塞操作的任務,不是所有的線程都會在所有的時間被調度,因此你需要一個更大的池。爲了正確地設置線程池的長度,你必須估算出任務花在等待的時間與用來計算的時間的比率;這個估算值不必十分精確,而且可以通過一些監控工具獲得。你還可以選擇另一種方法來調節線程池的大小,在一個基準負載下,使用 幾種不同大小的線程池運行你的應用程序,並觀察CPU利用率的水平。

給定下列定義:

面試官:高併發下,你怎麼選擇最優的線程數?

 

你可以使用Runtime來獲得CPU的數目:

int N_CPUS = Runtime.getRuntime().availableProcessors();

當然,CPU週期並不是唯一你可以使用線程池管理的資源。其他可以約束資源池大小的資源包括:內存、文件句柄、套接字句柄和數據庫連接等。計算這些類型資源池的大小約束非常簡單:首先累加出每一個任務需要的這些資源的總童,然後除以可用的總量。所得的結果是池大小的上限。

當任務需要使用池化的資源時,比如數據庫連接,那麼線程池的長度和資源池的長度會相互影響。如果每一個任務都需要一個數據庫連接,那麼連接池的大小就限制了線程池的有效大小;類似地,當線程池中的任務是連接池的唯一消費者時,那麼線程池的大小反而又會限制了連接池的有效大小。

如上,在《Java Concurrency in Practice》一書中,給出了估算線程池大小的公式:

面試官:高併發下,你怎麼選擇最優的線程數?

 

第二種,《Programming Concurrency on the JVM Mastering》即《Java 虛擬機併發編程》2.1節 12頁

爲了解決上述難題,我們希望至少可以創建處理器核心數那麼多個線程。這就保證了有儘可能多地處理器核心可以投入到解決問題的工作中去。通過下面的代碼,我們可以很容易地獲取到系統可用的處理器核心數:

Runtime.getRuntime().availableProcessors();

所以,應用程序的最小線程數應該等於可用的處理器核數。如果所有的任務都是計算密集型的,則創建處理器可用核心數那麼多個線程就可以了。在這種情況下,創建更多的線程對程序性能而言反而是不利的。因爲當有多個仟務處於就緒狀態時,處理器核心需要在線程間頻繁進行上下文切換,而這種切換對程序性能損耗較大。但如果任務都是IO密集型的,那麼我們就需要開更多的線程來提高性能。

當一個任務執行IO操作時,其線程將被阻塞,於是處理器可以立即進行上下文切換以便處理其他就緒線程。如果我們只有處理器可用核心數那麼多個線程的話,則即使有待執行的任務也無法處理,因爲我們已經拿不出更多的線程供處理器調度了。

如果任務有50%的時間處於阻塞狀態,則程序所需線程數爲處理器可用核心數的兩倍。如果任務被阻塞的時間少於50%,即這些任務是計算密集型的,則程序所需線程數將隨之減少,但最少也不應低於處理器的核心數。如果任務被阻塞的時間大於執行時間,即該任務是IO密集型的,我們就需要創建比處理器核心數大幾倍數量的線程。我們可以計算出程序所需線程的總數,總結如下:

  • 線程數 = CPU可用核心數/(1 - 阻塞係數),其中阻塞係數的取值在0和1之間。
  • 計算密集型任務的阻塞係數爲0,而IO密集型任務的阻塞係數則接近1。一個完全阻塞的任務是註定要掛掉的,所以我們無須擔心阻塞係數會達到1。

爲了更好地確定程序所需線程數,我們需要知道下面兩個關鍵參數:

  • 處理器可用核心數;
  • 任務的阻塞係數;

第一個參數很容易確定,我們甚至可以用之前的方法在運行時查到這個值。但確定阻塞係數就稍微困難一些。我們可以先試着猜測,抑或採用一些性能分析工具或java.lang.management API來確定線程化在系統IO操作上的時間與CPU密集任務所耗時間的比值。如上,在《Programming Concurrency on the JVM Mastering》一書中,給出了估算線程池大小的公式:

線程數 = Ncpu /(1 - 阻塞係數)

對於說法一,假設CPU 100%運轉,即撇開CPU使用率這個因素,線程數 = Ncpu x (1 + W/C)。

現在假設將方法二的公式等於方法一公式,即Ncpu /(1 - 阻塞係數)= Ncpu x (1 + W/C),推導出:阻塞係數 = W / (W + C),即阻塞係數 = 阻塞時間 /(阻塞時間 + 計算時間),這個結論在方法二後續中得到印證,如下:

由於對Web服務的請求大部分時間都花在等待服務器響應上了,所以阻塞係數會相當高,因此程序需要開的線程數可能是處理器核心數的若干倍。假設阻塞係數是0.9,即每個任務90%的時間處於阻塞狀態而只有10%的時間在幹活,則在雙核處理器上我們就需要開20個線程(使用第2.1節的公式計算)。如果有很多隻股票要處理的話,我們可以在8核處理器上開到80個線程來處理該任務。

由此可見,說法一和說法二其實是一個公式。

二、實際應用

那麼實際使用中併發線程數如何設置呢?我們先看一道題目:

假設要求一個系統的TPS(Transaction Per Second或者Task Per Second)至少爲20,然後假設每個Transaction由一個線程完成,繼續假設平均每個線程處理一個Transaction的時間爲4s。那麼問題轉化爲:

如何設計線程池大小,使得可以在1s內處理完20個Transaction?

計算過程很簡單,每個線程的處理能力爲0.25TPS,那麼要達到20TPS,顯然需要20/0.25=80個線程。

這個理論上成立的,但是實際情況中,一個系統最快的部分是CPU,所以決定一個系統吞吐量上限的是CPU。增強CPU處理能力,可以提高系統吞吐量上限。在考慮時需要把CPU吞吐量加進去。

分析如下(我們以說法一公式爲例):

Nthreads = Ncpu x (1 + W/C)

即線程等待時間所佔比例越高,需要越多線程。線程CPU時間所佔比例越高,需要越少線程。這就可以劃分成兩種任務類型:

IO密集型 一般情況下,如果存在IO,那麼肯定W/C > 1(阻塞耗時一般都是計算耗時的很多倍),但是需要考慮系統內存有限(每開啓一個線程都需要內存空間),這裏需要在服務器上測試具體多少個線程數適合(CPU佔比、線程數、總耗時、內存消耗)。如果不想去測試,保守點取1即可,Nthreads = Ncpu x (1 + 1) = 2Ncpu。這樣設置一般都OK。

計算密集型 假設沒有等待W = 0,則W/C = 0。Nthreads = Ncpu。

根據短板效應,真實的系統吞吐量並不能單純根據CPU來計算。那要提高系統吞吐量,就需要從“系統短板”(比如網絡延遲、IO)着手:

  • 儘量提高短板操作的並行化比率,比如多線程下載技術;
  • 增強短板能力,比如用NIO替代IO;

第一條可以聯繫到Amdahl定律,這條定律定義了串行系統並行化後的加速比計算公式:加速比 = 優化前系統耗時 / 優化後系統耗時 加速比越大,表明系統並行化的優化效果越好。Addahl定律還給出了系統並行度、CPU數目和加速比的關係,加速比爲Speedup,系統串行化比率(指串行執行代碼所佔比率)爲F,CPU數目爲N:Speedup <= 1 / (F + (1-F)/N)

當N足夠大時,串行化比率F越小,加速比Speedup越大。

這時候又拋出是否線程池一定比單線程高效的問題?

答案是否定的,比如Redis就是單線程的,但它卻非常高效,基本操作都能達到十萬量級/s。從線程這個角度來看,部分原因在於:

  • 多線程帶來線程上下文切換開銷,單線程就沒有這種開銷;
  • 鎖;

當然“Redis很快”更本質的原因在於:

Redis基本都是內存操作,這種情況下單線程可以很高效地利用CPU。而多線程適用場景一般是:存在相當比例的IO和網絡操作。

總的來說,應用情況不同,採取多線程/單線程策略不同;線程池情況下,不同的估算,目的和出發點是一致的。

至此結論爲:

IO密集型 = 2Ncpu(可以測試後自己控制大小,2Ncpu一般沒問題)(常出現於線程中:數據庫數據交互、文件上傳下載、網絡數據傳輸等等)

計算密集型 = Ncpu(常出現於線程中:複雜算法)

當然說法一中還有一種說法:

對於計算密集型的任務,一個有Ncpu個處理器的系統通常通過使用一個Ncpu + 1個線程的線程池來獲得最優的利用率(計算密集型的線程恰好在某時因爲發生一個頁錯誤或者因其他原因而暫停,剛好有一個“額外”的線程,可以確保在這種情況下CPU週期不會中斷工作)。

即,計算密集型 = Ncpu + 1,但是這種做法導致的多一個CPU上下文切換是否值得,這裏不考慮。讀者可自己考量。

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