面試官問:高并發下,你都怎么選擇最優的線程數?

希望文章像圖片一樣驚艷

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

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

一、理論分析

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

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

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

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

給定下列定義:

Ncpu?=?CPU的數量

Ucpu?=?目標CPU的使用率,?0?<=?Ucpu?<=?1

W/C?=?等待時間與計算時間的比率

為保持處理器達到期望的使用率,最優的池的大小等于:

Nthreads?=?Ncpu?x?Ucpu?x?(1?+?W/C)

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

intN_CPUS?=?Runtime.getRuntime().availableProcessors();

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

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

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

Nthreads?=?Ncpu?x?Ucpu?x?(1?+?W/C),其中

Ncpu?=?CPU核心數

Ucpu?=?CPU使用率,0~1

W/C?=?等待時間與計算時間的比率

第二種,《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 上下文切換是否值得,這里不考慮。讀者可自己考量。

來自:https://mp.weixin.qq.com/s/le8My8lmRMV_8rn7BTmKPA

?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。