背景
最近線上系統添加了告警資訊,突然出現了很多 CPU Load 的峰刺告警,如下:并且這種峰刺出現的頻率不固定,檢視 cat 發現,每小時出現的頻率也固定,多的時候十幾次,少的時候一兩次。有告警資訊可知,是 cat 采集到的 system.process:cpu.system.load.percent 名額超過 60% 導緻。而這個名額一看就是系統 CPU 負載的名額。為了解決這個問題,首先要搞清楚,CPU 負載也就是 CPU Load 是個啥?,是以就有了這篇文章。
可能很多人都看到過一個線程數設定的理論:
CPU 密集型的程式 - 核心數 + 1
I/O 密集型的程式 - 核心數 * 2
這個理論看起來很合理,但實際操作起來,總是不僅如此人意。
下面,我們就來看一下 線程數, CPU 的關系先說一個基本的理論,大家應該都知道:
一個CPU核心,機關時間内隻能執行一個線程的指令
那麼理論上,我一個線程隻需要不停的執行指令,就可以跑滿一個核心的使用率。
下面我們用一段程式來測試下 CPU 和線程數 之間的關系。
CPU 使用率測試
測試環境:公司配置的 mac 電腦(配置太低,日常開發夾到懷疑人生)
2.3 GHz 雙核Intel Core i5,(2 Core, 4 Threads)
8 GB 2133 MHz LPDDR3
來寫個死循環空跑的例子驗證一下:
public static void main(String[] args) {
testWithCalc();
}
public static void testWithCalc() {
while (true) {
}
}
運作這個例子之前,先來來看看現在CPU的使用率:
未運作之前
> 由于本身 有一些程式在跑可以看到,左上角 4 個 CPU 核心數(2核4線程,姑且認為 4個CPU核心),CPU 使用率都是個位數,看起來毫無壓力。右上角 有個 Load average 表示 CPU 的負載,代表 CPU 的處理線程的繁忙程度。
接下來,運作上面的程式之後,再來看看 CPU 的使用率:
1 個線程
從圖上可以看到,我的2号核心使用率達到 50% 多,但是為啥沒有跑滿呢,因為 CPU 執行線程是靠配置設定給線程時間片來運作不同的線程的,而我們的線程是個 while true 會一直循環 CPU ,是以 CPU 的使用率應該是 100 %沒錯,但是對于多核應該是多核的 CPU 使用率加起來,即
0号(28.9%)+1号(18.7)+2号(50.7%)+3号(7.9%) > 100%
那基于上面的理論,我多開幾個線程試試呢?
public static void main(String[] args) {
testWithThreadCalc(2);
}
public static void testWithThreadCalc(int threadNum) {
System.out.println("start ...");
for (int i = 0; i < threadNum; i++) {
new Thread(() -> {
// 模拟計算操作
while (true) {
}
}).start();
}
}
我們先開兩個線程,此時再看CPU使用率:
2 個線程
2 個線程運作我們的程式, 那麼 整體 CPU 使用率 定會大于 200% ,即
0号(68.4%)+1号(35.8)+2号(68.0%)+3号(37.6%) > 200%
那如果開4個線程呢,是不是會把所有核心的使用率都跑滿?答案一定是會的:
public static void main(String[] args) {
testWithThreadCalc(4);
}
public static void testWithThreadCalc(int threadNum) {
System.out.println("start ...");
for (int i = 0; i < threadNum; i++) {
new Thread(() -> {
// 模拟計算操作
while (true) {
}
}).start();
}
}
4 個線程
此時的結果不出我們所料,所有的 CPU 使用率依然達到 100%,而 右上角的 Load average 我們可以看到,才 10% 左右,說明, CPU 負載不是很高,如果這時再增加線程, CPU 排程就會開始繁忙起來,那麼 CPU Load 也會增加,為了印證我們的猜想,把線程數調整到 100 試試(此時的我已帶上頭盔,因為怕這破電腦炸了。。。)。
public static void main(String[] args) {
testWithThreadCalc(100);
}
public static void testWithThreadCalc(int threadNum) {
System.out.println("start ...");
for (int i = 0; i < threadNum; i++) {
new Thread(() -> {
// 模拟計算操作
while (true) {
}
}).start();
}
}
100 個線程(此刻,電腦 CPU 風扇狂轉)
随着風扇的嗡嗡聲,可以看到,我的 4個CPU 還是 100% 的使用率,不過此時的 CPU 負載 Load average 也從 10% 升高到了 98.98%,說明此時CPU更繁忙,線程的任務無法及時執行。很明顯,此刻,我的電腦已不堪重負。
現代CPU基本都是多核心的,比如我這裡測試用的 Intel CPU,2 核心 4 線程(超線程),我們可以簡單的認為它就是 4 核心 CPU。那麼我這個CPU就可以同時做 4 件事,互不打擾。
如果要執行的線程大于核心數,那麼就需要通過作業系統的排程了。作業系統給每個線程配置設定CPU時間片資源,然後不停的切換,進而實作“并行”執行的效果。
但是這樣真的更快嗎?從上面的例子可以看出,一個線程就可以把一個核心的使用率跑滿。如果每個線程都很“霸道”,不停的執行指令,不給CPU空閑的時間,并且同時執行的線程數大于CPU的核心數,就會導緻作業系統更頻繁的執行切換線程執行,以確定每個線程都可以得到執行。
線程切換也是有代價的,每次切換會伴随着寄存器資料更新,記憶體頁表更新等操作。雖然一次切換的代價和I/O操作比起來微不足道,但如果線程過多,線程切換的過于頻繁,甚至在機關時間内切換的耗時已經大于程式執行的時間,就會導緻CPU資源過多的浪費在上下文切換上,而不是在執行程式,得不償失。
上面一直死循環空跑的例子,有點過于極端了,正常情況下不太可能有這種程式。
大多程式在運作時都會有一些 I/O操作,可能是讀寫檔案,網絡收發封包等,這些 I/O 操作在進行時時需要等待回報的。比如網絡讀寫時,需要等待封包發送或者接收到,在這個等待過程中,線程是等待狀态,CPU沒有工作。此時作業系統就會排程CPU去執行其他線程的指令,這樣就完美利用了CPU這段空閑期,提高了CPU的使用率。
上面的例子中,程式不停的循環什麼都不做,CPU要不停的執行指令,幾乎沒有啥空閑的時間。如果插入一段I/O操作呢,I/O 操作期間 CPU是空閑狀态,CPU的使用率會怎麼樣呢?
下面代碼,我們開啟了 4個線程,每個線程裡面都有一個計數器,每當計數器達到 100w 的時候就随機睡眠 0-200 ms,模拟我們的 IO 操作,更加接近真實的運作環境。
public class CPULoadTest {
private static Random random = new Random();
public static void main(String[] args) {
testWithRandomIO(4);
}
public static void testWithRandomIO(int threadNum) {
for (int i = 0; i < threadNum; i++) {
new Thread(() -> {
long counter = 0;
while (true && counter < Long.MAX_VALUE) {
counter++;
if (counter % 1000000 == 0) {
// 随機睡眠 0-200 ms
randomSleep(200);
}
}
}).start();
}
}
public static void randomSleep(int sleep) {
// 模拟IO 操作
try {
int millis = random.nextInt(sleep);
System.out.println("sleep:" + millis + " ms");
Thread.sleep(millis);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
4 個線程(模拟随機IO的線程)
哇,最高使用率的0号核心,使用率也才22%,和前面沒有 sleep 的相比(每個核心 100%),已經低了太多了。注意此刻 CPU 負載也不過 3% 不到而已,現在把線程數調整到100個看看:
還記得我們之前沒有模拟随機 IO 時 ,100 線程的情況嗎,CPU 全部 100%,CPU 負載 98.98%,我的電腦差點挂掉,那這次擁有的随機 IO 後,情況會怎樣呢?不賣關子了,直接看下圖:
public class CPULoadTest {
private static Random random = new Random();
public static void main(String[] args) {
testWithRandomIO(100);
}
public static void testWithRandomIO(int threadNum) {
for (int i = 0; i < threadNum; i++) {
new Thread(() -> {
long counter = 0;
while (true && counter < Long.MAX_VALUE) {
counter++;
if (counter % 1000000 == 0) {
// 随機睡眠 0-200 ms
randomSleep(200);
}
}
}).start();
}
}
public static void randomSleep(int sleep) {
// 模拟IO 操作
try {
int millis = random.nextInt(sleep);
System.out.println("sleep:" + millis + " ms");
Thread.sleep(millis);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
100 個線程(模拟随機IO的線程)
單個核心的使用率最高 77% 左右,雖然比 剛才 4個線程的 CPU 使用率搞了那麼多,但還沒有把CPU使用率跑滿。CPU 負載此時才 3.93% ,也是輕輕松松。現在将線程數增加到200:
public class CPULoadTest {
private static Random random = new Random();
public static void main(String[] args) {
testWithRandomIO(200);
}
public static void testWithRandomIO(int threadNum) {
for (int i = 0; i < threadNum; i++) {
new Thread(() -> {
long counter = 0;
while (true && counter < Long.MAX_VALUE) {
counter++;
if (counter % 1000000 == 0) {
// 随機睡眠 0-200 ms
randomSleep(200);
}
}
}).start();
}
}
public static void randomSleep(int sleep) {
// 模拟IO 操作
try {
int millis = random.nextInt(sleep);
System.out.println("sleep:" + millis + " ms");
Thread.sleep(millis);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
200 個線程(模拟随機IO的線程)
此時單核心使用率平均 90% ,已經接近100%了。CPU 負載 由 3% 升到 12 % 。由此可見,當線程中有 I/O 等操作不占用CPU資源時,作業系統可以排程CPU可以同時執行更多的線程。如果線程排程比較繁忙,那麼 CPU Load 就會随之升高。
現在将I/O事件的頻率調高看看呢,還是 200個線程,随機睡眠 400 ms:
public class CPULoadTest {
private static Random random = new Random();
public static void main(String[] args) {
testWithRandomIO(200);
}
public static void testWithRandomIO(int threadNum) {
for (int i = 0; i < threadNum; i++) {
new Thread(() -> {
long counter = 0;
while (true && counter < Long.MAX_VALUE) {
counter++;
if (counter % 1000000 == 0) {
// 随機睡眠 0-400 ms
randomSleep(400);
}
}
}).start();
}
}
public static void randomSleep(int sleep) {
// 模拟IO 操作
try {
int millis = random.nextInt(sleep);
System.out.println("sleep:" + millis + " ms");
Thread.sleep(millis);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
200 個線程(模拟随機IO(400ms)的線程)
此時每個核心的使用率,大概隻有80%左右了。并且 CPU Load 也降到了 5.9 %。結果表明,當 IO 事件耗時越多時,CPU 使用率就越低,CPU 負載也越低。
現在,我們已經知道了 線程數,線程,IO事件,CPU Load 之間的關系。
但不管怎樣,我們的目标是: 提高 CPU 使用率,降低 CPU Load。
是以,回到我們開始遇到 告警問題,發現降低 CPU Load 的條件,要麼減少 CPU 密集型線程的使用,要麼減少線程的數量。而我們的服務本身為了提高性能,計算的時候特地使用的CPU 并行計算,那麼隻剩下減少線程數這一條路可走了。經過排查,發現我們的服務中依賴了很多線程池,由于我們所有服務都依賴一個 manager 層,每個服務啟動的時候,Spring會掃描所有的 Bean,進行初始化,包括我們的線城池,有一些線城池明顯不屬于這個服務,那麼在服啟動的時候就不應該加載。發現了原因沒問題就很好解決了,我們隻需要把所有的線程池Bean,設定為 懶加載模式即可,即隻有在第一次擷取 Bean 的時候才初始化這個線程池,雖然在服務運作時再初始化線程池會有一些滿請求,但是無傷大雅,為了性能,可以容忍。
那如果,線程數量不可減少的情況下又該怎麼辦呢?那就隻能更新機器配置了呗。
線程數 和 CPU 使用率 的小總結:
上面的例子,隻是輔助,為了更好的了解線程數/程式行為/CPU狀态的關系,我們來簡單總結一下:
- 一個極端的線程(不停執行“計算”型操作時),就可以把單個核心的使用率跑滿,多核心 CPU 最多隻能同時執行的線程數等于其核心數;
- 如果每個線程都這麼“極端”,且同時執行的線程數超過核心數,會導緻不必要的切換,造成負載過高,隻會讓執行更慢;
- I/O 等暫停類操作時,CPU 處于空閑狀态,作業系統排程 CPU 執行其他線程,可以提高 CPU 使用率,同時執行更多的線程;
- I/O 事件的頻率頻率越高,或者等待/暫停時間越長,CPU 的空閑時間也就更長,使用率越低,作業系統可以排程 CPU 執行更多的線程
線程數規劃的公式
《Java 并發程式設計實戰》介紹了一個線程數計算的公式:
Ncpu = CPU 核心數
Ucpu = CPU 使用率,0<= Ucpu <=1
W/C = 等待時間/計算時間
線程數計算公式:
Nthreads = Ncpu * Ucpu * (1+W/C)
雖然公式很好,但在真實的程式中,一般很難獲得準确的等待時間和計算時間,因為程式很複雜,不隻是“計算”。一段代碼中會有很多的記憶體讀寫,計算,I/O 等複合操作,精确的擷取這兩個名額很難,是以光靠公式計算線程數過于理想化。
比如一個普通的,SpringBoot 為基礎的業務系統,預設Tomcat容器+資料庫連接配接池+JDK+ Spring架構自帶線程,如果此時項目中也需要一個業務場景的多線程(或者線程池)來異步/并行執行業務流程。
此時我按照上面的公式來規劃線程數的話,誤差一定會很大。因為此時這台主機上,已經有很多運作中的線程了,Tomcat有自己的線程池,資料庫連接配接池也有自己的背景線程,JVM也有一些編譯的線程,連垃圾收集器都有自己的背景線程。這些線程也是運作在目前程序、目前主機上的,也會占用CPU的資源。是以受環境幹擾下,單靠公式很難準确的規劃線程數,一定要通過測試來驗證。
真實程式中的線程數
那麼在實際的程式中,或者說一些Java的業務系統中,線程數(線程池大小)規劃多少合适呢?
經常上面我們的測試,可以認為:沒有固定答案,一個比較好的實踐就是:先設定預期,比如我期望的CPU使用率在多少,負載在多少,GC頻率多少之類的名額後,再通過測試不斷的調整到一個合理的線程數
是以,不要糾結設定多少線程了。沒有标準答案,一定要結合場景,帶着目标,通過測試去找到一個最合适的線程數。
- [1]:《Java 并發程式設計實戰》