1. 程式人生 > >C# async/await

C# async/await

前言

Talk is cheap, Show you the code first!

private void button1_Click(object sender, EventArgs e)
        {
            Console.WriteLine("111 balabala. My Thread ID is :" + Thread.CurrentThread.ManagedThreadId);
            AsyncMethod();
            Console.WriteLine("222 balabala. My Thread ID is :" + Thread.CurrentThread.ManagedThreadId);
        }

        private async Task AsyncMethod()
        {
            var ResultFromTimeConsumingMethod = TimeConsumingMethod();
            string Result = await ResultFromTimeConsumingMethod + " + AsyncMethod. My Thread ID is :" + Thread.CurrentThread.ManagedThreadId;
            Console.WriteLine(Result);
            //返回值是Task的函式可以不用return
        }

        //這個函式就是一個耗時函式,可能是IO操作,也可能是cpu密集型工作。
        private Task<string> TimeConsumingMethod()
        {            
            var task = Task.Run(()=> {
                Console.WriteLine("Helo I am TimeConsumingMethod. My Thread ID is :" + Thread.CurrentThread.ManagedThreadId);
                Thread.Sleep(5000);
                Console.WriteLine("Helo I am TimeConsumingMethod after Sleep(5000). My Thread ID is :" + Thread.CurrentThread.ManagedThreadId);
                return "Hello I am TimeConsumingMethod";
            });

            return task;
        }

非同步方法的結構

上面是一個非常簡單的使用async/await的例子。
使用async/await能非常簡單的建立非同步方法,防止耗時操作阻塞當前執行緒。
使用async/await來構建的非同步方法,邏輯上主要有下面三個結構:

呼叫非同步方法

private void button1_Click(object sender, EventArgs e)
        {
            Console.WriteLine("111 balabala. My Thread ID is :" + Thread.CurrentThread.ManagedThreadId);
            AsyncMethod();//這個方法就是非同步方法,非同步方法的呼叫與一般方法完全一樣
            Console.WriteLine("222 balabala. My Thread ID is :" + Thread.CurrentThread.ManagedThreadId);
        }

注意:微軟建議非同步方法的命名是在方法名後新增Aysnc字尾,示例是我為了讀起來方便做成了字首,在真正構建非同步方法的時候請注意用字尾。
非同步方法的返回型別只能是voidTaskTask<TResult>。示例中非同步方法的返回值型別是Task
另外,上面的AsyncMethod()會被編譯器提示報警,如下圖:

因為是非同步方法編譯器提示在前面使用await關鍵字,這個後面再說,為了方便理解就先這麼放著。

非同步方法本體

private async Task AsyncMethod()
        {
            var ResultFromTimeConsumingMethod = TimeConsumingMethod();
            string Result = await ResultFromTimeConsumingMethod + " + AsyncMethod. My Thread ID is :" + Thread.CurrentThread.ManagedThreadId;
            Console.WriteLine(Result);
            //返回值是Task的函式可以不用return
        }

非同步方法必須用async來修飾,方法內部必須含有await修飾的方法,如果方法內部沒有await關鍵字修飾的表示式,哪怕函式被async修飾也只能算作同步方法,執行的時候也是同步執行的。

被await修飾的只能是Task或者Task<TResule>型別,通常情況下是一個返回型別是Task/Task<TResult>的方法,當然也可以修飾一個Task/Task<TResult>變數。上面程式碼中就是修飾了一個變數ResultFromTimeConsumingMethod

關於被修飾的物件,也就是返回值型別是TaskTask<TResult>函式或者Task/Task<TResult>型別的變數:如果是被修飾物件的前面用await修飾,那麼返回值實際上是void或者TResult(示例中ResultFromTimeConsumingMethodTimeConsumingMethod()函式的返回值,也就是Task<string>型別,當ResultFromTimeConsumingMethod在前面加了await關鍵字後 await ResultFromTimeConsumingMethod實際上完全等於 ResultFromTimeConsumingMethod.Result)。如果沒有await,返回值就是Task或者Task<TResult>

耗時函式

//這個函式就是一個耗時函式,可能是IO密集型操作,也可能是cpu密集型工作。
        private Task<string> TimeConsumingMethod()
        {            
            var task = Task.Run(()=> {
                Console.WriteLine("Helo I am TimeConsumingMethod. My Thread ID is :" + Thread.CurrentThread.ManagedThreadId);
                Thread.Sleep(5000);
                Console.WriteLine("Helo I am TimeConsumingMethod after Sleep(5000). My Thread ID is :" + Thread.CurrentThread.ManagedThreadId);
                return "Hello I am TimeConsumingMethod";
            });

            return task;
        }

這個函式才是真正幹活的(為了讓邏輯層級更分明,我把這部分專門做成了一個函式,在後面我會精簡一下直接放到非同步函式中,畢竟活在哪都是幹)。

在示例中是一個CPU密集型的工作,我另開一執行緒讓他拼命幹活幹5s。如果是IO密集型工作比如檔案讀寫等可以直接呼叫.Net提供的類庫,對於這些類庫底層具體怎麼實現的?是用了多執行緒還是DMA?或者是多執行緒+DMA?這些問題我沒有深究但是從表象看起來和我用Task另開一個執行緒去做耗時工作是一樣的。

await只能修飾Task/Task<TResult>型別,所以這個耗時函式的返回型別只能是Task/Task<TResult>型別。

總結:有了上面三個結構就能完成使用一次非同步函式。

async/await非同步函式的原理

在開始講解這兩個關鍵字之前,為了方便,對某些方法做了一些拆解,拆解後的程式碼塊用代號指定:

上圖對示例程式碼做了一些指定具體就是:
Caller代表呼叫方函式,在上面的程式碼中就是button1_Click函式。
CalleeAsync代表被呼叫函式,因為程式碼中被呼叫函式是一個非同步函式,按照微軟建議的命名添加了Async字尾,在上面示例程式碼中就是AsyncMethod()函式。
CallerChild1代表呼叫方函式button1_Click在呼叫非同步方法CalleeAsync之前的那部分程式碼。
CallerChild2代表呼叫方函式button1_Click在呼叫非同步方法CalleeAsync之後的那部分程式碼。
CalleeChild1代表被呼叫方函式AsyncMethod遇到await關鍵字之前的那部分程式碼。
CalleeChild2代表被呼叫方函式AsyncMethod遇到await關鍵字之後的那部分程式碼。
TimeConsumingMethod是指被await修飾的那部分耗時程式碼(實際上我程式碼中也是用的這個名字來命名的函式)

示例程式碼的執行流程


為了方便觀看我模糊掉了對本示例沒有用的輸出。
這裡涉及到了兩個執行緒,執行緒ID分別是1和3。
Caller函式被呼叫,先執行CallerChild1程式碼,這裡是同步執行與一般函式一樣,然後遇到了非同步函式CalleeAsync。在CalleeAsync函式中有await關鍵字,await的作用是打分裂點。編譯器會把整個函式(CalleeAsync)從這裡分裂成兩個函式。await關鍵字之前的程式碼作為一個函式(按照我上面定義的指代,下文中就叫這部分程式碼CalleeChild1)await關鍵字之後的程式碼作為一個函式(CalleeChild2)。CalleeChild1在呼叫方執行緒執行,執行到await關鍵字之後,另開一個執行緒耗時工作在Thread3中執行,然後立即返回。這時呼叫方會繼續執行下面的程式碼CallerChild2(注意是Caller不是Callee)。在CallerChild2被執行期間,TimeConsumingMethod也在非同步執行(可能是在別的執行緒也可能是CPU不參與操作直接DMA的IO操作)。當TimeConsumingMethod執行結束後,CalleeChild2也就具備了執行條件,而這個時候CallerChild2可能執行完了也可能沒有,由於CallerChild2與CalleeChild2都會在Caller的執行緒執行,這裡就會有衝突應該先執行誰,編譯器會在合適的時候在Caller的執行緒執行這部分程式碼。示意圖如下:

請注意,CalleeChild2在上圖中並沒有畫任何箭頭,因為這部分程式碼可能在button1按下的處理函式都執行完了才執行。
總結一下:整個流程下來,除了TimeConsumingMethod函式是在Thread3中執行的,剩餘程式碼都是在主執行緒Thread1中執行的。

帶返回值的非同步函式

之前的示例程式碼中非同步函式是沒有返回值的,作為理解原理足夠了,但是在實際應用場景中,帶返回值的應用才是最常用的。那麼上程式碼:

private void button1_Click(object sender, EventArgs e)
        {
            Console.WriteLine("111 balabala. My Thread ID is :" + Thread.CurrentThread.ManagedThreadId);
            var ResultTask  = AsyncMethod();
            Console.WriteLine(ResultTask.Result);
            Console.WriteLine("222 balabala. My Thread ID is :" + Thread.CurrentThread.ManagedThreadId);
        }

        private async Task<string> AsyncMethod()
        {
            var ResultFromTimeConsumingMethod = TimeConsumingMethod();
            string Result = await ResultFromTimeConsumingMethod + " + AsyncMethod. My Thread ID is :" + Thread.CurrentThread.ManagedThreadId;
            Console.WriteLine(Result);
            return Result;
        }

        //這個函式就是一個耗時函式,可能是IO操作,也可能是cpu密集型工作。
        private Task<string> TimeConsumingMethod()
        {            
            var task = Task.Run(()=> {
                Console.WriteLine("Helo I am TimeConsumingMethod. My Thread ID is :" + Thread.CurrentThread.ManagedThreadId);
                Thread.Sleep(5000);
                Console.WriteLine("Helo I am TimeConsumingMethod after Sleep(5000). My Thread ID is :" + Thread.CurrentThread.ManagedThreadId);
                return "Hello I am TimeConsumingMethod";
            });

            return task;
        }

主要更改的地方在這裡:

按理說沒錯吧?然而,這程式碼一旦執行就會卡死。

死鎖

是的,死鎖。分析一下為什麼:

按照之前我劃定的程式碼塊指定,在添加了新程式碼後CallerChild2與CalleeChild2的劃分如上圖。這兩部分程式碼塊都是在同一個執行緒上執行的,也就是主執行緒Thread1。Console.WriteLine(ResultTask.Result);這一句屬於CallerChild2,而且通常情況下是會早於CalleeChild2執行的(畢竟CalleeChild2得在耗時程式碼塊執行之後執行),而Console.WriteLine(ResultTask.Result);其實是在請求CallerChild2的執行結果,此時明顯CallerChild2還沒有結束沒有return任何結果,那Console.WriteLine(ResultTask.Result);就只能等待直到CalleeChild2有結果,然而問題就在這,CalleeChild2也是在Thread1上執行的,此時CallerChild2一直佔用Thread1等待,就算耗時程式結束後輪到CalleeChild2執行了他也搶不到執行權,這就造成了死鎖。

解決辦法有兩種一個是把Console.WriteLine(ResultTask.Result);放到一個新開執行緒中等待(個人覺得這方法有點麻煩,畢竟要新開執行緒),還有一個方法是把Caller也做成非同步方法:

ResultTask.Result變成了ResultTask 的原因上面也說了,await修飾的Task/Task<TResult>得到的是TResult。
之所以這樣就能解決問題是因為嵌套了兩個非同步方法,現在的Caller也成了一個非同步方法,當Caller執行到await後直接返回了(await拆分方法成兩部分),CalleeChild2執行之後才輪到Caller中await後面的程式碼塊(Console.WriteLine(ResultTask.Result);)。

這樣沒省多少事啊?

到現在,使用async/await不比直接用Task.Run()來的簡單啊?比如我用TaskTaskContinueWith方法也能實現:

private void button1_Click(object sender, EventArgs e)
        {
            var ResultTask = Task.Run(()=> {
                Console.WriteLine("Helo I am TimeConsumingMethod. My Thread ID is :" + Thread.CurrentThread.ManagedThreadId);
                Thread.Sleep(5000);
                Console.WriteLine("Helo I am TimeConsumingMethod after Sleep(5000). My Thread ID is :" + Thread.CurrentThread.ManagedThreadId);
                return "Hello I am TimeConsumingMethod";
            });

            ResultTask.ContinueWith(OnDoSomthingIsComplete);

        }

        private void OnDoSomthingIsComplete(Task<string> t)
        {
            Action action = () => {
                textBox1.Text = t.Result;
            };
            textBox1.Invoke(action);
            Console.WriteLine("Continue Thread ID :" + Thread.CurrentThread.ManagedThreadId);
        }

是的,上面的程式碼也能實現。但是,async/await的優雅的開啟方式是這樣的:

private async void button1_Click(object sender, EventArgs e)
        {
            var t = Task.Run(() => {
                Thread.Sleep(5000);
                return "Hello I am TimeConsumingMethod";
            });
            textBox1.Text = await t;
        }

看到沒,驚不驚喜,意不意外,寥寥幾行就搞定了,不用再多寫那麼多函式,使用起來也很靈活。最讓人頭疼的跨執行緒修改控制元件的問題完美解決了,因為修改控制元件的操作壓根就是在原來的執行緒上做的,還能不阻塞UI。