之前只知道在同步方法中調用非同步(async)方法時,如果用.Result等待調用結果,會造成線程死結(deadlock)。
昨天一個偶然的情況,造成在同步方法中調用了async方法,並且沒有使用.Result,結果造成整個ASP.NET應用程式的崩潰,見識了同步/非同步水火難容的厲害。
當時的情況是這樣的,發布了一個經過非同步化改造的ASP.NET程式,其中有這樣一個同步方法:
public static void Notify(string title, string content, int recipientId)
{
//...
}
被改造為非同步方法呼叫:
public static async Task Notify(string title, string content, int recipientId)
{
//await ...
}
之前在WebForms(.aspx)中是這樣同步調用它的:
<script runat="server">
void Page_Load(Object sender, EventArgs e)
{
//...
MsgService.Notify(title, body, userId);
//...
}
</script>
現在改為在MVC Controller Action中非同步呼叫它:
public class ApplyController : Controller
{
[HttpPost]
public async Task<string> Pass()
{
//...
await MsgService.Notify(title, body, userId);
//...
}
}
這次發布就是為了用MVC取代WebForms,但發布時同步調用Notify()方法的.aspx檔案沒有從伺服器上刪除。
發布後,這個ASP.NET程式跑一會就崩潰(crash),具體表現為:
a)訪問網站出現503錯誤;
b)IIS管理器中顯示對應的應用程式集區處於停止狀態;
c)在Windows事件記錄中發現以下三個錯誤:
日誌1:
發生了未處理的例外狀況,已終止進程。
Application ID: /LM/W3SVC/15/ROOT
Process ID: 23808
Exception: System.NullReferenceException
Message: 未將對象引用設定到對象的執行個體。
StackTrace:
在 System.Web.ThreadContext.AssociateWithCurrentThread(Boolean setImpersonationContext)
在 System.Web.HttpApplication.OnThreadEnterPrivate(Boolean setImpersonationContext)
在 System.Web.LegacyAspNetSynchronizationContext.CallCallbackPossiblyUnderLock(SendOrPostCallback callback, Object state)
在 System.Web.LegacyAspNetSynchronizationContext.CallCallback(SendOrPostCallback callback, Object state)
在 System.Threading.Tasks.AwaitTaskContinuation.RunCallback(ContextCallback callback, Object state, Task& currentTask)
--- 引發異常的上一位置中堆疊追蹤的末尾 ---
在 System.Threading.Tasks.AwaitTaskContinuation.<ThrowAsyncIfNecessary>b__1(Object s)
在 System.Threading.ExecutionContext.RunInternal(ExecutionContext executionContext, ContextCallback callback, Object state, Boolean preserveSyncCtx)
在 System.Threading.ExecutionContext.Run(ExecutionContext executionContext, ContextCallback callback, Object state, Boolean preserveSyncCtx)
在 System.Threading.QueueUserWorkItemCallback.System.Threading.IThreadPoolWorkItem.ExecuteWorkItem()
在 System.Threading.ThreadPoolWorkQueue.Dispatch()
日誌2:
應用程式: w3wp.exe
Framework 版本: v4.0.30319
說明: 由於未處理的例外狀況,進程終止。
異常資訊: System.NullReferenceException
堆棧:
在 System.Threading.Tasks.AwaitTaskContinuation.<ThrowAsyncIfNecessary>b__1(System.Object)
在 System.Threading.ExecutionContext.RunInternal(System.Threading.ExecutionContext, System.Threading.ContextCallback, System.Object, Boolean)
在 System.Threading.ExecutionContext.Run(System.Threading.ExecutionContext, System.Threading.ContextCallback, System.Object, Boolean)
在 System.Threading.QueueUserWorkItemCallback.System.Threading.IThreadPoolWorkItem.ExecuteWorkItem()
在 System.Threading.ThreadPoolWorkQueue.Dispatch()
日誌3:
Faulting application name: w3wp.exe, version: 7.5.7601.17514, time stamp: 0x4ce7afa2
Faulting module name: KERNELBASE.dll, version: 6.1.7601.18798, time stamp: 0x5507b87a
Exception code: 0xe0434352
Fault offset: 0x000000000001aaad
Faulting process id: 0x5d00
Faulting application start time: 0x01d0b86f3af9058e
Faulting application path: c:\windows\system32\inetsrv\w3wp.exe
Faulting module path: C:\Windows\system32\KERNELBASE.dll
Report Id: 7bec0e6c-2462-11e5-b24e-c43d8baaa802
從日誌資訊看,問題肯定是非同步引起的,於是檢查所有進行非同步呼叫的代碼,沒發現問題(唯獨沒有檢查那個以為不在使用、沒有刪除的.aspx檔案)。
後來才想到那個沒有刪除的.aspx檔案,可是它已經被MVC取代了,沒在使用啊。如果是它引起的,只有一個可能。。。這個檔案依然在被某些請求訪問。仔細排查後發現原來是引用js的地方沒加hash參數,造成有些用戶端瀏覽器由於緩衝的原因還在使用舊版的js,舊版的js還會向這個.aspx檔案發出ajax請求。
原來是一個疏忽造成了在同步方法中直接調用非同步方法呼叫,但怎麼也沒想到竟然有如此大的威力,能引起整個應用程式的崩潰,於是好奇心被激發。
看了網上的一些資料後,對這個問題有了一些認識。
在ASP.NET中(ASP.NET天生是多線程的,基於線程池的,沒有UI線程的概念),如果你調用了一個async方法,如果有await相伴,當前線程立馬被釋放回線程池,線程的上下文資訊(比如reqeust context)被儲存;如果沒有await相伴(也沒有其他的wait代碼),調用async方法之後,代碼會繼續往下執行,直至完成,當前線程被釋放回線程池,線程的上下文資訊不會被儲存。當async中的非同步任務完成後(註:非同步任務不是在另外一個線程中完成的,是在一個狀態機器中完成的),會從線程池中取出一個線程繼續執行,執行時會讀取當時調用它的原線程的上下文資訊(預設情況下的行為,如果ConfigureAwait(false) ,就沒有這一步操作),如果當初調用時沒有使用await,線程的上下文資訊沒有被儲存,這時就會引發NullReferenceException。而在這種層級發生的未處理null引用異常,會引發整個應用程式崩潰,更準確地說是應用程式所在的進程崩潰。因為這樣的異常實在太危險,為了不讓一隻老鼠壞了一鍋湯,只能被犧牲。
所以,如果不想被犧牲,要麼老老實實地await;要麼告訴async方法,不要讀取原線程的上下文資訊(ConfigureAwait(false),未經實際驗證是否有效);要麼調用async方法的線程沒有需要儲存的上下文資訊,比如在Task.Run(或Task.Factory.StartNew)中調用async方法,也就是用一個新的線程調用async方法。
C#的async和await
async/task/await三組合是.NET Framework 4.5帶給.NET開發人員的大禮,合理地使用它,可以提高應用程式的吞吐能力。
但是如果不正確使用,會帶來意想不到的問題——比如await之後一直在等待。
先看一段ASP.NET MVC範例程式碼:
public class BlogController : Controller
{
public async Task<ActionResult> AwaitDemo()
{
var responseHtml = GetResponseHtml("http://www.cnblogs.com/");
return Content(responseHtml);
}
private string GetResponseHtml(string url)
{
return GetResponseContentAsync(url).Result;
}
private async Task<string> GetResponseContentAsync(string url)
{
var httpClient = new System.Net.Http.HttpClient();
var response = await httpClient.GetAsync(url);
if (response.StatusCode == System.Net.HttpStatusCode.OK)
{
return await response.Content.ReadAsStringAsync();
}
else
{
return "error";
}
}
}
代碼說明:
在上面的代碼中,雖然在Action方法之前加了async Task<ActionResult>,但由於在方法體中沒有使用await,所以實際還是以同步的方式執行的,與直接使用ActionResult是一樣的。
GetResponseHtml是同步方法,GetResponseContentAsync是非同步方法呼叫,在GetResponseHtml中調用了非同步GetResponseContentAsync。(如果調用的是第三方程式集,我們就不知道在GetResponseHtml中進行了非同步呼叫,所以這個方法的設計是有問題的)
這段代碼執行結果會是怎樣呢?
——結果就是沒有結果,一直在執行。。。
(註:如果在控制台應用程式中調用同樣的GetResponseHtml,不會出現這個問題)
那如果解決這個問題呢:
解決方案一:在MVC Action中開啟一個Task進行await
public async Task<ActionResult> AwaitDemo()
{
var responseHtml = await Task.Factory.StartNew(() =>
GetResponseHtml("http://www.cnblogs.com/"));
return Content(responseHtml);
}
解決方案二:將GetResponseHtml變成非同步方法呼叫
public async Task<ActionResult> AwaitDemo()
{
var responseHtml = await GetResponseHtml("http://www.cnblogs.com/");
return Content(responseHtml);
}
private async Task<string> GetResponseHtml(string url)
{
return await GetResponseContentAsync(url);
}
顯然,第2個解決方案是更好的。
所以,我們在設計一個方法(method)時,如果調用了async方法,一定要將這個方法本身設計為async的。不然,別人調用時就一直等。