接上一篇《如何設計一個異步Web服務——接口部分》
Application已經將任務信息發到了Service服務器中,接下來,Service服務器改如何對自身的資源進行合理分配以滿足Application對功能、性能、用戶體驗等各方面的需求呢?
可以從如下幾個方向入手去考慮:
-
當task提交到Service后,我們希望Service能夠盡可能快的完成這個task並返回結果。
-
當大量task同時提交Service后,我們希望Service不要因為需要同時處理大量task導致性能下降,甚至失去響應。
-
當有多個task被提交到Service時,我們不希望某一個task占用了所有計算資源,導致其他task長時間處於等待狀態。
如需轉載,請注明轉自:http://www.cnblogs.com/silenttiger/p/4135461.html
根據上面的要求,我們會產生如下的設計要求:
-
根據第一點要求,為了能夠盡快完成一個task,我們可以使用多線程(或多進程)技術,將一個task拆分為多個子task然后並行處理,充分利用多核CPU的計算資源。
-
根據第二點要求,我們需要為Service實現一個任務隊列,以免大量並發請求導致Service計算資源被耗盡。同時,大量的並發也會導致CPU為進行資源調度浪費許多計算資源。
-
根據第三點要求,雖然Service會使用任務隊列對任務進行排隊處理,但我們仍然希望有少量的task是並行進行的。
-
另外,一個task被拆分為許多子task后,如果為每個子task創建一個單獨的線程去處理,會導致CPU將大量時間消耗在線程的創建、銷毀過程中。所以,應該使用線程池(或進程池)技術。
下面,我們就根據上述的這些要求開始設計。
首先,我們需要一個http服務器作為接收Application請求的接口。然后,我們創建一個QueenAnt(蟻后)類來負責任務和資源的調度,同時還需要若干個WorkerAnt(工蟻)類來處理各個具體的task。
注意,這里的QueenAnt類是靜態的,或者也可以用單例模式創建。前面提到我們需要使用線程池(或進程池)技術,所以,在QueenAnt類被實例化以后首先就需要把這個線程池創建出來,並創建若干線程放入這個池中。其中,每個線程都會實例化一個WorkerAnt類來等待QueenAnt發過來的task。這個地方還有一個問題,那就是我們的線程池中到底創建幾個線程最優?這個問題我留到后面說明。
當這些准備好了以后,Service就可以等待Application的請求了。
當Application向Service發出addTask的請求時,http服務器會將這個請求通知給QueenAnt,並返回QueenAnt返回的taskId。
QueenAnt在收到task請求后,除了返回taskId,還需要對這個task的相關信息進行初始化,比如設置task的狀態信息,將task添加到任務隊列等等。
等這些結束以后,QueenAnt就開始針對已經收到的task進行任務調度和資源分配了。我們定義一個allocateResource方法來處理相關的邏輯。該方法將會指定threadPool中的哪個具體線程會來處理這個task。這之后,我們就可以把task相關的數據發給這個指定的thread進行處理了。而當有task完成時,處理該task的線程中的WorkerAnt就會發送相關信息給QueenAnt,調用QueenAnt的taskEndCallback方法,讓QueenAnt重新分配資源。
當WorkerAnt完成某一個task之后,他需要將這個task的相關信息返回給QueenAnt。同時標記自己為空閑狀態,以便QueenAnt再進行資源分配。
QueenAnt在收到WorkerAnt關於task完成的消息后,他也需要更新於這個task的相關狀態信息,並在此根據threadPool和taskQueue的具體情況重新進行資源分配。
到這里,我們就通過上圖描述的邏輯,滿足了設計要求中的第二和第四點要求。那第一和第三點要求呢,就得通過allocateResource這個方法去實現了。
下面我們詳細講一下allocateResource這個方法的內部邏輯。
這里先聲明一下后文的描述方法,我們把Application發過來的一個任務叫做"task",而把由這個任務拆分出來的許許多多的小任務叫做"子task"。
可能有人會產生疑惑,根據設計要求中的第一點,我們應該把task拆分為子task。可上面的設計中,我們放入taskQueue的卻是Application傳過來的task,是不是差一個拆分的步驟呢?
其實並不是這樣,這樣的設計是因為開頭的考慮方向中的第三點和設計要求中第三點,都要求一個task不可以占用所有的計算資源。這樣說可能不太好理解,我們來舉個例子:
首先,Application向Service提交了task01,該task共20個子task,需要Service滿負荷運行5分鍾才能完成。
到第3分鍾的時候,Application又向Service提交了task02,該task共4個子task,需要Service滿負荷運行1分鍾即可完成。
我們來分析一下這個場景。如果我們在將task01加入taskQueue之前,就將其拆分為許多的子task。並把threadPool中的資源依次分給這些子task。那么到第3分鍾加入task02的各個子task的時候,由於task01的子task沒有完成,task02只好處於等待狀態。而且需要等task01的幾乎所有子task都完成以后,才能進入處理中的狀態,這一等就是10分鍾。這顯然違背了我們考慮方向中的第三點和設計要求中第三點。
那么,怎樣設計這個allocateResource的邏輯才能既滿足設計要求中的第一點,又能滿足第三點了?我的思路是這樣的。
首先,我們給task加上兩個屬性threadRequirement和runningThread。threadRequirement表示,為了完成這個task,如果給其每個子task分配一個線程,那么一共需要多少個線程,隨着子task的完成,這個數值會越來越小,最后變為0即表示這個task已經全部完成。runningThread表示,當前有幾個線程正在處理這個task的子task。
然后,allocateResource這個方法有兩個地方會調用,一是當Service收到新的task請求的時候。二是當某個子task完成,QueenAnt中的taskEndCallback被調用的時候。
allocateResource在給task分配資源的時候,應遵守以下幾個准則:
-
taskQueue中處於等待狀態的task應該盡可能的少。
-
同時進行的task的數量不得超過threadPool中線程的總數。
-
每個task都應該至少有一個線程在處理其子task。
-
在滿足以上條件的情況下,threadRequirement最小的task分配到所有剩余的空閑線程資源。
這樣說可能有些抽象。我們還是來舉個上面那個例子,假設threadPool中共4個線程,task01的threadRequirement為20,task02的threadRequirement為2。過程如下:
-
QueenAnt收到task01的請求后開始調用allocateResource方法。且當前threadPool中有空閑的線程資源。
-
根據准則1,我們看到taskQueue中當前有一個task,就是task01。
-
當前沒有正在進行的task數量沒有達到threadPool中的線程總數4,滿足准則2。於是將task01從taskQueue中取出准備為其分配線程資源。
-
為滿足准則3,我們將threadPool中的一個空閑線程thread01分配給task01的一個子task:childTask01。
-
為滿足准則4,我們將threadPool中剩下的所有空閑線程都分配給task01,這樣以來,task01的4個子task,childTask01~childTask04同時接受處理。
-
一分鍾后,childTask01~childTask04相繼結束,taskEndCallback被觸發,allocateResource再次被調用。重復上面的步驟3和步驟4。childTask05~childTask08開始接受處理。
-
又一分鍾后,childTask05~childTask08相繼結束,taskEndCallback被觸發,allocateResource再次被調用。重復上面的步驟3和步驟4。childTask09~childTask12開始接受處理。
-
一秒鍾后,QueenAnt收到task02的請求后開始調用allocateResource方法。但當前threadPool中沒有空閑的線程資源,所以方法退出,task02停留在taskQueue中等待。
-
大概59秒以后,task01的一個childTask09結束,taskEndCallback被觸發,allocateResource再次被調用。
-
根據准則2,當前正在進行的task只有task01,遠沒達到threadPool中線程的總數4,所以我們可以將task02從taskQueue中取出准備為其分配線程資源。
-
根據准則3,每個task至少分配一個線程資源,而當前task02的runningThread為0。所以我們把剛才處理task01中childTask09的線程thread01分配給task02。就這樣,task02也開始運行起來了。
-
接下來,threadPool中的thread02和thread03也相繼完成了task01的childTask10和childTask11,並觸發taskEndCallback調用allocateResource。
-
此時,我們根據准則4,會把剛剛釋放出來的thread02和thread03兩個線程資源都分配給task02。這時,task01只有一個線程資源thread04在處理,而其他三個線程資源都被用來處理task02了。
-
再接下來,thread04處理完task01的childTask12后,根據准則3又會被分配給task01處理childTask13。
大概的邏輯就是上面這樣了。步驟看起來雖然略顯復雜,但其實只有掌握了前面說的4個准則,allocateResource的邏輯還是很好實現:
至此,關於Service任務調度和資源分配的設計也結束了。
下面,我們來說一下前面遺留的一個問題:線程池中到底創建幾個線程最優?
為什么最后要特別來談談這個問題,是因為市面上有一種叫做超線程的CPU虛擬化的技術。比如Intel公司的酷睿i3系列CPU,明明是兩個物理核心,在Windows的任務管理中,或在Linux系統的top命令下,顯示的卻是4個核心,因為CPU在硬件層面將兩個物理核心模擬為4個邏輯核心了。根據我們上面的設計,自然是希望threadPool中的線程數量越多越好,可是也不能太多。因為多個線程同時爭用一個CPU核心的資源是沒有必要的。所以,如果是4核的CPU,我們一般會起4個線程放入threadPool。可是在這種使用了超線程技術的CPU平台上,如果你把線程數目配置為與CPU邏輯核心數目一致卻是沒有必要的。我在i3平台上實測數據如下:
線程數 |
總耗時(s) |
CPU 0 使用率 |
CPU 1 使用率 |
CPU 2 使用率 |
CPU 3 使用率 |
1 |
22.4 |
1 |
0 |
98 |
0 |
2 |
12.6 |
2 |
98 |
0 |
97 |
3 |
11.2 |
78 |
64 |
97 |
4 |
4 |
10.5 |
98 |
99 |
100 |
98 |
我想上面的數據已經很好地說明了問題,雖然是4個邏輯核心,雖然你可以讓4個線程同時運行,但其實在CPU物理層面,同時運行的指令最多就兩個。也就是4個線程中每兩個線程去爭用一個物理核心的運算資源。
其結果就是,性能上的微小進步卻帶來了CPU使用率的大幅飆升,反而使得用來作為接口的httpServer響應時間變長。
如需轉載,請注明轉自:http://www.cnblogs.com/silenttiger/p/4135461.html
歡迎關注我的微信公眾號:老虎的小窩