close
文章出處

本文轉載自MSDN
作者:Stephen Cleary
原文地址:https://msdn.microsoft.com/en-us/magazine/dn802603.aspx

大多數有關 async/await 的在線資源假定您正在開發客戶端應用程序,但在服務器上有 async 的位置嗎?可以非常肯定地回答“有”。本文是對 ASP.NET 上異步請求的概念性概述,并提供了對最佳在線資源的引用。我不打算介紹 async 或 await 的語法;因為我已經在一篇介紹性的博客文章 (bit.ly/19IkogW) 以及一篇關于 async 最佳做法的文章 (msdn.microsoft.com/magazine/jj991977) 中介紹過了。本文將特別重點介紹 async 在 ASP.NET 上的工作原理。

對于客戶端應用程序,如 Windows 應用商店、Windows 桌面和 Windows Phone 應用程序,async 的主要優點是出色的響應能力。這些類型的應用程序使用 async 主要是為了保證用戶界面的響應能力。對于服務器應用程序,async 異步的主要優點是不錯的可擴展性。Node.js 可擴展性的關鍵是其固有的異步本質;Open Web Interface for .NET (OWIN) 針對異步進行了全新設計;ASP.NET 也可以是異步的。Async:不僅僅適用于 UI 應用程序!

同步與異步請求處理

在深入探討異步請求處理程序之前,我想簡要地回顧同步請求處理程序在 ASP.NET 上的工作原理。在本例中,假設系統中的請求依賴于一些外部資源,如數據庫或 Web API。當收到請求時,ASP.NET 將其中的一個線程池線程分配給該請求。因為它是同步編寫,所以請求處理程序將同步調用該外部資源。這將阻止請求線程,直到返回對外部資源的調用。圖 1 說明了具有兩個線程的線程池,其中有一個線程被阻止,正在等待外部資源。
images
圖1 等待同步方式對外部資源

最后,返回該外部資源的調用,并且請求線程恢復處理該請求。當完成該請求,且準備好發送響應時,請求線程將返回到線程池中。

這一切好倒是好,但是您的 ASP.NET 服務器獲得的請求總會超出它的線程能夠處理的數量。這時候,額外的請求必須等到有線程可用時才能夠運行。圖 2 說明的仍是該雙線程服務器,此時它收到三個請求。

images
圖2 雙線程服務器接收三個請求

在這種情況下,前兩個請求都被分配到線程池中的線程。每個請求都調用外部資源,于是阻止了它們的線程。第三個請求必須等待有線程可用時,才可以開始進行處理,但該請求已經在系統中。它的計時器一直在工作,它正處于發生 HTTP 錯誤 503(服務不可用)的危險之中。

但是對此考慮一下:這第三個請求正在等待可用線程,與此同時系統中的另外兩個線程實際上什么都沒做。這些線程受到阻止,都在等待返回外部調用。它們確實沒有做任何實際工作;它們不處于運行狀態,也不占用任何 CPU 時間。這些線程被白白浪費掉,但還有請求處于需要中。以下是異步請求解決的情況。

異步請求處理程序的操作方式與此不同。當收到請求時,ASP.NET 將其中的一個線程池線程分配給該請求。這一次,請求處理程序將異步調用該外部資源。當返回對外部資源的調用之前,已將此請求線程返回到線程池中。圖 3 說明當請求在異步等待外部資源時具有兩個線程的線程池。

image
圖3 異步等待對外部資源

重要的區別在于,在進行異步調用的過程中,已將請求線程返回到線程池中。當線程處于線程池中時,它不再與該請求相關聯。此時,當返回外部資源調用時,ASP.NET 將其線程池中的一個線程重新分配給該請求。該線程將繼續處理該請求。當請求完成時,該線程再次返回到線程池中。注意,對于同步處理程序,同一線程會用于該請求的整個生命周期;相反,對于異步處理程序,可以將不同的線程分配給同一請求(在不同的時間)。

現在,如果三個請求都進來,服務器就可以輕松處理了。因為每當請求在等待異步工作時,線程就會被釋放到線程池中,它們可以自由處理新的以及現有的請求。異步請求可以讓數量較少的線程去處理數量較多的請求。因此,ASP.NET 上的異步代碼的主要優點是出色的可擴展性。

為什么不增加線程池的大小?

此時,總是會被問到:為什么不增加線程池的大小?答案有兩個:與阻止線程池線程相比,異步代碼擴展得更深層且更快。

異步代碼的擴展性超過阻止線程,這是因為它使用的內存更少;在現代操作系統上每個線程池線程具有 1MB 的堆棧,外加一個不分頁的內核堆棧。這聽起來好像很多,但當您的服務器上有一大堆線程時,會發現其實不夠用。與此相反,異步操作的內存開銷要小得多。因此,使用異步操作的請求比使用阻止線程的請求面臨更少的內存壓力。異步代碼使您可以將更多的內存用于其他任務(例如緩存)。

異步代碼在速度上比阻止線程更快,因為線程池的注入速度有限。截至發稿時,該速度為每兩秒鐘一個線程。注入速度有限是件好事;它避免了持續的線程構建和破壞。然而,考慮一下請求蜂擁而至時會發生什么。同步代碼很容易就會陷入癱瘓,因為請求將用光所有可用的線程,其余請求必須等待線程池有新的線程注入。而另一方面,異步代碼不需要有這樣的限制;它是“始終開放”的,可以這么說。異步代碼能夠更出色地響應請求量突然波動。

請記住,異步代碼不會取代線程池。不應該只有線程池或異步代碼;而是要同時擁有線程池和異步代碼。異步代碼可以讓您的應用程序充分利用線程池。它使用現有的線程池,并把它提高到 11。

線程執行異步工作怎么樣?

我一直被人問到這個問題。這意味著,必須有一些線程阻止對外部資源進行 I/O 調用。因此,異步代碼釋放請求線程,但這只能以犧牲系統中另一個線程為代價吧?沒有,一點關系也沒有。

要了解異步請求為什么擴展,我將跟蹤一個異步 I/O 調用的(簡化)示例。假設有一個請求需要寫入到文件中。請求線程調用異步寫入方法。WriteAsync 由基類庫 (BCL) 實現,并使用其異步 I/O 的完成端口。因此,WriteAsync 調用會作為異步文件寫入傳遞到 OS 中。然后,OS 與驅動程序堆棧進行通信,同時傳遞數據以寫入到 I/O 請求數據包 (IRP) 中。

現在,有趣的事情發生了:如果設備驅動程序不能立即處理 IRP,就必須異步進行處理。因此,驅動程序告訴磁盤開始寫入,并將“掛起”響應返回到 OS 中。OS 將“掛起”響應傳遞到 BCL,然后 BCL 將一個不完整的任務返回到請求處理代碼。請求處理代碼等待將不完整的任務從該方法等處返回的任務。最后,請求處理代碼最終向 ASP.NET 返回一個不完整的任務,并且請求線程被釋放回線程池中。

現在,考慮系統的當前狀態。已經分配了各種 I/O 結構(例如,任務實例和 IRP),而且它們都處在掛起/不完整的狀態。然而,沒有任何線程因等待寫入操作完成而受到阻止。ASP.NET、BCL、OS 以及設備驅動程序都沒有專門用于異步工作的線程。

當磁盤完成寫入數據時,它通過中斷通知其驅動程序。該驅動程序會通知 OS 該 IRP 已經完成,并且 OS 會通過完成端口通知 BCL。線程池線程通過完成從 WriteAsync 返回的任務來響應該通知;這反過來又恢復異步請求代碼。在該完成通知階段中,短期“借用”了一些線程,但實際上沒有線程在寫入過程中受到阻止。

本示例經過極大地簡化,但是要點突出:真正的異步工作并不需要線程。實際推送字節也無需占用 CPU 時間。還有一個輔助課程要了解。考慮一下在設備驅動程序的世界里,設備驅動程序如何做才能立即或異步處理 IRP。同步處理是不是一個選項。在設備驅動程序級別,所有重要的 I/O 都是異步的。許多開發人員的思維模式都是把用于 I/O 操作的“普通 API”認為是同步的,異步 API 作為一層建立在普通的同步 API 上。然而,這恰恰相反:實際上,普通 API是異步的;使用異步 I/O 實現的是正是同步 API!

為什么沒有了異步處理程序?

如果異步請求處理是如此完美,那它為什么還不可用?事實上,異步代碼非常適合擴展,因此從 Microsoft .NET Framework 形成之初到現在,ASP.NET 平臺一直支持異步處理程序和模塊。ASP.NET 2.0 引入了異步網頁,ASP.NET MVC 2 中 MVC 得到了異步控制器。

然而,最近,異步代碼在編寫上總是有些問題,并且難于維護。許多公司便決定同步開發代碼、支付更大的服務器場或更昂貴的托管,這樣就會簡單一些。現在,出現了逆轉:在 ASP.NET 4.5 中,使用 async 和 await 的異步代碼幾乎與編寫同步代碼一樣簡單。由于大型系統遷移到云托管并要求更加有規模,越來越多的公司開始青睞 ASP.NET 上的 async 和 await。

異步代碼不是靈丹妙藥

異步請求處理盡管很強大,但它不會解決所有問題。關于 ASP.NET 上的 async 和 await 可以做什么的問題,存在一些常見的誤解。

當一些開發人員了解 async 和 await 后,他們認為這是服務器代碼“讓步”于客戶端(例如瀏覽器)的一種方式。然而,ASP.NET 上的 async 和 await 只“讓步”于 ASP.NET 運行時;HTTP 協議保持不變,您對每個請求仍只有一個響應。如果在 async/await 之前您需要 SignalR 或 AJAX 或 UpdatePanel,那么在 async/await 之后仍然需要 SignalR 或 AJAX 或 UpdatePanel。

使用 async 和 await 的異步請求處理可以幫助擴大您的應用程序規模。然而,這是在一臺服務器上的擴展;您可能仍然需要對擴展進行規劃。如果您確實需要擴展體系結構,將仍然需要考慮無狀態的冪等請求和可靠的隊列。Async/await 多少有所幫助:它們使您能夠充分利用服務器資源,所以您不需要經常進行擴展。但是,如果您確實需要向外擴展,您將需要一個合適的分布式體系結構。

ASP.NET 上的 async 和 await 都是關于 I/O 的。它們非常適合讀取和寫入文件、數據庫記錄和 REST API。然而,它們不能很好地執行占用大量 CPU 的任務。您可以通過等待 Task.Run 開始一些背景工作,但這樣做沒有任何意義。事實上,通過啟發式干擾 ASP.NET 線程池會損害您的可擴展性。如果您要在 ASP.NET 上執行占用大量 CPU 的工作,最好的辦法是直接在請求線程上執行該工作。通常,不要將工作排隊送到 ASP.NET 上的線程池。

最后,在整體上考慮系統的可擴展性。十年前,常見的體系結構要有一個可與后端的 SQL Server 數據庫進行通信的 ASP.NET Web 服務器。在這種簡單的體系結構中,通常數據庫服務器是可擴展性的瓶頸,而不是 Web 服務器。讓您的數據庫調用異步可能起不到幫助作用;當然您可以用它們來擴展 Web 服務器,但數據庫服務器將阻止整個系統的擴展。

Rick Anderson 在他精彩的博客文章中針對異步數據庫調用給出案例,“我的數據庫調用應該是異步的嗎?”(bit.ly/1rw66UB)。以下是兩點支持論據:首先,異步代碼有難度(因而開發人員的時間成本比只是購買較大的服務器要高);其次,如果數據庫后端是瓶頸,那么擴展 Web 服務器沒有什么意義。在寫這篇文章時,這兩方面的論據非常有道理,但隨著時間的推移這兩個論據的意義已經慢慢弱化。首先,使用 async 和 await 編寫異步代碼更加容易了。其次,隨著全球逐步采用云計算,網站的數據后端逐漸得到擴展。諸如 Microsoft Azure SQL 數據庫、NoSQL 以及其他 API 之類的現代后端與單個 SQL Server 相比可以得到更進一步的擴展,從而將瓶頸又推回 Web 服務器。在這種情況下,async/await 可以通過擴展 ASP.NET 帶來巨大的優勢。

在開始之前

首先您需要知道只有 ASP.NET 4.5 支持 async 和 await。有一個叫做 Microsoft.Bcl.Async 的 NuGet 程序包可為 .NET Framework 4 啟用 async 和 await,但并不使用它;這將無法正常工作!其原因是,為了能與 async 和 await 更好地一起工作,ASP.NET 本身必須更改其管理異步請求處理的方式;NuGet 程序包中包含編譯器需要的所有類型,但不會修補 ASP.NET 運行時。沒有解決方法;您需要 ASP.NET 4.5 或更高版本。

接下來,要知道,ASP.NET 4.5 在服務器上引入了“quirks 模式”。如果您創建一個新的 ASP.NET 4.5 項目,則不必擔心。但是,如果要將現有的項目升級到 ASP.NET 4.5,所有 quirk 都將被打開。我建議您​​通過編輯 web.config 并將 httpRuntime.targetFramework 設置為 4.5 把它們全部關閉。如果使用此設置的應用程序失敗(并且您不想花時間去修復它),至少您可以通過為 aspnet:UseTaskFriendlySynchronizationContext 的 appSetting 鍵添加值“true”來獲取 async/await 工作。如果您將 httpRuntime.targetFramework 設置為 4.5,則 appSetting 鍵不必要。Web 開發團隊已在 bit.ly/1pbmnzK 發表一篇關于這一新的“quirks 模式”的詳細信息的博客。提示: 如果您看到出現奇怪的行為或例外情況,并且您的調用堆棧包括 LegacyAspNetSynchronizationContext,那么您的應用程序正在這個“quirks 模式”下運行。LegacyAspNetSynchronizationContext 與異步不兼容;您在 ASP.NET 4.5 上需要常規的 AspNetSynchronizationContext。

在 ASP.NET 4.5 中,所有的 ASP.NET 設置都針對異步請求設置了很好的默認值,但也有幾個其他設置您可能要更改。首先是 IIS 設置:考慮將 IIS/HTTP.sys 的隊列限制(應用程序池|高級設置|隊列長度)從默認的 1,000 提高到 5,000。另一個是 .NET 運行時設置:ServicePointManager.DefaultConnectionLimit,它的默認值是內核數量的 12 倍。DefaultConnectionLimit 限制到同一主機名的傳出連接數。

關于中止請求的提示

當 ASP.NET 同步處理一個請求時,它有一個非常簡單的機制可以中止請求(例如,如果請求超出其超時值):它會中止該請求的工作線程。這是有道理的,因為在同步領域,每個請求從開始到結束都使用同一個工作線程。中止線程對于 AppDomain 的長期穩定性而言尚不完美,因此默認情況下 ASP.NET 將定期回收您的應用程序,以保持干凈。

對于異步請求,如果要中止請求,ASP.NET 并不會中止工作線程。相反,它會取消使用 CancellationToken 的請求。異步請求處理程序應該接受并遵守取消標記。大多數較新的框架(包括 Web API、MVC 和 SignalR)將構建并直接向您傳遞 CancellationToken;您需要做的就是把它聲明為一個參數。您也可以直接訪問 ASP.NET 標記;例如,HttpRequest.TimedOutToken 是當請求超時時取消的一個 CancellationToken。

隨著應用程序遷移到云,中止請求就顯得更為重要。基于云的應用程序也越來越依賴于可能占用任意時間量的外部服務。例如,一種標準模式是使用指數回退來重試外部請求;如果您的應用程序依賴于類似這樣的多種服務,對您的請求處理在整體上應用一個超時上限不失為一個好方法。

Async 支持的現狀

針對 async 的兼容性問題,已對許多庫進行了更新。在版本 6 中已將 async 支持添加到實體框架(在 EntityFramework NuGet 程序包中)。不過,當以異步方式運行時,您必須要小心操作以避免延遲加載,因為延遲加載總是以同步方式執行。HttpClient(在 Microsoft.Net.Http NuGet 程序包中)是采用 async 理念設計而成的現代 HTTP 客戶端,是調用外部 REST API 的理想選擇;是 HttpWebRequest 和 WebClient 的現代版替代品。在 2.1 版本中,Microsoft Azure 存儲客戶端庫(在 WindowsAzure.Storage NuGet 程序包中)添加了異步支持。

較新的框架(如 Web API 和 SignalR)對 async 和 await 提供全面的支持。個別 Web API 已圍繞 async 支持建立起整個管道:不僅有異步控制器,還有異步篩選器和處理程序。Web API 和 SignalR 有一個很平凡的異步故事:您可以“放手去做”然后“就會成功”。

這給我們帶來了一個令人傷感的故事:如今,ASP.NET MVC 只是部分支持 async 和 await。有基本的支持——異步控制器的操作和取消工作正常。ASP.NET 網站上有關于如何使用 ASP.NET MVC 中的異步控制器操作的精彩教程 (bit.ly/1m1LXTx);這對于 MVC 上的 async 入門是絕佳的資源。不幸的是,ASP.NET MVC (目前)不支持異步篩選器 (bit.ly/1oAyHLc) 和異步子操作 (bit.ly/1px47RG)。

ASP.NET Web 窗體是一個較舊的框架,但它也充分支持 async 和 await。并且,ASP.NET 網站上有關異步 Web 窗體的教程也是入門的絕佳資源 (bit.ly/Ydho7W)。有了 Web 窗體,異步支持可以選擇加入。您必須先將 Page.Async 設置為 true,然后您可以使用 PageAsyncTask 通過該頁面注冊異步工作(或者,您可以使用 async void 事件處理程序)。PageAsyncTask 也支持取消。

如果您有一個自定義 HTTP 處理程序或 HTTP 模塊,那么 ASP.NET 現在也可以支持它們的異步版本。HTTP 處理程序是通過 HttpTaskAsyncHandler (bit.ly/1nWpWFj) 進行支持的,HTTP 模塊是通過 EventHandlerTaskAsyncHelper (bit.ly/1m1Sn4O) 進行支持的。

截至發稿時,ASP.NET 團隊正在開發名為 ASP.NET vNext 的一個新項目。在 vNext 中,默認情況下整個管道是異步的。目前,該計劃將 MVC 和 Web API 合并到能夠全面支持 async/await(包括異步篩選器和異步視圖組件)的單一框架中。其他異步就緒框架(如 SignalR)會在 vNext 中找到一個自然的家。當然,未來是 async 的天下。

尊重安全網

ASP.NET 4.5 中引入了幾個新的“安全網”,幫助您捕捉應用程序中的異步問題。這些是默認情況下存在的,應當保留。

當同步處理程序試圖執行異步工作時,您的 InvalidOperationException 會收到這樣的消息,“此時不能開始異步操作”。有兩個主要原因導致出現此異常。第一,Web 窗體頁有異步事件處理程序,但忽略了將 Page.Async 設置為 true。第二,同步代碼調用 async void 方法。這是也是避免 async void 的另一個原因。

另一個安全網適用于異步處理程序:當異步處理程序完成請求,但 ASP.NET 檢測到異步工作尚未完成時,您的 InvalidOperationException 會收到這樣的消息,“異步模塊或處理程序已完成,但異步操作仍然處于掛起狀態”。這通常是由于異步代碼調用 async void 方法而導致的,但也可能是因為不當使用基于事件的異步模式 (EAP) 組件 (bit.ly/19VdUWu)。

您還可以使用一個選項來關閉這兩個安全網:HttpContext.AllowAsyncDuringSyncStages(也可以在 web.config 中對它進行設置)。Internet 上的一些頁面建議您在看到這些異常時進行這樣的設置。我完全不同意。說真的,我不知道這怎么可行。禁用安全網是一個可怕的想法。我能夠想到的唯一可能的原因是,您的代碼可能已經進行了一些非常先進的異步處理(遠超我曾經嘗試過的范圍),您是一個多線程處理的天才。所以,如果您已經閱讀了整篇文章,邊打著呵欠邊想,“拜托,我可不是菜鳥”,那么我想你可以考慮禁用安全網。而對于我們中的其他人,這是一個非常危險的選項,除非您完全知曉后果,否則不應進行此設置。

開始使用

終于到最后了!準備好開始使用 async 和 await 了嗎?我很欣賞您的耐心。

首先,查看本文的“異步代碼不是靈丹妙藥”一節以確保 async/await 對您的體系結構是有益的。接下來,將您的應用程序更新到 ASP.NET 4.5 并關閉 quirks 模式(此時若只為確保不發生中斷,運行它也可以)。這時,您便可以開始真正的同步/等待工作了。

從“葉”開始。想想您的請求如何進行處理并標識任何基于 I/O 的操作,特別是基于網絡的操作。常見的示例是數據庫查詢和命令,以及對其他 Web 服務和 API 的調用。選擇一個來開始,并做一些調查來查找使用 async/await 執行該操作的最佳選擇。.NET Framework 4.5 中有許多內置 BCL 類型目前都已異步就緒;例如,SmtpClient 具有 SendMailAsync 方法。某些類型可以提供異步就緒更換;例如,HttpWebRequest 和 Web 客戶端可以用 HttpClient 來更換。如需要,請升級您的庫版本;例如,EF6 中的實體框架具有異步兼容方法。

但是,要避免庫中的“假異步”。假異步是這樣一種現象:一個組件中具有一個異步就緒 API,而它只是通過將同步 API 封裝在線程池線程內來實現的。這對于實現 ASP.NET 上的可擴展性適得其反。假異步的一個典型示例就是 Newtonsoft JSON.NET,一個其他方面很出色的庫。最好不調用(假)異步版本來執行 JSON 的序列化;只需換做調用同步版本即可。假異步的一個棘手示例就是 BCL 文件流。當打開一個文件流時,必須以顯式方式打開以便于異步訪問;否則,它會使用假異步,同步阻止文件讀取和寫入操作中的線程池線程。

選擇一個“葉”之后,就可以開始使用代碼中調用該 API 的方法,使之成為通過等待調用異步就緒 API 的異步方法。如果您調用的 API 支持 CancellationToken,您的方法應該采用 CancellationToken 并將其傳遞給 API 方法。

只要將一個方法標記為異步,就應該更改其返回類型:void 變為“Task”,非 void 類型 T 變為“Task”。您會發現,所有該方法的調用者都需要變為異步,以使它們能夠等待任務,等等。此外,將 Async 附加到您方法的名稱中,以遵循基于任務的異步模式約定 (bit.ly/1uBKGKR)。

允許 async/await 模式將您的調用堆棧向“Trunk”進行擴展。在 Trunk 中,您的代碼將與 ASP.NET 框架(MVC、Web 窗體,Web API)相連接。閱讀本文前面所述的“異步支持的現狀”一節中的相關教程,將您的異步代碼與框架進行集成。

順便找出任何本地線程的狀態。由于異步請求可能會更改線程,所以本地線程狀態(如 ThreadStaticAttribute、ThreadLocal、線程數據插槽和 CallContext.GetData/SetData)將不可用。如果可能的話,請使用 HttpContext.Items 進行更換;或者您可以將不可變的數據存儲在 CallContext.LogicalGetData/LogicalSetData 中。

以下是我發現的有用技巧:您可以(暫時)復制您的代碼來創建一個垂直分區。利用這種技術,您不用將同步方法更改為異步;可以復制整個同步方法,然后將副本改為異步。然后,您可以讓大多數應用程序保持使用同步方法,只創建異步的一個小垂直片即可。如果您想把異步作為概念證明來進行探索或只針對應用程序的一部分執行負載測試來體驗怎樣擴展您的系統,這會是一個很棒的主意。您可以具有一個完全異步的請求(或頁面),而應用程序的其余部分保持為同步。當然,您不希望對每一個方法都保留副本;最終,所有的 I/O 綁定代碼都將是異步的,可以刪除同步副本。

總結

我希望本文能夠幫助您了解 ASP.NET 上的異步請求的基礎概念。使用 async 和 await,可以使編寫能夠最大限度地利用其服務器資源的 Web 應用程序、服務和 API 變得比以往任何時候都更容易。Async 真是太棒了!


Stephen Cleary 生活在密歇根州北部,他是一位丈夫、父親和程序員。他 16 年來一直從事多線程處理和異步編程的工作,自從第一個社區技術預覽版出現以來,他就一直在 Microsoft .NET Framework 中使用異步支持。他的主頁(包括博客)位于 stephencleary.com。

衷心感謝以下 Microsoft 技術專家對本文的審閱:James McCaffrey


文章列表


不含病毒。www.avast.com
arrow
arrow
    全站熱搜

    AutoPoster 發表在 痞客邦 留言(0) 人氣()