天天看點

别再糾結線程池大小了,沒有固定公式的!終于有人說清楚了。。

可能很多人都看到過一個線程數設定的理論:

CPU 密集型的程式 - 核心數 + 1

I/O 密集型的程式 - 核心數 * 2

不會吧,不會吧,真的有人按照這個理論規劃線程數?

線程數和CPU使用率的小測試

抛開一些作業系統,計算機原理不談,說一個基本的理論(不用糾結是否嚴謹,隻為好了解):一個CPU核心,機關時間内隻能執行一個線程的指令那麼理論上,我一個線程隻需要不停的執行指令,就可以跑滿一個核心的使用率。

來寫個死循環空跑的例子驗證一下:

測試環境:AMD Ryzen 5 3600, 6 - Core, 12 - Threads

public class CPUUtilizationTest {
    public static void main(String[] args) {
        //死循環,什麼都不做
        while (true){
        }
    }
}      

運作這個例子後,來看看現在CPU的使用率:

别再糾結線程池大小了,沒有固定公式的!終于有人說清楚了。。

從圖上可以看到,我的3号核心使用率已經被跑滿了

那基于上面的理論,我多開幾個線程試試呢?

public class CPUUtilizationTest {
 public static void main(String[] args) {

  for (int j = 0; j < 6; j++) {
   new Thread(new Runnable() {
    @Override
    public void run() {
     while (true){
     }
    }
   }).start();
  }
 }
}      

此時再看CPU使用率,1/2/5/7/9/11 幾個核心的使用率已經被跑滿:

别再糾結線程池大小了,沒有固定公式的!終于有人說清楚了。。

那如果開12個線程呢,是不是會把所有核心的使用率都跑滿?答案一定是會的:

别再糾結線程池大小了,沒有固定公式的!終于有人說清楚了。。

如果此時我把上面例子的線程數繼續增加到24個線程,會出現什麼結果呢?

别再糾結線程池大小了,沒有固定公式的!終于有人說清楚了。。

從上圖可以看到,CPU使用率和上一步一樣,還是所有核心100%,不過此時負載已經從11.x增加到了22.x(load average解釋參考scoutapm.com/blog/unders…),說明此時CPU更繁忙,線程的任務無法及時執行。

現代CPU基本都是多核心的,比如我這裡測試用的AMD 3600,6核心12線程(超線程),我們可以簡單的認為它就是12核心CPU。那麼我這個CPU就可以同時做12件事,互不打擾。

如果要執行的線程大于核心數,那麼就需要通過作業系統的排程了。作業系統給每個線程配置設定CPU時間片資源,然後不停的切換,進而實作“并行”執行的效果。

但是這樣真的更快嗎?從上面的例子可以看出,一個線程 就可以把一個核心 的使用率跑滿。如果每個線程都很“霸道”,不停的執行指令,不給CPU空閑的時間,并且同時執行的線程數大于CPU的核心數,就會導緻作業系統更頻繁的執行切換線程執行 ,以確定每個線程都可以得到執行。

不過切換是有代價的,每次切換會伴随着寄存器資料更新,記憶體頁表更新等操作 。雖然一次切換的代價和I/O操作比起來微不足道,但如果線程過多,線程切換的過于頻繁,甚至在機關時間内切換的耗時已經大于程式執行的時間,就會導緻CPU資源過多的浪費在上下文切換上,而不是在執行程式,得不償失。

上面死循環空跑的例子,有點過于極端了,正常情況下不太可能有這種程式。

大多程式在運作時都會有一些 I/O操作,可能是讀寫檔案,網絡收發封包等,這些 I/O 操作在進行時時需要等待回報的。比如網絡讀寫時,需要等待封包發送或者接收到,在這個等待過程中,線程是等待狀态,CPU沒有工作。此時作業系統就會排程CPU去執行其他線程的指令,這樣就完美利用了CPU這段空閑期,提高了CPU的使用率。

上面的例子中,程式不停的循環什麼都不做,CPU要不停的執行指令,幾乎沒有啥空閑的時間。如果插入一段I/O操作呢,I/O 操作期間 CPU是空閑狀态,CPU的使用率會怎麼樣呢?先看看單線程下的結果:

public class CPUUtilizationTest {
 public static void main(String[] args) throws InterruptedException {

  for (int n = 0; n < 1; n++) {
   new Thread(new Runnable() {
    @Override
    public void run() {
     while (true){
                        //每次空循環 1億 次後,sleep 50ms,模拟 I/O等待、切換
      for (int i = 0; i < 100_000_000l; i++) {
      }
      try {
       Thread.sleep(50);
      }
      catch (InterruptedException e) {
       e.printStackTrace();
      }
     }
    }
   }).start();
  }
 }
}      
别再糾結線程池大小了,沒有固定公式的!終于有人說清楚了。。

哇,唯一有使用率的9号核心,使用率也才50%,和前面沒有sleep的100%相比,已經低了一半了。現在把線程數調整到12個看看:

别再糾結線程池大小了,沒有固定公式的!終于有人說清楚了。。

單個核心的使用率60左右,和剛才的單線程結果差距不大,還沒有把CPU使用率跑滿,現在将線程數增加到18:

别再糾結線程池大小了,沒有固定公式的!終于有人說清楚了。。

此時單核心使用率,已經接近100%了。由此可見,當線程中有 I/O 等操作不占用CPU資源時,作業系統可以排程CPU可以同時執行更多的線程。

現在将I/O事件的頻率調高看看呢,把循環次數減到一半,50_000_000,同樣是18個線程:

别再糾結線程池大小了,沒有固定公式的!終于有人說清楚了。。

此時每個核心的使用率,大概隻有70%左右了。

線程數和CPU使用率的小總結

上面的例子,隻是輔助,為了更好的了解線程數/程式行為/CPU狀态的關系,來簡單總結一下:

一個極端的線程(不停執行“計算”型操作時),就可以把單個核心的使用率跑滿,多核心CPU最多隻能同時執行等于核心數的“極端”線程數

如果每個線程都這麼“極端”,且同時執行的線程數超過核心數,會導緻不必要的切換,造成負載過高,隻會讓執行更慢

I/O 等暫停類操作時,CPU處于空閑狀态,作業系統排程CPU執行其他線程,可以提高CPU使用率,同時執行更多的線程

I/O 事件的頻率頻率越高,或者等待/暫停時間越長,CPU的空閑時間也就更長,使用率越低,作業系統可以排程CPU執行更多的線程

線程數規劃的公式

前面的鋪墊,都是為了幫助了解,現在來看看書本上的定義。《Java 并發程式設計實戰》介紹了一個線程數計算的公式:

Ncpu=CPU核心數Ncpu=CPU 核心數Ncpu=CPU核心數

Ucpu=目标CPU使用率,0<=Ucpu<=1Ucpu=目标CPU使用率,0<=Ucpu<=1Ucpu=目标CPU使用率,0<=Ucpu<=1

WC=等待時間和計算時間的比例\frac{W}{C}=等待時間和計算時間的比例CW=等待時間和計算時間的比例

如果希望程式跑到CPU的目标使用率,需要的線程數公式為:

Nthreads=Ncpu∗Ucpu∗(1+WC)Nthreads=NcpuUcpu(1+\frac{W}{C})Nthreads=Ncpu∗Ucpu∗(1+CW)

公式很清晰,現在來帶入上面的例子試試看:

如果我期望目标使用率為90%(多核90),那麼需要的線程數為:

核心數12 * 使用率0.9 * (1 + 50(sleep時間)/50(循環50_000_000耗時)) ≈ 22

現在把線程數調到22,看看結果:

别再糾結線程池大小了,沒有固定公式的!終于有人說清楚了。。

現在CPU使用率大概80+,和預期比較接近了,由于線程數過多,還有些上下文切換的開銷,再加上測試用例不夠嚴謹,是以實際使用率低一些也正常。

把公式變個形,還可以通過線程數來計算CPU使用率:

Ucpu=NthreadsNcpu∗(1+WC)Ucpu=\frac{Nthreads}{Ncpu*(1+\frac{W}{C})}Ucpu=Ncpu∗(1+CW)Nthreads

線程數22 / (核心數12 * (1 + 50(sleep時間)/50(循環50_000_000耗時))) ≈ 0.9

雖然公式很好,但在真實的程式中,一般很難獲得準确的等待時間和計算時間,因為程式很複雜,不隻是“計算” 。一段代碼中會有很多的記憶體讀寫,計算,I/O 等複合操作,精确的擷取這兩個名額很難,是以光靠公式計算線程數過于理想化。

真實程式中的線程數

那麼在實際的程式中,或者說一些Java的業務系統中,線程數(線程池大小)規劃多少合适呢?

先說結論:沒有固定答案,先設定預期,比如我期望的CPU使用率在多少,負載在多少,GC頻率多少之類的名額後,再通過測試不斷的調整到一個合理的線程數

比如一個普通的,SpringBoot 為基礎的業務系統,預設Tomcat容器+HikariCP連接配接池+G1回收器,如果此時項目中也需要一個業務場景的多線程(或者線程池)來異步/并行執行業務流程。

此時我按照上面的公式來規劃線程數的話,誤差一定會很大。因為此時這台主機上,已經有很多運作中的線程了,Tomcat有自己的線程池,HikariCP也有自己的背景線程,JVM也有一些編譯的線程,連G1都有自己的背景線程。這些線程也是運作在目前程序、目前主機上的,也會占用CPU的資源。

是以受環境幹擾下,單靠公式很難準确的規劃線程數,一定要通過測試來驗證。

流程一般是這樣:

分析目前主機上,有沒有其他程序幹擾

分析目前JVM程序上,有沒有其他運作中或可能運作的線程

設定目标

目标CPU使用率 - 我最高能容忍我的CPU飙到多少?

目标GC頻率/暫停時間 - 多線程執行後,GC頻率會增高,最大能容忍到什麼頻率,每次暫停時間多少?

執行效率 - 比如批處理時,我機關時間内要開多少線程才能及時處理完畢

……

梳理鍊路關鍵點,是否有卡脖子的點,因為如果線程數過多,鍊路上某些節點資源有限可能會導緻大量的線程在等待資源(比如三方接口限流,連接配接池數量有限,中間件壓力過大無法支撐等)

不斷的增加/減少線程數來測試,按最高的要求去測試,最終獲得一個“滿足要求”的線程數**

而且而且而且!不同場景下的線程數理念也有所不同:

Tomcat中的maxThreads,在Blocking I/O和No-Blocking I/O下就不一樣

Dubbo 預設還是單連接配接呢,也有I/O線程(池)和業務線程(池)的區分,I/O線程一般不是瓶頸,是以不必太多,但業務線程很容易稱為瓶頸

Redis 6.0以後也是多線程了,不過它隻是I/O 多線程,“業務”處理還是單線程

是以,不要糾結設定多少線程了。沒有标準答案,一定要結合場景,帶着目标,通過測試去找到一個最合适的線程數。

可能還有同學可能會有疑問:“我們系統也沒啥壓力,不需要那麼合适的線程數,隻是一個簡單的異步場景,不影響系統其他功能就可以”

很正常,很多的内部業務系統,并不需要啥性能,穩定好用符合需求就可以了。那麼我的推薦的線程數是:CPU核心數

附錄

Java 擷取CPU核心數

Runtime.getRuntime().availableProcessors()//擷取邏輯核心數,如6核心12線程,那麼傳回的是12      

Linux 擷取CPU核心數

# 總核數 = 實體CPU個數 X 每顆實體CPU的核數
# 總邏輯CPU數 = 實體CPU個數 X 每顆實體CPU的核數 X 超線程數

# 檢視實體CPU個數
cat /proc/cpuinfo| grep "physical id"| sort| uniq| wc -l

# 檢視每個實體CPU中core的個數(即核數)
cat /proc/cpuinfo| grep "cpu cores"| uniq

# 檢視邏輯CPU的個數
cat /proc/cpuinfo| grep "processor"| wc -l