2019年9月9日 星期一

ASP.NET MVC 非同步程式設計,發生無法更新 ViewBag 的問題探討

ASP.NET MVC 非同步程式設計,發生無法更新 ViewBag 的問題探討

當在 ASP.NET MVC 專案下進行非同步程式設計的時候,相信很多人會遇到一些詭異的事情,這是因為程式碼使用非同不方式進行設計,這與傳統的同步方式設計產生了比較難以除錯的現象,想要徹底了解這些問題是如何產生的以及日後可以使用正確的方式來解決此一問題,就需要了解隱藏在背後的運作原理。

了解更多關於 [使用 async 和 await 進行非同步程式設計] 的使用方式


在這裡請先使用 ASP.NET MVC 專案樣本建立起一個 ASP.NET MVC 的專案,接著,打開 HomeController.cs 檔案,找到 Index() 這個 Action,將其方法使用底下程式碼來替換;在這裡個程式碼中,將會使用 WebClient 這個類別所產生的執行個體,進行非同步的遠端 Web 呼叫 ,這裡將會透過 wc.DownloadStringAsync() 方法呼叫來啟動非同步執行作業,而該 URL 將會把 99 與 87 進行加總計算,並請等候 2 秒鐘之後,才會結束這次的非同步作業。
當 WebClient 完成非同步作業之後,將會觸發 DownloadStringCompleted 事件,在底下的程式碼中,將會使用 Lambda 匿名委派方法來訂閱該事件,所以,便會執行該 Lambda 內的程式碼,這裡將會看到會執行 ViewBag.Name = "OK" + e.Result; 這個敘述,這個 ViewBag.Name 將會在這個 View 中顯示出來。
為了避免這個 WebClient 非同步作業尚未結束之前,就直接呼叫 return View(); 敘述,把這個 Action 相對應的 View 顯示出來,所以在呼叫 wc.DownloadStringAsync() 方法之後,會採用輪詢的方式來檢查 WebClient 的非同步作業是否已經完成了,若沒有完成,將會每 30 ms 進行輪詢檢查 WebClient.IsBusy 這個屬性值。
C Sharp / C#
public async Task<ActionResult> Index()
{
    WebClient wc = new WebClient();

    wc.DownloadStringCompleted += (s, e) =>
    {
        ViewBag.Name = "OK" + e.Result;
    };
    wc.DownloadStringAsync(new Uri("https://lobworkshop.azurewebsites.net/api/RemoteSource/Add/99/87/2"));
    while (wc.IsBusy)
    {
        Thread.Sleep(30);
    }
    return View();
}
另外,請打開這個 Action 相對應的 View,也就是 Index.cshtml 這個檔案,將 <p>@Html.Raw(ViewBag.Name)</p> 標記宣告內容,加入此 Index.cshtml 檔案內
HTML
<div class="jumbotron">
    <h1>ASP.NET</h1>
    <p>@Html.Raw(ViewBag.Name)</p>
    <p class="lead">ASP.NET is a free web framework for building great Web sites and Web applications using HTML, CSS and JavaScript.</p>
    <p><a href="https://asp.net" class="btn btn-primary btn-lg">Learn more &raquo;</a></p>
</div>

開始執行有問題的專案

現在請執行這個 ASP.NET MVC 專案,當首頁顯示在瀏覽器之後,會發現到並沒有看到 ViewBag.Name 顯示在螢幕上。

了解發生了甚麼問題

若針對這個 Index 方法設定中斷點,並且進行除錯,你將會發現到當 WebClient 完成非同步作業的時候,wc.IsBusy 的屬性值將會為 false,這代表了這個無窮迴圈將會停止執行,接下來將會直接執行 return View() 敘述,這樣就會直接顯示該 View 的內容。
可是,這個時候 WebClient 執行個體才要準備進行執行 DownloadStringCompleted 所訂閱的事件方法,並且更新 ViewBag 的內容,可是,這個時候該系統已經正在顯示完成 View 的網頁內容,理所當然是看不到該網頁上有 ViewBag.Name 的內容顯示出來。

暫緩一段時間才要來執行 return View()

既然 WebClient 的完成 callback 要呼叫之前,就已經執行了 return View() 敘述,而造成問題,此時,這個非同步 Web API 的呼叫大約需要2秒鐘的時間,因此,將程式碼修改成為底下的內容,應該就不會有問題了吧。
這裡在無窮迴圈之後,加入這個敘述 Thread.Sleep(3000); 也就是強制讓這個 HTTP 請求執行緒 (HTTP Request Thread) 休息 3 秒鐘的時間,讓 WebClient 可以有足夠的時間來觸發其 DownloadStringCompleted 事件的委派方法,並且將 ViewBag.Name 的物件值設定進去,之後,才會執行 return View() 敘述,讓 ASP.NET MVC 顯示出 View 的內容,這樣總沒有問題了吧。
可是,當實際再度執行這個專案的時候,卻發現,不論 Thread.Sleep 的休息時間值設定為多少,都不會正常將 WebClient 呼叫 API 取得的結果顯在螢幕上,並且當休息時間設定越久,將會造成整個網頁要顯示出來的時間也越久。這到底是哪裡出了問題呢?
C Sharp / C#
public async Task<ActionResult> Index()
{
    WebClient wc = new WebClient();

    wc.DownloadStringCompleted += (s, e) =>
    {
        ViewBag.Name = "OK" + e.Result;
    };
    wc.DownloadStringAsync(new Uri("https://lobworkshop.azurewebsites.net" +
             $"/api/RemoteSource/Add/99/87/2"));
    while (wc.IsBusy)
    {
        Thread.Sleep(30);
    }

    Thread.Sleep(3000);
    return View();
}

了解 WebClient 的 DownloadStringCompleted 行為

其實,實際的原因那就是若呼叫 WebCleint.DownloadStringAsync 的時候,此時的執行緒(當然這個時候的執行緒是當時 HTTP Request 所配置使用的執行緒) 將會使用到 SynchronizationContext 這個技術,這裡可以在 呼叫 WebCleint.DownloadStringAsync 方法前來檢查 SynchronizationContext.Current 這個屬性值是否為空值,若為空值,則表示這個執行緒沒有使用到 SynchronizationContext。根據 ASP.NET MVC 開發框架預設的規劃,每次當使用者對該網站請求一個 HTTP Request 請求的時候,IIS 就會配置一個執行緒來執行此次的 HTTP Request 的請求,而在這個執行緒內將會產生出一個 SynchronizationContext 物件。
好,那這樣會有甚麼問題呢?
會產生這樣的問題那是因為 當 WebClient.DownloadStringCompleted 事件被觸發的時候,將不會直接使用一個新的執行緒來執行這個委派方法,而是因為呼叫 WebClient.DownloadStringAsync 方法的時候,因為偵測到這個執行緒內的 SynchronizationContext.Current 有配置一個物件,所以,會先把這個物件狀態儲存取來,當完成非同步作業之後,會取出 SynchronizationContext ,並且把訂閱事件的委派方法,傳送到 SynchronizationContext 內的工作單元 Unit of Work 內的佇列來排隊等候執行;而在 SynchronizationContext 當初設計的機制內,同一個時間僅會執行一個委派方法,然而,此時,該 HTTP Request 執行緒內因為還在執行,所以,就需要等到這個執行緒執行完畢之後,才會接著從 SynchronizationContext 工作單元佇列中,找到下一個要執行的委派方法,也就是 WebClient.DownloadStringCompleted 所指定的訂閱委派方法,接著來繼續執行。
所以,在剛剛所設計的程式碼中,使用了 Thread.Sleep(3000) 暫停了 3秒鐘的時間,這也代表了這個 HTTP Request 執行緒需要暫時停止 3 秒鐘的執行,當然,對於所訂閱的委派方法,也需要在 3 秒鐘之後才能夠繼續執行,不過,在休息 3秒鐘之後,就會直接執行 return View(); 敘述,當然,View 也就已經產生出來了,就算這個時候當初事件訂閱的委派發法開始要執行了,也不會影響到已經產生 View 網頁內容的結果了。

使用 WebClient TPL API 來替換掉 EAP 的事件 API

請查閱 WebClient API 文件,將會發現到 WebClient 有提供同樣呼叫 Web API 方法的 API,不過,使用的是 TPL 的方式,那就是 DownloadStringTaskAsync,這個方法將會回傳一個 Task 物件,此時,可以透過這個工作物件可以使用 await 運算子來等候呼叫 API 的回傳字串內容,如底下的程式碼所顯示的做法。
C Sharp / C#
public async Task<ActionResult> Index()
{
    WebClient wc = new WebClient();

    var task = wc.DownloadStringTaskAsync(new Uri("https://lobworkshop.azurewebsites.net" +
    $"/api/RemoteSource/Add/99/87/2"));
    string result = await task;
    ViewBag.Name = "OK" + result;

    return View();
}
當然,也可以使用底下的作法,當取得 WebClient 呼叫 Web API 的回傳的字串,是可以透過另外一個執行緒將回傳字串設定到 ViewBag.Name,這樣的作法將會與上面的做法得到相同的網頁內容。
C Sharp / C#
public async Task<ActionResult> Index()
{
    WebClient wc = new WebClient();

    var task = wc.DownloadStringTaskAsync(new Uri("https://lobworkshop.azurewebsites.net" +
    $"/api/RemoteSource/Add/99/87/2"));
    string result = await task;
    await Task.Run(() => ViewBag.Name = "OK" + result);

    return View();
}
然而,若將程式碼改寫成底下的方式,對於 task 這個工作物件,不是使用 await 運算子來等待,而是使用 Wait() 方法來進行封鎖式的等候,此時,將會看到悲慘的現象,那就是整個網頁凍結了,瀏覽器一直在轉圈圈,無法將網頁顯示出來,也就是不論等多久,都只會看到底下的畫面內容。
C Sharp / C#
public async Task<ActionResult> Index()
{
    WebClient wc = new WebClient();

    var task = wc.DownloadStringTaskAsync(new Uri("https://lobworkshop.azurewebsites.net" +
    $"/api/RemoteSource/Add/99/87/2"));
    task.Wait();
    string result = task.Result;
    ViewBag.Name = "OK" + result;

    return View();
}


了解更多關於 [使用 async 和 await 進行非同步程式設計] 的使用方式



沒有留言:

張貼留言