整理一點讀書筆記,有關 IIS 伺服器與非同步處理的基本觀念。

運行於 IIS 中的 ASP.NET 應用程式本來就已經可以同時處理多個用戶端請求了,那麼,應用程式還需要特別使用非同步處理的技巧嗎?在某些情況下的確需要,尤其是當線上用戶數量龐大,且應用程式有許多「與 CPU 運算無關」的耗時工作的場合(例如磁碟  I/O、呼叫遠端的 WCF 服務等等)。

基本觀念:運行於 IIS 中的 ASP.NET 應用程式會使用一個執行緒集區(thread pool)來管理來自用戶端的 HTTP 請求,而一條執行緒一次只會傳回一個 HTTP 回應───因為一條執行緒無法同時服務多個請求。

IIS Web 伺服器的處理架構

IIS 會在 CLR(Common Language Runtime)的執行緒集區裡面保留一組特定數量的執行緒來專供自己使用。當用戶端發出的請求送達 IIS web 伺服器時,就是由這些執行緒來提供服務。每當 IIS 從執行緒集區裡面取出一條執行緒,該執行緒就只能服務一個請求。這通常不成問題,可是當許多用戶端同時發出大量請求時,可能就會影響效能,例如網頁遲遲無法顯示。

如果 ASP.NET 應用程式在處理某項作業時需要花很長的時間,這也會降低網站的延展性,因為每一個正在處理那項長時間作業的執行緒都會卡在那兒,等待作業執行完畢,才能再去服務其他請求。這些工作中的執行緒,就像忙線中的客服人員,必須等到掛完電話,才有辦法繼續接聽下一通電話。然而,當大量用戶端請求同時湧入(許多客戶同時打電話給同一家公司),執行緒集區裡面的可用執行緒的數量就會迅速減少,甚至出現完全沒有執行緒可用的情況(所有客服人員現在全部忙線中,若要等待,請按米字鍵…)。這種情況叫做「執行緒耗盡」(thread starvation),會嚴重影響應用程式的效能。
兩種執行緒集區:CLR 管理的執行緒集區有兩種: worker thread pool 和 I/O thread pool。 兩種集區裡面的執行緒是同樣的東西,之所以分成兩個集區,主要是希望應用程式依實際用途來選擇適當的集區,以免經常發生執行緒耗盡的情形。
應用程式若能適時運用非同步模式,就比較不會出現執行緒耗盡的情形,並有助於提升回應速度與延展性。舉例來說,當 ASP.NET 應用程式開始執行某項工作時──也許是呼叫遠程服務、存取檔案等等── web 伺服器先從執行緒集區裡面取出一條閒置的執行緒,由它來處理這項工作。由於這項 I/O 工作需要花比較長的時間,我們決定採用非同步的方式來處理,而此非同步呼叫的動作會告訴執行緒:此工作要花較多時間,等它處理完畢時,會再去通知 IIS。於是,在等待作業完成期間,那條執行緒就可以先放回執行緒集區,繼續處理其他用戶端的請求。一旦先前交代的工作執行完畢,原先的非同步動作就會通知 IIS 目前的處理狀況。此時,IIS 會從執行緒集區中取出一條閒置的執行緒,並將目前的作業交給它處理。請注意,一開始接受任務的那個執行緒不見得剛好就是後來接手繼續完成作業的執行緒。不過,這整個過程當中的細節都會由 IIS 處理,應用程式本身無須在意實際的工作是由哪條執行緒負責執行;它只要知道如何取得執行結果就行了。

簡短複習一下非同步處理的基本觀念:執行某一件工作所需要花的時間,並不會因為你採用了非同步呼叫的寫法而有顯著差異。非同步呼叫的優點是它不會卡住(block)應用程式目前的執行緒(或稱主執行緒),故呼叫端無須停下來等待特定單一操作,從而提升了應用程式的回應速度。更好的是,若電腦本身有多顆 CPU 或多核心的 CPU,應用程式還能夠同時執行兩件或更多工作,充分利用機器的運算資源,因而提升整體效能。

與併行處理有關的 IIS 與伺服器組態設定

就非同步處理而言,IIS 7.5 和 IIS 8.0 的預設組態通常能夠滿足一般的應用程式,但在某些情況下,我們仍可能需要對 IIS 的設定做些調整,以獲取最佳效能。

比如說,ASP.NET 4.0 和 4.5 的 MaxConcurrentRequestPerCPU 預設值是 5000,這表示每顆 CPU 能夠同時處理的用戶端請求數量最多為 5000(先前的 .NET 版本是很小的數字,據說是 12)。
註:以上是針對 Windows 伺服器的環境組態而言。用戶端版本的 Windows 另有限制,參見:Windows 8 / IIS 8 Concurrent Requests Limit

即使每顆 CPU 可同時處理的請求數量已經夠大,仍有其他因素會影響非同步處理的效率。其中一個關鍵因素是應用程式集區(application pool)的佇列長度,參數名稱是 queueLength。此參數如何影響應用程式效能呢?

當伺服器收到用戶端送出的 HTTP 請求時,位於系統底層的 HTTP 監聽器──亦即 HTTP.sys,它是個 kernel-mode 驅動程式──會將請求轉交給 IIS 處理,然後,IIS 應用程式所產生的回應也會由 HTTP.sys 負責傳回用戶端瀏覽器。可是當應用程式集區的佇列中等待的請求數量已經達到 queueLength 參數的設定值(佇列已滿),HTTP.sys 就會拒絕後續的用戶端請求。 

每個應用程式集區有自己的佇列長度參數,預設值是 1000。我們可以透過 IIS 管理員來修改這個參數:在左邊樹狀結構的區域中展開電腦名稱,點選「應用程式集區」,然後在中間內容區塊中找到你想要調整的應用程式集區,於該項目上點右鍵,選「進階設定」,如下圖所示:


接著找到「佇列長度」參數,修改成你想要的數值,如下圖所示。要注意的是,佇列長度如果設得太小,用戶端可能會經常碰到 HTTP 503 的錯誤,代表伺服器拒絕再接受更多請求。


把這個參數設定成 5000 應該足以應付大多數的場合。照 Rick Anderson 所說,每加入一條新執行緒至 thread pool 中就需要配置約 1MB 的堆疊記憶空間,萬一你的 ASP.NET 應用程式會一次用滿 5000 條執行緒,那就等於是 5GB 的記憶體負擔了!

還有一些與應用程式效能有關的設定,將來碰到了再個別說明。接著稍微提一下 ASP.NET 非同步程式設計所要注意的事項。

建立執行緒 vs. 從執行緒集區取得執行緒

在撰寫多執行緒的程式時,一種作法是以 new Thread() 的方式來建立執行緒,但此作法的成本會比從 thread pool 直接取出執行緒來得高。因此,一般建議是盡量透過 thread pool,也就是呼叫 ThreadPool.QueueUserWorkItem() 來取得執行緒。

可是,在 ASP.NET 應用程式中取用 thread pool 中的執行緒,將導致它能夠同時處理的 HTTP 請求數量減少。原因正如前面提過的,ASP.NET 也是用 thread pool 中的執行緒來處理 HTTP 請求,一旦 pool 中的執行緒用完,就沒辦法再接受其他 HTTP 請求了。

那麼,如果我們明確指定要使用 I/O thread pool 中的執行緒呢?這樣就不會占用 worker thread pool 的資源了吧?這個....除了 .NET Framework 類別本身提供的一些非同步方法之外,似乎就只剩下一個有點麻煩的選擇:I/O Completion Port。還好,許多常用的 I/O 操作,例如檔案存取、網路呼叫等,.NET Framework 都有提供對應的非同步方法,足以應付大部分的場合。

如此說來,無論是建立新執行緒還是使用 ThreadPool,在 ASP.NET 應用程式中都得謹慎使用,甚至最好別用。比較好的作法,還是以非同步呼叫的方式來執行與 I/O 有關的工作,例如:Stream.BeginRead/EndRead、WebRequest.BeginGetResponse/EndGetResponse 等等。參考底下這段從 MSDN 網站上抄來的範例程式:

public static async Task<byte[]> DownloadDataAsync(string url)
{
    var request = WebRequest.Create(url)
    using(var response = await request.GetResponseAsync())
    using(var responseStream = response.GetResponseStream())
    using(var result = new MemoryStream())
    {
        await responseStream.CopyToAsync(result);
        return result.ToArray();
    }
}

這段範例程式使用了幾個 .NET 4.5 新增的非同步方法,包括 WebRequest.GetResponseAsync 和 Stream.CopyToAsync。

最後一個重點,也是建議採用非同步呼叫來取代建立執行緒的原因之一:非同步呼叫不必然會建立新的執行緒。這點在 Rick Anderson 的文章裡面也有提到。

延伸閱讀