C# - 非同步的async和await

  • 7697
  • 0
  • 2013-07-31

C# - 非同步的async和await

7/31 更新 – async和await是.net 4.5才出現的,感謝ALEX前輩提醒~裡面內文已經更新

前一段時間,在測試Visual Studio的非同步偵錯功能,範例是一個使用async和await滿簡單的程式,但其中有一行Code,內容是寫Task.Yield(),小弟我一直搞不太懂,所花了一點時間把async和await的底層稍微看了一下( 真的只有稍微…但花了整整一整天的時間… ),看完後,也大致上了解Task.Yield(),所以小弟也在這邊紀錄一下;雖然這次是從Task.Yield()為出發點,但實際上還是會圍繞在async和await上面。

這篇文章,大致上的內容是出處於MSDN雜誌,有興趣的可以去看看原文,而這篇文章,也不太會針對async和await的用法做太多的解釋,也請大家見諒。

首先,該從哪邊談起呢??應該還是要從非同步與同步開始說起吧,基本上,這裡不會太加以敘述這方面的概念,只會簡單的說一下,同步簡單來說,就是一步接著一步,如我們平常寫的程式碼一樣,從上而下,一步一步依照著我們的步調執行。

那這樣會遇到怎要的問題呢??簡單的說,當我們遇到了大量存取,或是要消耗大量運算的時候,會碰到怎樣的問題!??,是低,就是卡住,因為會卡在那一行的程式碼嘛…

而因此,就產生了非同步的概念,非同步簡單的說,就像時空跳躍,當我們遇到需要處理大量資料,而會卡住的時候,先跳去做別的事情,然後等那大量的資料處理完後,再接著處理那大量資料後的程式碼;如果以網頁的角度來說,同步,就好像,我們按下了一個按鈕後,然後看著圈圈在那邊轉阿轉,然後像瀏覽器當掉一樣,甚麼事情都不能做;而非同步,當我們按了一個按鈕之後,還是可以到處亂點~~ ( 其實現在網頁幾乎都在跑非同步了… )

好,其實還是花了一些時間介紹- -|||,基本上,我想非同步語同步的觀念,以現在寫前端的朋友們,應該都非常熟了才對。

那我們回來看一下早期的C#寫法( 其實也不算多早阿QQ… ),如下,我們使用MSDN雜誌的Code來進行解釋~~;其實如果會寫js的朋友,應該就很有感覺,因為語法幾乎一樣阿XDD;下面這個範例,就是TryFetchAsync會傳入網址和一個Action型別的委派( 稱為callback ),以功能面來說,就是抓取網頁資料的方法~~,然後這個方法定義了WebClient,準備抓取資料,那可想而知,WebClient一定會跑很久( 好啦,至少不會很快… ),所以如果沒使用非同步的話,就會需要等待;而這邊使用了非同步,所以會定義一個完成後的觸發事件,讓網頁下載完成後,在進入那個事件去做處理;也因此,在網頁下載的過程中,可以去做其他的事情,而不會卡在那邊~~。而這種感覺,就是小弟我前面說的,把等事情處理完後,再回來執行後面的程式碼。那當資料下載完後,就使用委派的方式,去呼叫傳進來Action型別的callback,如果成功就呼叫callback(args.Result,null),至於為什麼會這樣呼叫,是因為callback第一個型別為byte,第二個型別為exception,這兩個參數會由DownloadDataCompleted的事件處理,預設傳入的兩個參數來得知,由DownloadDataCompleted的事件處理的兩個參數為sender ( 事件的來源,也就是由誰觸發 )和args ( 事件的資料,看是成功後取得的資料,或是錯誤的資訊 )。好吧…有點離題太遠了,有興趣的可以參考msdn的這裡那裏


{
    //使用WebClient抓取網頁資料
    var client = new WebClient();
    //定義事件,當下載完成後,執行此事件,_Sender、args是事件資料
    client.DownloadDataCompleted += (_, args) =>
    {
        if (args.Error == null) callback(args.Result, null);
        else if (args.Error is WebException) callback(null, null);
        else callback(null, args.Error);
    };
    //開始非同步方式下載
    client.DownloadDataAsync(new Uri(url));
}

當然,到了.Net 4.5開始,提供了async和await,讓使用者更加輕鬆的去處理,而且看起來和一般的Code沒啥差異,如下Code。( 一樣拿MSDN雜誌的Code~~因為小弟懶得再去想自己的Code了…請見諒阿… );基本上,功能和上面一樣,但是變得更加優雅了,我們加上了async並且用Task<byte[]>來當作回傳的型別,當WebClient抓到資料的時候,就直接回傳byte[],如果沒抓到,就回傳null。( 這邊小弟就不解說為什麼回傳用Task<byte[]>,但實際上,可以直接接到byte[]了,請見諒… )


{
    //使用WebClient抓取網頁資料
    var client = new WebClient();
    try
    {
        return await client.DownloadDataTaskAsync(url);
    }
    catch (WebException) { }
    return null;
}

補充一下,當然,你也可以說,在沒使用async和await的情況下,我也可以直接把code塞到DownloadDataCompleted裡面去,的確,這樣的確也是可行,只要後續你要處理byte的程式碼不會很長…如果你後續還要對byte要做一堆的處理,那你想想看,我們要把這些東西塞到DownloadDataCompleted裡面去,不是就把整個cs檔案給塞爆了嗎…但不管怎樣,還是希望大家不要離題XDD,我們重點還是在討論async和await…其實中間的那段,可以省略不看啦0 0..

好,前面說了一堆大家都知道的東西,後續才是小弟比較重要的紀錄。

當我們使用async和await的時候,其實會產生類似如下的程式碼( 歹勢,請允許我說類似,因為底下的Code在C#下,可能是不能編譯的,但別忘了,編譯器編譯C#後,產生的是IL中間語言,恩,好吧,其實某方面也不是全對,底下的Code,IL也不能完全真的去跑,但這邊只是想用C#的語法來呈現概念,記這,這是概念~~ …P.S 同樣,Code來自MSDN 雜誌~ )

是的,底下其實是很長的,但我們分段來看吧,下面先po出全貌。



    public class AsyncTaskMethodBuilder<TResult>
    {
        public Task<TResult> Task { get; }
        public void SetResult(TResult result);
        public void SetException(Exception exception);
    }

    public struct TaskAwaiter<TResult>
    {
        public bool IsCompleted { get; }
        public void OnCompleted(Action continuation);
        public TResult GetResult();
    }
#endif

        static async Task<byte[]> TryFetchAsync(string url)
        {
            var client = new WebClient();
            try
            {
                return await client.DownloadDataTaskAsync(url);
            }
            catch (WebException) { }
            return null;
        }


    class TryFetch2
    {
        static Task<byte[]> TryFetchAsync(string url)
        {
            var __builder = new AsyncTaskMethodBuilder<byte[]>(); //建立生成器
            int __state = 0;
            Action __moveNext = null; //_moveNext委託 ( 播放 )
            TaskAwaiter<byte[]> __awaiter1;

            WebClient client = null;

            __moveNext = delegate
            {
                try
                {
                    if (__state == 1) goto Resume1;
                    client = new WebClient();
                Resume1:
                    try
                    {
                        if (__state == 1) goto Resume1a;
                        __awaiter1 = client.DownloadDataTaskAsync(url).GetAwaiter();
                        if (!__awaiter1.IsCompleted)
                        {
                        //__awaiter1未完成時,就先設定。
                            __state = 1;
                            __awaiter1.OnCompleted(__moveNext);//設定完程執行__moveNext (因為__moveNext是action型別,所以可以直接這樣寫)
                            return; //( 暫停 利用return跳出function,未來如果awaiter1完成,則會重新觸發__moveNext方法,再度進來。
                            //所以利用了這個機制,來達到"等待執行完"才會執行await後面的方法)
                        }
                    Resume1a://恢復執行
                        __builder.SetResult(__awaiter1.GetResult());//完成
                    }
                    catch (WebException) { }
                    __builder.SetResult(null);
                }
                catch (Exception exception)
                {
                    __builder.SetException(exception);
                }
            };

            __moveNext();//上面其實是在撰寫__moveNext的Function,這裡才是真正的第一次觸發__moveNext
            return __builder.Task;
        }
    }

首先,會建立兩個類別,分別是AsyncTaskMethodBuilder,這是一個生成器,到時候錯誤和Result都會利用SetException和SetResult來設定最終取得出來的結果 ( 也就是WebClient傳回來的結果 ) 並且放到Task裡面去,而最終,會利用Task來取得最終的結果。

第二個是TaskAwaiter,他會去定義一些方法,例如可以得知當WebClient是否完成,完成後要處理哪些事情、和取得完成後的資料。



    public class AsyncTaskMethodBuilder<TResult>
    {
        public Task<TResult> Task { get; }
        public void SetResult(TResult result);
        public void SetException(Exception exception);
    }

    public struct TaskAwaiter<TResult>
    {
        public bool IsCompleted { get; }
        public void OnCompleted(Action continuation);
        public TResult GetResult();
    }
#endif

接下來是重頭戲了,這邊有點攏長,請大家忍耐XDD;而這段Code也是真正後端處理的邏輯,他的步驟大致上如下。

  1. 會先建立一個AsyncTaskMethodBuilder這個類別,也就是建立生成器。
  2. 然後利用狀態機的概念,先把目前的狀態,設定為0,表示一開始。( int __state = 0; )
  3. 會建立一個新的Action委派,名稱為__moveNext。
  4. 準備好TaskAwaiter型別的變數,__awaiter1。
  5. 設定型別WebClient的變數為client ( 這應該是最簡單的一行吧0 0 )。
  6. 開始撰寫Action委派__moveNext裡面的內容 ( 實際上到這邊,也只是定義__moveNext會執行怎樣的東西罷了,不會真的去執行裡面的Code。 )
  7. 定義完後,跳到倒數第二行,也就是__moveNext();這裡。( 是的,到這邊,才真正的要開始執行步驟6定義的內容 )。
  8. 現在開始執行裡面的內容了,首先,先判斷__state是否為1 (也就是if(__state == 1) goto Resume1這行 ) ,理所當然,我們一開始的狀態__state = 0,所以就不會goto。
  9. 既然是第一次,所以這邊把WebClient給new起來了。
  10. 進入到第二個try裡面,同樣的,這邊判斷__state是否為1,如果為1,就跳到Resumela。
  11. 因為__state還是為0,所以這邊執行了要超長時間的client.DownloadDataTaskAsync,並且把取回的Awaiter,放到__awaiter1( 其實應該用指向啦,但用放到,初學者應該會比較好理解… )
  12. 接下來,用TaskAwaiter的IsCompleted來判斷,WebClient下載完了沒。
  13. 既然是要花超長時間下載,自然還沒好,所以又進入了if裡面。
  14. 而這時,就把狀態改成1了( __state = 1 ),雖然先把狀態改成1,但這時其實是還沒下載完成的喔!!
  15. 並且在__awaiter1.OnCompleted註冊了__moveNext這個方法。是的,沒有看錯,這邊代表當下載完成的時候,又會執行一次__moveNext這個方法一次,
  16. 然後就return了,表示完全離開了__moveNext()這個方法,跑去摸魚,喔,不是,跑去做其他的事情了。
  17. 當WebClient完成後,神奇的是其發生了,因為剛剛我們註冊了OnCompleted這個方法,所以當WebClient完成的時候,就觸發了__moveNext再執行一次!!
  18. 然後因為這次__state = 1了,所以就直接goto到Resume1,也不用再new一次WebClient了。
  19. 而又因為__state = 1,所以又跳到Resumela。
  20. 那Resumela那邊做了甚麼事情呢!?因為WebClient已經完成,就可以透過TaskAwaiter的GetResult取得資料,並且透過AsyncTaskMethodBuilder的SetResult,放到AsyncTaskMethodBuilder的Task裡面去。( 也就是__builder.SetResult(__awaiter1.GetResult());這行code )
  21. 最後,整個__moveNext()執行完後,就到了return __builder.Task,並且把Task回傳回去了~~
  22. 於是,整個TryFetchAsync真正的結束了~~

打完了整個步驟,感覺好像看完一部卡通一樣,有點哀傷的感覺(疑!?),但是從這個步驟中,我們就可以了解,整個底層的運作過程。

此外,過程中沒提到TaskAwaiter和Task的一些細節,這部分也請大家見諒,看看以後有機會,再把這兩個細節補完~~


    {
        static Task<byte[]> TryFetchAsync(string url)
        {
            var __builder = new AsyncTaskMethodBuilder<byte[]>(); //建立生成器
            int __state = 0;
            Action __moveNext = null; //_moveNext委託 ( 播放 )
            TaskAwaiter<byte[]> __awaiter1;

            WebClient client = null;

            __moveNext = delegate
            {
                try
                {
                    if (__state == 1) goto Resume1;
                    client = new WebClient();
                Resume1:
                    try
                    {
                        if (__state == 1) goto Resume1a;
                        __awaiter1 = client.DownloadDataTaskAsync(url).GetAwaiter();
                        if (!__awaiter1.IsCompleted)
                        {
                        //__awaiter1未完成時,就先設定。
                            __state = 1;
                            __awaiter1.OnCompleted(__moveNext);//設定完程執行__moveNext (因為__moveNext是action型別,所以可以直接這樣寫)
                            return; //( 暫停 利用return跳出function,未來如果awaiter1完成,則會重新觸發__moveNext方法,再度進來。
                            //所以利用了這個機制,來達到"等待執行完"才會執行await後面的方法)
                        }
                    Resume1a://恢復執行
                        __builder.SetResult(__awaiter1.GetResult());//完成
                    }
                    catch (WebException) { }
                    __builder.SetResult(null);
                }
                catch (Exception exception)
                {
                    __builder.SetException(exception);
                }
            };

            __moveNext();//上面其實是在撰寫__moveNext的Function,這裡才是真正的第一次觸發__moveNext
            return __builder.Task;
        }
    }

到這邊為止,大致上把整個async和await的底層補完,但大家有沒有想到一件事情!?是的,那就是Task.Yield勒!??我們引用一下MSDN雜誌的這句話。

靜態 Task.Yield 實用程序方法返回一個將聲稱(通過 IsCompleted)未完成的可等待操作,但會立即調度傳遞給它的 OnCompleted 方法的回調,就好像該操作實際已完成一樣。 這允許您強制進行調度並繞過編譯器為跳過它而進行的優化(如果結果已可用)。 您可以通過這種方式在「實時」代碼中擠出時間,同時提高並未處於空閒狀態的代碼的響應性。

簡單的說,Task.Yield會返回一個未完成的可等待操作(透過IsCompleted),但返回後,又會馬上調度OnCompleted,也因此,會立即的執行在await的下一行Code,就如同完成了await一樣,而這個狀況下,就可以在Code裡面,擠出一點時間( 雖然只是一下下,但對cpu來說,可能是很多了… )

另外,MSDN也提到Task.Yield的一個點。如下。

You can use await Task.Yield(); in an asynchronous method to force the method to complete asynchronously. If there is a current synchronization context (SynchronizationContext object), this will post the remainder of the method’s execution back to that context. However, the context will decide how to prioritize this work relative to other work that may be pending. The synchronization context that is present on a UI thread in most UI environments will often prioritize work posted to the context higher than input and rendering work. For this reason, do not rely on await Task.Yield(); to keep a UI responsive. For more information, see the entry Useful Abstractions Enabled with ContinueWith in the Parallel Programming with .NET blog.

大致上的翻譯是這樣的,您可以使用Task.Yield();來產生一個快速地中斷,來讓CPU有機會處理別的Thread,但如果當前有一個同步上下文(SynchronizationContext的對象),那Back回來後,這將繼續執行剩下的Code。然而,上下文會決定這項工作或相對於其他尚未了結工作的優先順序,而在大多數UI環境,上下文通常都會在目前的UI線程,所以往往會優先讓原本的Code高於UI輸入和UI渲染工作。出於這個原因,不要依賴於await Task.Yield(),以保持一個UI響應。

簡單的說,如果是想讓UI變快,用Task.Yield是沒用的,基本上Task.Yield還是用於提高其他邏輯上的處理…

後記

花了很長一段時間,把這些東西整理好,當然,對於整個await和async來說,這只是其中的一小部分,現階段小弟也沒力氣繼續追下去了,但相信Google也有更好的文章可以讓大家繼續研究下去,今天就先到這邊吧XDDD

參考資料