非同步的執行流程(await/async)

寫.Net程式一定會遇到非同步(await/async),async要放在哪裡比較沒有異議,但await放的地方才是重點。

同步 vs 非同步 vs 多執行緒的差異

下面這張圖(來源:一張圖看懂同步、非同步與多執行緒的差別)簡單了說明了三者之間的差異。

  • 同步:一個人同時只能做一件事
  • 非同步:一個人同時能做多件事
  • 多執行緒:多個人同時能做多件事
同步、非同步與多執行緒的差異
來源:https://ouch1978.github.io/blog/2022/09/25/understand-sync-async-and-multi-thread-with-one-pic

非同步的執行流程

async Function遇到await時會做兩件事情。

  • async Function:遇到await時會等待,且不執行await後續的程式,並回傳一個Task給呼叫端。
  • 呼叫端(call此async Function的Function):收到回傳Task後(可理解為被通知不必等待async Function),並繼續執行呼叫端之後續程式。

上述流程有兩個角色,呼叫端Function跟非同步Function。一個程式流在收到呼叫端Function回傳的Task後,接續執行呼叫端之後續程式。同一個程式流可以同時做兩件事的這個過程,就可以稱為非同步。

下面這兩張圖(來源:C#中async/await的执行顺序)是我覺得比較清楚的表達出非同步執行的流程

  • ExecAsync:非同步Function
  • Main:呼叫端

非同步(async/await)與WebAPI的關係

各位或許會有個疑問,如果以一個WebAPI專案來看的話。通常我們Controller裡的的Action會是一個非同步Function(以程式來看的話,已經是最上層的Function了),那他的呼叫端會是誰呢?

答案是IIS的Thread Pool裡面的執行緒。

 

在同步的做法,Thread Pool裡面的執行緒,一個執行緒一次只能處理一個Request,處理完後,再去處理另外一個Request。

Waiting Synchronously for an External Resource

如果一次有很多個Request的話,由於沒有足夠的執行緒,但Server又已經接收Request的狀況下,Request一直得不到會應,應該很快就會出現HTTP 503(服務無法使用)的錯誤了。

A Two-Threaded Server Receiving Three Requests

如果用非同步的方式處理的話,執行緒收到回傳的Task後,就可以先去處理別的Request,而不必進行無謂的等待,這樣就能提升Web Server的效能了。

Waiting Asynchronously for an External Resource

或許你可能又有疑問了,那為什麼不把Thread Pool的執行緒數量加大就好了,因為新增一條執行緒會占用記憶體1MB的記憶空間。執行緒數量越多,就越佔用記憶體空間。


非同步程式實測

我們來寫一個api來作測試,流程大概如下:

Test會呼叫Work1Async. Work2Async. Work3Async這三個async function。並回傳相加的結果。回傳的結果是無異議的6

但各位想想看下面的程式執行時間會花幾秒?答案是6秒,跟同步執行的秒數是一樣的,那這樣加了一堆await的意義在哪裡@@

       [HttpGet]
        public async Task<int> Test()
        {
            var a = await Work1Async();
            var b = await Work2Async();
            var c = await Work3Async();

            return a + b + c;
        }
        
		private async Task<int> Work1Async()
        {
            await Task.Delay(1000);
            return 1;
        }

        private async Task<int> Work2Async()
        {
            await Task.Delay(2000);
            return 2;
        }

        private async Task<int> Work3Async()
        {
            await Task.Delay(3000);
            return 3;
        }

我們來分解一下流程:

  1. 進入Test
  2. 執行Work1Async。遇到Work1Async.await。返回Test,遇到Test.await Work1Async,等待Work1Async的回傳結果(1) => 共花了1秒
  3. 執行Work2Async。遇到Work2Async.await。返回Test,遇到Test.await Work2Async,等待Work2Async的回傳結果(2) => 共花了2秒
  4. 執行Work3Async。遇到Work3Async.await。返回Test,遇到Test.await Work3Async,等待Work3Async的回傳結果(3) => 共花了3秒
  5. return a+b+c。共花了6秒

先前有提到過,async function遇到await後,會停止執行後續程式,一直等到function結束後在繼續往下執行後續程式,我們把await放在Work1Async~Work3Async前,這樣就導致了整個程式流程依序執行Work1Async~Work3Async,就變得跟同步執行一樣了。

如果要讓整段程式以非同步執行的話,就要調整一下await的位置。調整完後再呼叫一次API看看花費的時間。結果讓人驚艷!!快了近一倍的時間。

       [HttpGet]
        public async Task<int> Test()
        {
            var a = Work1Async();
            var b = Work2Async();
            var c = Work3Async();

            return await a + await b + await c;
        }
        
		private async Task<int> Work1Async()
        {
            await Task.Delay(1000);
            return 1;
        }

        private async Task<int> Work2Async()
        {
            await Task.Delay(2000);
            return 2;
        }

        private async Task<int> Work3Async()
        {
            await Task.Delay(3000);
            return 3;
        }

我們來分解一下流程:

  1. 進入Test
  2. 執行Work1Async。遇到Work1Async.await。返回Test,往下執行Work2Async
  3. 執行Work2Async。遇到Work2Async.await。返回Test,往下執行Work3Async
  4. 執行Work3Async。遇到Work3Async.await。返回Test,往下執行return await a + await b + await c,這時候才會開始等待Work1Async~Work3Async的回傳結果,由於是同步執行,我們真正的等待會為Work3Async的3秒,Work1AsyncWork2Async的等待時間,已經在等待Work3Async的3秒中,非同步的執行完畢了。
  5. return a+b+c。共花了3秒

從以上測試可以看出來,當處理事件的時間有重複時,非同步處理最終只要花費最長作業的花費時間即可完成整個作業流程,而同步處理必須要花費所有作業時間的相加才能完成整個作業流程。由此可知,非同步處理確實能讓效能更好。非同步的測試建議可以參考這兩篇文章,寫的都很好而且易懂。

 

以上


Ref: