ASP.NET 4.5 中使用非同步方法

作者:Rick Anderson

本教學將教你如何使用 Visual Studio Express 2012 for Web(Microsoft Visual Studio 的免費版本)建立非同步 ASP.NET Web 表單應用程式的基礎。 你也可以使用 Visual Studio 2012。 以下章節包含在本教學中。

本教學提供了完整範例,網址為
https://github.com/RickAndMSFT/Async-ASP.NET/ 在 GitHub 網站上。

ASP.NET 4.5 網頁結合 .NET 4.5 允許你註冊非同步方法,回傳 Task 類型的物件。 .NET Framework 4 引入了非同步程式設計概念,稱為 Task ASP.NET 4.5 支援 Task。 任務以 System.Threading.Tasks 命名空間中的任務類型及相關類型表示。 .NET Framework 4.5 基於此非同步支援,透過 await 與 async 關鍵字,使得操作 任務 物件比先前非同步方法簡單許多。 等待關鍵字是語法上的簡寫,表示一段程式碼應該非同步等待另一段程式碼。 非同步關鍵字代表一個提示,可以用來標記方法為基於任務的非同步方法。 await、async 和 Task 物件的組合,讓你在 .NET 4.5 中寫非同步程式碼變得容易許多。 非同步方法的新模型稱為 基於任務的非同步模式 (TAP)。 這個教學假設你對使用 await 和 async 關鍵字以及 Task 命名空間的非同步程式設計有一定熟悉。

關於使用 await 和 async 關鍵字以及 任務 命名空間的更多資訊,請參閱以下參考文獻。

請求是如何由執行緒池處理的

在網頁伺服器上,.NET Framework 維護一個執行緒池,用於服務 ASP.NET 請求。 當請求抵達時,來自執行緒池的執行緒會被派遣去處理該請求。 如果請求是同步處理,處理請求的執行緒在請求被處理時會處於忙碌狀態,該執行緒無法再處理其他請求。

這可能不是問題,因為執行緒池可以設計得足夠大,容納許多繁忙的執行緒。 然而,執行緒池中的執行緒數量有限(.NET 4.5 的預設最大執行緒為 5,000 個)。 在大型且長時間執行請求頻繁的應用中,所有可用執行緒可能都處於忙碌狀態。 這種狀況稱為線條飢餓。 當達到此條件時,網頁伺服器會排隊處理請求。 如果請求佇列已滿,網頁伺服器會拒絕 HTTP 503 狀態(伺服器過忙)的請求。 CLR 執行緒池對新執行緒注入有限制。 如果並發是突發式(也就是說,你的網站突然收到大量請求),而所有可用的請求執行緒都因為高延遲的後端呼叫而忙碌,那麼執行緒注入速率的限制會讓你的應用程式反應非常差。 此外,每新增一個執行緒到執行緒池,都會產生額外負擔(例如 1 MB 的堆疊記憶體)。 使用同步方法處理高延遲呼叫且執行緒池成長至 .NET 4.5 預設最大 5,000 執行緒的網頁應用程式,會比使用非同步方法且僅 50 執行緒的應用程式多約 5 GB 記憶體。 當你在進行非同步工作時,你不一定總是要使用執行緒。 例如,當你發出非同步網路服務請求時,ASP.NET 不會在 非同步 方法呼叫與 wait 之間使用任何執行緒。 使用執行緒池來處理高延遲的請求,可能導致記憶體佔用量大且伺服器硬體利用率低。

處理非同步請求

在啟動時有大量同時請求或有突發負載(即並發突然增加)的網頁應用程式中,將網路服務呼叫設為非同步,能提升應用程式的反應速度。 非同步請求的處理時間與同步請求相同。 例如,若一個請求進行一個需要兩秒完成的網路服務呼叫,無論是同步還是非同步執行,請求時間都是兩秒。 然而,在非同步呼叫中,執行緒在等待第一個請求完成時,不會被阻擋回應其他請求。 因此,當有許多同時的請求啟動需要長時間運行的操作時,非同步請求可以防止請求排隊及避免執行緒池的擴展。

選擇同步或非同步方法

本節列出何時使用同步或非同步方法的指引。 這些只是指引;逐一檢視每個應用程式,以判斷非同步方法是否有助於提升效能。

一般而言,以下情況下應使用同步方法:

  • 操作簡單或耗時不長。
  • 簡單比效率更重要。
  • 這些操作主要是 CPU 操作,而非涉及大量磁碟或網路開銷的操作。 在受限於 CPU 的操作上使用非同步方法並無益處,反而增加了開銷。

一般而言,以下條件應使用非同步方法:

  • 你呼叫的服務是透過非同步方式被使用的,而且你使用的是 .NET 4.5 或更高版本。

  • 這些操作是網路綁定或 I/O 綁定,而非 CPU 綁定。

  • 平行性比程式碼的簡單性更重要。

  • 你想提供一個機制,讓使用者可以取消長期執行的請求。

  • 當切換執行緒的好處超過上下文切換的成本時, 一般來說,如果同步方法在沒有工作的情況下阻擋 ASP.NET 請求執行緒,你應該讓該方法成為非同步。 透過將呼叫設為非同步,ASP.NET 請求執行緒在等待網路服務請求完成時不會被阻塞,無法進行任何工作。

  • 測試顯示阻擋操作是站點效能的瓶頸,IIS 透過非同步方法處理這些阻擋呼叫,能處理更多請求。

    可下載的範例展示了如何有效使用非同步方法。 所提供的範例旨在簡單示範 ASP.NET 4.5 版本中的非同步程式設計。 本範例並非用於 ASP.NET 非同步程式設計的參考架構。 範例程式呼叫 ASP.NET Web API 方法,這些 API 方法又呼叫 Task.Delay 來模擬長時間執行的網路服務呼叫。 大多數生產應用程式不會顯示出使用非同步方法的明顯好處。

很少有應用要求所有方法都非同步。 通常,將少數同步方法轉換為非同步方法,能在所需工作量中獲得最佳效率提升。

範例應用

你可以從 https://github.com/RickAndMSFT/Async-ASP.NETGitHub 網站下載範例應用程式。 該資料庫包含三個專案:

  • WebAppAsync:ASP.NET Web 表單專案,採用 Web API WebAPIpwg 服務。 這個教學的大部分程式碼來自這個專案。
  • WebAPIpgw:ASP.NET MVC 4 Web API 專案,實作控制器 Products, Gizmos and Widgets 。 它提供 WebAppAsync 專案與 Mvc4Async 專案的資料。
  • Mvc4Async:ASP.NET MVC 4 專案,包含另一個教學中使用的程式碼。 它會呼叫 Web API 給 WebAPIpwg 服務。

Gizmos 同步頁面

以下程式碼展示了 Page_Load 用來顯示一系列裝置清單的同步方法。 (本文中,gizmo 是一個虛構的機械裝置。)

public partial class Gizmos : System.Web.UI.Page
{
    protected void Page_Load(object sender, EventArgs e)
    {
        var gizmoService = new GizmoService();
        GizmoGridView.DataSource = gizmoService.GetGizmos();
        GizmoGridView.DataBind();
    }
}

以下程式碼顯示 gizmo 服務的方法。

public class GizmoService
{
    public async Task<List<Gizmo>> GetGizmosAsync(
        // Implementation removed.
       
    public List<Gizmo> GetGizmos()
    {
        var uri = Util.getServiceUri("Gizmos");
        using (WebClient webClient = new WebClient())
        {
            return JsonConvert.DeserializeObject<List<Gizmo>>(
                webClient.DownloadString(uri)
            );
        }
    }
}

此 GizmoService GetGizmos 方法會將 URI 傳遞給 ASP.NET Web API HTTP 服務,該服務會回傳一份 gizmos 資料清單。 WebAPIpgw 專案包含了 Web API gizmos, widget 與product控制器的實作。
以下圖片展示了範例專案中的小工具頁面。

Sync Gizmos 網頁頁面截圖,顯示 gizmos 表格及輸入 Web API 控制器後的相關細節。

建立非同步 Gizmos 頁面

範例使用新的 非同步 與 等待 關鍵字(可在 .NET 4.5 與 Visual Studio 2012 中提供),讓編譯器負責維護非同步程式設計所需的複雜轉換。 編譯器允許你使用 C# 的同步控制流程結構撰寫程式碼,編譯器會自動套用回調所需的轉換,以避免阻塞執行緒。

ASP.NET 非同步頁面必須包含 Page 指令,並將 Async 屬性設為「true」。 以下程式碼顯示 頁面指令, 並將 Async 屬性設為「true」,代表 GizmosAsync.aspx 頁面。

<%@ Page Async="true"  Language="C#" AutoEventWireup="true" 
    CodeBehind="GizmosAsync.aspx.cs" Inherits="WebAppAsync.GizmosAsync" %>

以下程式碼顯示 Gizmos 同步 Page_Load 方法與 GizmosAsync 非同步頁面。 如果你的瀏覽器支援 HTML 5 <標記> 元素,你會看到變化會以黃色高亮顯示 GizmosAsync 。

protected void Page_Load(object sender, EventArgs e)
{
   var gizmoService = new GizmoService();
   GizmoGridView.DataSource = gizmoService.GetGizmos();
   GizmoGridView.DataBind();
}

非同步版本:

protected void Page_Load(object sender, EventArgs e)
{
    RegisterAsyncTask(new PageAsyncTask(GetGizmosSvcAsync));
}

private async Task GetGizmosSvcAsync()
{
    var gizmoService = new GizmoService();
    GizmosGridView.DataSource = await gizmoService.GetGizmosAsync();
    GizmosGridView.DataBind();
}

為了讓GizmosAsync頁面能夠非同步,已進行以下變更。

  • 頁面指令必須設定Async屬性為「true」。
  • 此 RegisterAsyncTask 方法用於註冊包含非同步執行程式碼的非同步任務。
  • 新GetGizmosSvcAsync方法使用非同步關鍵字標記,這會讓編譯器為主體部分生成回調,並自動建立並回傳Task。
  • 「非同步」會附加在非同步方法名稱後面。 非必須加上「Async」,但這是撰寫非同步方法時的慣例。
  • 新 GetGizmosSvcAsync 方法的回傳型態為 Task。 回傳類型 Task 代表正在進行的工作,並為方法呼叫者提供一個句柄,讓使用者可以透過此等待非同步操作完成。
  • wait 關鍵字被套用在網路服務通話上。
  • 非同步網路服務 API 被命名為 (GetGizmosAsync)。

在 GetGizmosSvcAsync 方法本體中,另一個非同步方法 GetGizmosAsync 被呼叫。 GetGizmosAsync 立即回傳Task<List<Gizmo>>,該Task<List<Gizmo>>會在資料可用時完成。 因為你不想在拿到小工具資料前做其他事,程式碼會等待任務(使用 await 關鍵字)。 你只能在標註了非同步關鍵字的方法中使用 await 關鍵字。

等待關鍵字在任務完成前不會阻塞執行緒。 它會將方法的剩餘部分註冊為任務上的回調函數,並立即返回。 當等待的任務最終完成時,它會呼叫該回調,並從中止處繼續執行該方法。 欲了解更多關於使用 await 與 async 關鍵字及 任務 命名空間的資訊,請參閱 非同步參考文獻。

以下程式碼顯示了 GetGizmos 和 GetGizmosAsync 方法。

public List<Gizmo> GetGizmos()
{
    var uri = Util.getServiceUri("Gizmos");
    using (WebClient webClient = new WebClient())
    {
        return JsonConvert.DeserializeObject<List<Gizmo>>(
            webClient.DownloadString(uri)
        );
    }
}
public async Task<List<Gizmo>> GetGizmosAsync()
{
    var uri = Util.getServiceUri("Gizmos");
    using (WebClient webClient = new WebClient())
    {
        return JsonConvert.DeserializeObject<List<Gizmo>>(
            await webClient.DownloadStringTaskAsync(uri)
        );
    }
}

這些非同步變更與上述 GizmosAsync 類似。

  • 方法簽名以 非同步 關鍵字標註,回傳類型改為 Task<List<Gizmo>>,並將 非同步 附加在方法名稱後。
  • 非同步的 HttpClient 類別被使用,取代同步的 WebClient 類別。
  • wait 關鍵字被應用於 HttpClientGetAsync 非同步方法。

下圖顯示非同步裝置的視角。

Gizmos 非同步網頁瀏覽器頁面的截圖,顯示 gizmos 表格及其對應的細節,這些細節是經由 Web API 控制器輸入的。

瀏覽器對 gizmos 資料的呈現方式與同步呼叫所建立的視圖完全相同。 唯一的差別是非同步版本在重負載下可能效能較高。

RegisterAsyncTask 筆記

連接的方法 RegisterAsyncTask 會在 預渲染後立即執行。

如果你直接使用非同步的空白頁面事件,如以下程式碼所示:

protected async void Page_Load(object sender, EventArgs e) {
    await ...;
    // do work
}

你不再能完全控制事件何時執行。 例如,若 .aspx 和 .Master 檔案都定義了 Page_Load 事件,且其中一個或兩個事件是非同步的,則其執行順序無法保證。 事件處理程序(例如 async void Button_Click )也適用相同的不定順序。

並行執行多項操作

當動作必須執行多個獨立操作時,非同步方法相較於同步方法有顯著優勢。 在提供的範例中,同步頁面 PWG.aspx(代表產品、小工具與小工具)顯示三次網路服務呼叫的結果,以取得產品、小工具與小工具的清單。 提供這些服務的 ASP.NET Web API 專案使用 Task.Delay 來模擬延遲或網路呼叫變慢。 當延遲設為 500 毫秒時,非同步 PWGasync.aspx 頁完成時間略超過 500 毫秒,而同步 PWG 版本則需超過 1,500 毫秒。 同步 PWG.aspx 頁以以下程式碼顯示。

protected void Page_Load(object sender, EventArgs e)
{
    Stopwatch stopWatch = new Stopwatch();
    stopWatch.Start();

    var widgetService = new WidgetService();
    var prodService = new ProductService();
    var gizmoService = new GizmoService();

    var pwgVM = new ProdGizWidgetVM(
        widgetService.GetWidgets(),
        prodService.GetProducts(),
        gizmoService.GetGizmos()
       );
    WidgetGridView.DataSource = pwgVM.widgetList;
    WidgetGridView.DataBind();
    ProductGridView.DataSource = pwgVM.prodList;
    ProductGridView.DataBind();
    GizmoGridView.DataSource = pwgVM.gizmoList;
    GizmoGridView.DataBind();

    stopWatch.Stop();
    ElapsedTimeLabel.Text = String.Format("Elapsed time: {0}", 
        stopWatch.Elapsed.Milliseconds / 1000.0);
}

背後的非同步 PWGasync 程式碼如下所示。

protected void Page_Load(object sender, EventArgs e)
{
    Stopwatch stopWatch = new Stopwatch();
    stopWatch.Start();
    RegisterAsyncTask(new PageAsyncTask(GetPWGsrvAsync));
    stopWatch.Stop();
    ElapsedTimeLabel.Text = String.Format("Elapsed time: {0}",
        stopWatch.Elapsed.Milliseconds / 1000.0);
}

private async Task GetPWGsrvAsync()
{
    var widgetService = new WidgetService();
    var prodService = new ProductService();
    var gizmoService = new GizmoService();

    var widgetTask = widgetService.GetWidgetsAsync();
    var prodTask = prodService.GetProductsAsync();
    var gizmoTask = gizmoService.GetGizmosAsync();

    await Task.WhenAll(widgetTask, prodTask, gizmoTask);

    var pwgVM = new ProdGizWidgetVM(
       widgetTask.Result,
       prodTask.Result,
       gizmoTask.Result
       );

    WidgetGridView.DataSource = pwgVM.widgetList;
    WidgetGridView.DataBind();
    ProductGridView.DataSource = pwgVM.prodList;
    ProductGridView.DataBind();
    GizmoGridView.DataSource = pwgVM.gizmoList;
    GizmoGridView.DataBind();           
}

下圖顯示非同步 PWGasync.aspx 頁面回傳的視圖。

非同步小工具、產品與 Gizmos 網頁瀏覽器頁面的截圖,顯示小工具、產品與 Gizmos 表格。

使用取消標記

回Task傳的非同步方法是可取消的,也就是說,當提供AsyncTimeout指令的屬性時,它們會取用 CancellationToken 參數。 以下程式碼顯示 GizmosCancelAsync.aspx 頁,且逾時時間為第二秒。

<%@ Page  Async="true"  AsyncTimeout="1" 
    Language="C#" AutoEventWireup="true" 
    CodeBehind="GizmosCancelAsync.aspx.cs" 
    Inherits="WebAppAsync.GizmosCancelAsync" %>

以下程式碼顯示 GizmosCancelAsync.aspx.cs 檔案。

protected void Page_Load(object sender, EventArgs e)
{
    RegisterAsyncTask(new PageAsyncTask(GetGizmosSvcCancelAsync));
}

private async Task GetGizmosSvcCancelAsync(CancellationToken cancellationToken)
{
    var gizmoService = new GizmoService();
    var gizmoList = await gizmoService.GetGizmosAsync(cancellationToken);
    GizmosGridView.DataSource = gizmoList;
    GizmosGridView.DataBind();
}
private void Page_Error(object sender, EventArgs e)
{
    Exception exc = Server.GetLastError();

    if (exc is TimeoutException)
    {
        // Pass the error on to the Timeout Error page
        Server.Transfer("TimeoutErrorPage.aspx", true);
    }
}

在提供的範例應用程式中,選擇 GizmosCancelAsync 連結會呼叫 GizmosCancelAsync.aspx 頁面,並展示透過逾時方式取消非同步呼叫。 因為延遲時間在隨機範圍內,你可能需要重新整理頁面幾次才能看到逾時錯誤訊息。

高並行性/高延遲網路服務呼叫的伺服器配置

要實現非同步網頁應用的好處,你可能需要對預設伺服器設定做些調整。 在配置和壓力測試你的非同步網頁應用程式時,請記住以下幾點。

  • Windows 7、Windows Vista、Windows 8 以及所有 Windows 用戶端作業系統最多有 10 個並行請求。 你需要 Windows Server 作業系統,才能在高負載下看到非同步方法的好處。

  • 請使用以下指令從升高的命令提示字元註冊 .NET 4.5 與 IIS:
    %windir%\Microsoft.NET\Framework64 \v4.0.30319\aspnet_regiis -i
    請參見 ASP.NET IIS註冊工具(Aspnet_regiis.exe)

  • 你可能需要將 HTTP.sys 隊列上限從預設的 1,000 增加到 5,000。 如果設定太低,你可能會看到 HTTP.sys 以 HTTP 503 狀態拒絕請求。 要更改 HTTP.sys 隊列上限:

    • 打開 IIS 管理員,並導向應用程式池窗格。
    • 右鍵點擊目標應用程式池,選擇 進階設定。
      網路資訊服務管理員的截圖顯示進階設定選單以紅色矩形標示。
    • 在 進階設定 對話框中,將 佇列長度 從 1,000 改為 5,000。
      進階設定對話框的截圖,顯示佇列長度欄位設為 1000,並以紅色矩形標示。

    請注意,在上方圖片中,.NET 框架標示為 v4.0,儘管應用程式池使用的是 .NET 4.5。 要理解此差異,請參考以下內容:

  • .NET 版本控制與多目標化 - .NET 4.5 是對 .NET 4.0 的原地升級

  • 如何設定 IIS 應用程式或 AppPool 使用 ASP.NET 3.5 而非 2.0 版本

  • .NET Framework 版本與相依性

  • 如果你的應用程式使用 Web 服務或 System.NET 透過 HTTP 與後端通訊,可能需要增加 connectionManagement/maxconnection 元素。 對於 ASP.NET 應用程式,autoConfig 功能限制此數為 CPU 數量的 12 倍。 這表示在四處理器中,最多只能有 12 * 4 = 48 個並發連線到一個 IP 端點。 由於這與 autoConfig 綁定,在 maxconnection ASP.NET 應用程式中增加 DefaultConnectionLimit 最簡單的方法是透過 global.asax 檔案的 from Application_Start 方法以程式方式設定 System.Net.ServicePointManager.DefaultConnectionLimit。 請參考範例下載。

  • 在 .NET 4.5 中, MaxConcurrentRequestsPerCPU 預設為 5000 應該沒問題。

貢獻者們