如何設計一個異步Web服務——任務調度


接上一篇《如何設計一個異步Web服務——接口部分

 

Application已經將任務信息發到了Service服務器中,接下來,Service服務器改如何對自身的資源進行合理分配以滿足Application對功能、性能、用戶體驗等各方面的需求呢?

 

可以從如下幾個方向入手去考慮:

  1. 當task提交到Service后,我們希望Service能夠盡可能快的完成這個task並返回結果。
  2. 當大量task同時提交Service后,我們希望Service不要因為需要同時處理大量task導致性能下降,甚至失去響應。
  3. 當有多個task被提交到Service時,我們不希望某一個task占用了所有計算資源,導致其他task長時間處於等待狀態。

根據上面的要求,我們會產生如下的設計要求:

  1. 根據第一點要求,為了能夠盡快完成一個task,我們可以使用多線程(或多進程)技術,將一個task拆分為多個子task然后並行處理,充分利用多核CPU的計算資源。
  2. 根據第二點要求,我們需要為Service實現一個任務隊列,以免大量並發請求導致Service計算資源被耗盡。同時,大量的並發也會導致CPU為進行資源調度浪費許多計算資源。
  3. 根據第三點要求,雖然Service會使用任務隊列對任務進行排隊處理,但我們仍然希望有少量的task是並行進行的。
  4. 另外,一個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分配資源的時候,應遵守以下幾個准則:

  1. taskQueue中處於等待狀態的task應該盡可能的少。
  2. 同時進行的task的數量不得超過threadPool中線程的總數。
  3. 每個task都應該至少有一個線程在處理其子task。
  4. 在滿足以上條件的情況下,threadRequirement最小的task分配到所有剩余的空閑線程資源。

 

這樣說可能有些抽象。我們還是來舉個上面那個例子,假設threadPool中共4個線程,task01的threadRequirement為20,task02的threadRequirement為2。過程如下:

  1. QueenAnt收到task01的請求后開始調用allocateResource方法。且當前threadPool中有空閑的線程資源。
  2. 根據准則1,我們看到taskQueue中當前有一個task,就是task01。
  3. 當前沒有正在進行的task數量沒有達到threadPool中的線程總數4,滿足准則2。於是將task01從taskQueue中取出准備為其分配線程資源。
  4. 為滿足准則3,我們將threadPool中的一個空閑線程thread01分配給task01的一個子task:childTask01。
  5. 為滿足准則4,我們將threadPool中剩下的所有空閑線程都分配給task01,這樣以來,task01的4個子task,childTask01~childTask04同時接受處理。
  6. 一分鍾后,childTask01~childTask04相繼結束,taskEndCallback被觸發,allocateResource再次被調用。重復上面的步驟3和步驟4。childTask05~childTask08開始接受處理。
  7. 又一分鍾后,childTask05~childTask08相繼結束,taskEndCallback被觸發,allocateResource再次被調用。重復上面的步驟3和步驟4。childTask09~childTask12開始接受處理。
  8. 一秒鍾后,QueenAnt收到task02的請求后開始調用allocateResource方法。但當前threadPool中沒有空閑的線程資源,所以方法退出,task02停留在taskQueue中等待。
  9. 大概59秒以后,task01的一個childTask09結束,taskEndCallback被觸發,allocateResource再次被調用。
  10. 根據准則2,當前正在進行的task只有task01,遠沒達到threadPool中線程的總數4,所以我們可以將task02從taskQueue中取出准備為其分配線程資源。
  11. 根據准則3,每個task至少分配一個線程資源,而當前task02的runningThread為0。所以我們把剛才處理task01中childTask09的線程thread01分配給task02。就這樣,task02也開始運行起來了。
  12. 接下來,threadPool中的thread02和thread03也相繼完成了task01的childTask10和childTask11,並觸發taskEndCallback調用allocateResource。
  13. 此時,我們根據准則4,會把剛剛釋放出來的thread02和thread03兩個線程資源都分配給task02。這時,task01只有一個線程資源thread04在處理,而其他三個線程資源都被用來處理task02了。
  14. 再接下來,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

 

歡迎關注我的微信公眾號:老虎的小窩
微信公眾號 老虎的小窩


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM