async/await是.NET 4.5+加入的新玩意兒。.NET 4推出的Task簡化了非同步程序的撰寫,async/await則讓程式碼簡潔度更上一層樓。如果大家對Thread、Task、aysnc、await還不熟悉,我找到兩篇還算淺顯易讀的對岸文章-async & await 的前世今生、异步编程 In .NET,文章完整涵蓋C#在多執行緒程式撰寫上的演進,從.NET 1.1到.NET 4.5,能做到的事跟背後運用機制依舊,寫法卻愈來愈簡潔,身為.NET開發人員是件幸福的事。(老鳥每每想到這些都得壓抑一下情緒,不然會變成把「唉,你們很好命囉!當年我們哪有白飯可吃,都嘛吃蕃薯籤…」掛嘴邊的碎唸老人)
這兩年我的主戰場都在前端,對async、await這些新東西仍一知半解,最近就踩了一個地雷! 在一段MVC程式搞出如下寫法:
using System.Threading.Tasks;
using System.Web.Mvc;
namespace MVC.Controllers
{
publicclass HomeController : Controller
{
public ActionResult Index()
{
var res = GetRemoteData().Result;
return Content("Result=" + res);
}
async Task<int> GetRemoteData()
{
int res = 0;
await Task.Run(() =>
{
//假裝執行某個耗時程序後取得結果
Task.Delay(1000);
res = 32767;
});
return res;
}
}
}
猜猜會發生什麼事?程式會卡死,瀏覽器永遠等不到網頁回傳Result=32767。
想簡化茶包重現程序,將同樣程式搬到Console Application執行,結果卻完全不同,Result=32767如預期顯現,毫無障礙。
看到這裡,應該有不少人跟我一樣丈二金剛摸不著腦袋(知道發生什麼事的同學請到講台領獎品,可以下課去操場玩囉),陸續讀了一些文章,才搞懂怎麼一回事。
await關鍵字必須搭配Awaitable物件使用,Task/Task<TResult>則是最常用的.NET內建Awaitable(如果有需要,你也可以自訂Awaitable類別)。當使用await關鍵字,Awaitable會自動偵測目前所處的SynchronizationContext並記錄下來,確保非同步作業完成後繼續用SynchronizationContext指定的Thread執行後續程式,這點對Window Form等受UI Thread限制的情境非常重要。
Awaitable如何決定SynchronizationContext?在Async and Await一文找到簡要說明:
口語版:
- 如果在UI Thread執行,就用當下的UI Context。
- 如果在ASP.NET Request裡執行,就使用ASP.NET Request Context。
- 若非以上情境,則使用Thread Pool Context.。
術語版:
- 當SynchronizationContext.Current不為null,就採用Current所指的Context。
(在UI及ASP.NET環境,SynchronizationContext.Current會分別指向UI Context及ASP.NET Request Context) - 若SynchronizationContext.Current為null,就使用TaskScheduler.Default。
(即Thread Pool Context)
以上差異即為「程式在Console Application執行OK,移到MVC就壞掉」結果的關鍵,我們分別在Console Application與MVC中檢測SychronizationContext,可以證實其在Console Application中Current為null:
在MVC中Current為AspNetSynchronizationContext:
那麼,為何遇到AspNetSynchronizationContext會讓程式卡死?在另一篇文章Don't Block on Async Code,我找到解答並試著依樣畫葫蘆,描繪問題爆發的過程:
- HomeController.Index()呼叫async方法GetRemoteData() (處於ASP.NET Context)
- GetRemoteData()用Task.Run()執行模擬的遠端呼叫,立即傳回還沒執行完成的Task (仍在ASP.NET Context)
- GetRemoteData() 使用await等待Task.Run裡面的程序跑完(被放了Task.Delay(1000),要跑一秒),先抓取當下的ASP.NET Context(確保Task.Run跑完的後續動作繼續用ASP.NET Context執行),await指令列以下的程式被暫緩執行,GetRemoteData()先回傳還沒跑完的Task給呼叫端。
- 呼叫端Index()使用.Result要求同步化取回結果,此舉將Block(阻擋)ASP.NET Context Thread。(用.Wait()也會Block)
- Task.Run內部的Task.Delay(1000)結束,res設為32767,Task作業完成。
- GetRemoteData()察覺await在等待的Task作業做完了,準備用ASP.NET Context的Thread處理後續作業,將結果傳回呼叫端。
- 轟!Deadlock!
Index() Block住ASP.NET Context Thread靜候GetRemoteData()傳回結果;GetRemoteData()等著用ASP.NET Context Thread處理結果傳回Index() ,偏偏該Thread已被Index() Block住動彈不得,僵持至死。
文章提到兩種解決方案:第一種是為Task.Run(() => …)加上.ConfigureAwait(false),一旦指定為false,GetRemoteData()在await Task完成後將改用ThreadPool繼續執行,不堅持使用原ASP.NET Context Thread,即可避開Deadlock。在我們的情境不需限定Thread,改用ThreadPool是OK的。
static async Task<int> GetRemoteData()
{
int res = 0;
await Task.Run(() =>
{
Task.Delay(1000);
res = 32767;
}).ConfigureAwait(false); //加設ConfigureAwait
return res;
}
第二種做法則是將Index()改為async,取資料部分由GetRermoteData().Result改為await GetRemote(),避免Block ASP.NET Context執行,改為非同步等待,也可以順利過關。
using System.Threading;
using System.Threading.Tasks;
using System.Web.Mvc;
namespace MVC.Controllers
{
publicclass HomeController : Controller
{
public async Task<ActionResult> Index()
{
var res = await GetRemoteData();
return Content("Result=" + res);
}
static async Task<int> GetRemoteData()
{
int res = 0;
await Task.Run(() =>
{
Task.Delay(1000);
res = 32767;
});
return res;
}
}
}
以上兩種方法都可避免Deadlock,而文章裡提到二者併用可以達到更好的效能及反應速度,是個好主意。(不鎖定特定Context/Thread,任由系統自動分配,有利效能最佳化)
最後補充,這類Deadlock常見於混用await及Task.Wait()/Task.Result的場合,一般建議使用await取代傳統會Block Thread的Task.Wait()/Task.Result,一方面可獲得更好的效能表現,另一方面也避免混用二者產生Deadlock,改用await的方式如同上述第二種解法所示範,先將使用Wait()/Result的函式宣告為async,再將原本的Task.Wait()改為await Task.Wait(),var res = Task.Result改為var res = await Task.Result即可。關於更多的非同步程式設計指南,推薦一篇MSDN文章-Async-Await - Best Practices in Asynchronous Programming。