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
{
    public class HomeController : Controller
    {
        public ActionResult Index()
        {
            var res = GetRemoteData().Result;
            return Content("Result=" + res);
        }
 
        async Task<int> GetRemoteData()
        {
            int res = 0;
            await Task.Run(() =>
            {
                //假裝執行某個耗時程序後取得結果
                Thread.Sleep(1000);
                res = 32767;
            });
            return res;
        }
    }
}

猜猜會發生什麼事?程式會卡死,瀏覽器永遠等不到網頁回傳Result=32767。

想簡化茶包重現程序,將同樣程式搬到Console Application執行,結果卻完全不同,Result=32767如預期顯現,毫無障礙。(註:例圖程式碼之Task.Delay(1000)請改為Thread.Sleep(1000)) 

看到這裡,應該有不少人跟我一樣丈二金剛摸不著腦袋(知道發生什麼事的同學請到講台領獎品,可以下課去操場玩囉),陸續讀了一些文章,才搞懂怎麼一回事。

await關鍵字必須搭配Awaitable物件使用,Task/Task<TResult>則是最常用的.NET內建Awaitable(如果有需要,你也可以自訂Awaitable類別)。當使用await關鍵字,Awaitable會自動偵測目前所處的SynchronizationContext並記錄下來,確保非同步作業完成後繼續用SynchronizationContext指定的Thread執行後續程式,這點對Window Form等受UI Thread限制的情境非常重要。

Awaitable如何決定SynchronizationContext?在Async and Await一文找到簡要說明:

口語版:

  1. 如果在UI Thread執行,就用當下的UI Context。
  2. 如果在ASP.NET Request裡執行,就使用ASP.NET Request Context。
  3. 若非以上情境,則使用Thread Pool Context.。

術語版:

  1. 當SynchronizationContext.Current不為null,就採用Current所指的Context。
    (在UI及ASP.NET環境,SynchronizationContext.Current會分別指向UI Context及ASP.NET Request Context)
  2. 若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,我找到解答並試著依樣畫葫蘆,描繪問題爆發的過程:

  1. HomeController.Index()呼叫async方法GetRemoteData() (處於ASP.NET Context)
  2. GetRemoteData()用Task.Run()執行模擬的遠端呼叫,立即傳回還沒執行完成的Task (仍在ASP.NET Context)
  3. GetRemoteData() 使用await等待Task.Run裡面的程序跑完(被放了Thread.Sleep(1000)要跑一秒),先抓取當下的ASP.NET Context(確保Task.Run跑完的後續動作繼續用ASP.NET Context執行),await指令列以下的程式被暫緩執行,GetRemoteData()先回傳還沒跑完的Task給呼叫端。
  4. 呼叫端Index()使用.Result要求同步化取回結果,此舉將Block(阻擋)ASP.NET Context Thread。
  5. Task.Run內部1秒等待結束,res設為32767,Task作業完成。
  6. GetRemoteData()察覺await在等待的Task作業做完了,準備用ASP.NET Context的Thread處理後續作業,將結果傳回呼叫端。
  7. 轟!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(() =>
            {
                Thread.Sleep(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
{
    public class 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(() =>
            {
                Thread.Sleep(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,var res = Task.Result改為var res = await Task<T>即可。關於更多的非同步程式設計指南,推薦一篇MSDN文章-Async-Await - Best Practices in Asynchronous Programming


Comments

# by bubu

感謝指點!

# by Moses

good

# by maybe

# by Allen

感謝大師解惑

Post a comment