原文鏈接:https://segmentfault.com/a/1190000022158995
含淚播種的人一定能含笑收獲。
有個朋友Hunter跟我聊,最近他參加騰訊的面試,在二面的時候被問到了關於線程池線程數目設置的一個問題。此處記錄下這個問題的面試過程,以及后面關於此問題的理論方面的知識講解。
面試過程
面試官開場了:
線程池你用過吧,線程數是怎么設置的呢?
Hunter心想,這不難啊,曾經在《Java並發編程》一書中有看到過線程池中線程數目設置的講述,於是張口就來:
線程數的設置需要考慮三方面的因素,服務器的配置、服務器資源的預算和任務自身的特性。具體來說就是服務器有多少個CPU,多少內存,IO支持的最大QPS是多少,任務主要執行的是計算、IO還是一些混合操作,任務中是否包含數據庫連接等的稀缺資源。線程池的線程數設置主要取決於這些因素。
面試官追問來了:
那具體是怎么設置呢?
Hunter略一思忖,整理了下思路,娓娓道來:
假設機器有N個CPU,那么對於計算密集型的任務,應該設置線程數為N+1;對於IO密集型的任務,應該設置線程數為2N;對於同時有計算工作和IO工作的任務,應該考慮使用兩個線程池,一個處理計算任務,一個處理IO任務,分別對兩個線程池按照計算密集型和IO密集型來設置線程數。
面試官表情毫無變化,接着發問:
N+1和2N是怎么來的?
Hunter張口就來:
是個經驗值。
面試官:
經驗值嗎?那為什么不是N+2或者N+3,而非得是N+1呢?
Hunter被駁得稍有點懵,腦子里努力在回想學習過的那些技術點,竟一時語塞。
看得出來面試官略有不滿,於是提示道:
那假如在一個請求中,計算操作需要5ms,DB操作需要100ms,對於一台8個CPU的服務器,怎么設置線程數呢?
Hunter努力平復心情,緊接着最開始的思路,說到:
這是一個計算和IO混合型的任務,可以將其分解為兩個線程池來處理。一個線程池處理計算操作,設置N+1=9個線程,一個線程處理IO操作,設置2N=16個線程。
面試官:
如果一個任務同時包含了一個計算操作和DB操作呢,不能拆分怎么設置?你能講一下具體的計算過程嗎?
Hunter略有點慌,心里不斷給自己暗示:這個問題不難不難。然后不斷回想看過的《Java並發編程實戰》和《Java虛擬機並發編程》中關於線程池設置的章節,並試圖將自己對這個問題的分析思路也表達出來。
首先這個任務整體上是一個IO密集型的任務。在處理一個請求的過程中,總共耗時100+5=105ms,而其中只有5ms是用於計算操作的,CPU利用率為5/(100+5)。使用線程池是為了盡量提高CPU的利用率,減少對CPU資源的浪費,假設以100%的CPU利用率來說,要達到100%的CPU利用率,對於一個CPU就要設置其利用率的倒數個數的線程數,也即1/(5/(100+5)),8個CPU的話就乘以8。那么算下來的話,就是……168,對,這個線程池要設置168個線程數。
面試官表情略有緩和,嘴角微微一笑:
如果實際的任務差異較大,不同任務實際的CPU操作耗時和IO操作耗時有所不同,那么怎么設置線程數呢?
經過剛才的分析過程,Hunter心里已經回憶起了這塊的知識點,已然不慌了。
那對所有任務的CPU操作耗時和IO操作耗時求個平均值就好了。
Hunter心里漸漸恢復了自信,大腦的利用率瞬間提高好幾十個百分點。
面試官輕輕“嗯”了一聲,表示認可。
那如果現在這個IO操作是DB操作,而DB的QPS上限是1000,這個線程池又該設置為多大呢?
經過剛才的心理調整,對問題完整的分析過程,以及面試官的略微認可,Hunter已經知道如何去更好地回答面試官的問題了。
按比例來減少就可以了,按照之前的計算過程,可以計算出來當線程數設置為168的時候,DB操作的QPS為,168(1000/(100+5))=1600,如果現在DB的QPS最大為1000,那么對應的,最大只能設置168(1000/1600)=105個線程。
面試官這次是真的滿意了,給這個回答給了一個正面的評價:
思路挺清晰的。那設置線程池的時候除了考慮這些,還需要考慮哪些內容呢?
Hunter此時已經完全找回自信了,不懼任何問題。
除了考慮任務CPU操作耗時、IO操作耗時之外,還需要服務器的內存資源、硬盤資源、網絡帶寬等等的。
面試官點點頭,看起來Hunter已經獲得了面試官的正式認可了。面試官告訴Hunter,表現不錯,等接下來的面試安排吧。
面試后總結
Hunter內心異常激動,這真算是一次“死里逃生”的經歷了。面試結束后,Hunter壓抑興奮,馬上去找到《Java並發編程實戰》和《Java虛擬機並發編程》兩本書,翻到對應的章節,想確認下自己的回答。
果然,壓力除了會造成緊張之外,也能提高大腦利用率。Hunter在調整狀態后的回答完全正確。附上兩本書中對線程池設置的理論。
線程數的第一種計算方法
在《Java並發編程實踐》中,是這樣來計算線程池的線程數目的:
在一個基准負載下,使用 幾種不同大小的線程池運行你的應用程序,並觀察CPU利用率的水平。
給定下列定義:
Ncpu = CPU的數量
Ucpu = 目標CPU的使用率, 0 <= Ucpu <= 1
W/C = 等待時間與計算時間的比率
為保持處理器達到期望的使用率,最優的池的大小等於:
Nthreads = Ncpu x Ucpu x (1 + W/C)
這種計算方式,我們需要知道上面定義的幾個數值,才能計算出來線程池需要設置的線程數。其中,CPU數量是確定的,CPU使用率是目標值也是確定的,W/C也是可以通過基准程序測試得出的。
線程數的第二種計算方法
而在《Java虛擬機並發編程》中,則是這樣來計算線程池的線程數目的:
線程數 = CPU可用核心數/(1 - 阻塞系數),其中阻塞系數的取值在0和1之間。
計算密集型任務的阻塞系數為0,而IO密集型任務的阻塞系數則接近1。一個完全阻塞的任務是注定要掛掉的,所以我們無須擔心阻塞系數會達到1。
這種計算方式,我們需要知道CPU可用核心數和阻塞系數,才能計算出來線程池需要設置的線程數目。其中,CPU可用核心數是確定的,阻塞系數可以通過公式:阻塞系數=阻塞時間/(阻塞時間+計算時間),其實也就是上一種算法中的W/C的方式來計算,所以阻塞系數也是可以通過基准程序計算得出的。
所謂的經驗值怎么來的
那么我們再來看所謂的N+1與2N的經驗值的來源。
計算密集型應用
以第一種計算方式來看,對於計算密集型應用,假定等待時間趨近於0,是的CPU利用率達到100%,那么線程數就是CPU核心數,那這個+1意義何在呢?
《Java並發編程實踐》這么說:
計算密集型的線程恰好在某時因為發生一個頁錯誤或者因其他原因而暫停,剛好有一個“額外”的線程,可以確保在這種情況下CPU周期不會中斷工作。
所以N+1確實是一個經驗值。
IO密集型應用
同樣以第一種方式來看,對於IO密集型應用,假定所有的操作時間幾乎都是IO操作耗時,那么W/C的值就為1,那么對應的線程數確實為2N。