1. 為什么會有/怎么解決: async/await的無限嵌套
public async Task<int> myFuncAsync1() { //some code int num = await getNumberFromDatabaseAsync(); //如果沒有await那么async修飾的函數仍然是同步執行,失去意義 return num; } public async Task<string> myFuncAsync2() { //some code int num = await myFuncAsync1(); //因為用await等待async函數,所以此函數也要標記為async string s = ""+ num.toString(); return s; } public async Task<int> myFuncAsync3() { ... } ...
第一次遇到async/await是在做一個智能家居的網絡控制程序上,為了不阻塞UI,老同事說把其中有的方法改成了async,讓后端修改數據庫的邏輯異步執行,返回操作結果之后再刷新UI,可是發現把一個函數標記成async之后,你就必須在這個函數里頭有await的對象(通常也是一個異步函數)才能讓async真的異步,否則即使標記async函數仍然會同步執行,那這個await的對象又一定要是一個async的函數,同理,剛才說的函數的母函數里頭,他又需要在一個await修飾調用的這個函數才能等到結果,有了await母函數也要標記為async,那是不是無窮無盡了?
答案是否定的。
先說下為什么為引起這樣的原因。正如上文提到的,一般是因為你想異步執行什么操作,歸根結底就是異步的數據庫操作或者調用異步API,比如調用 QueryAsync()或者httpGetAsync(),這些ORM或者API已經被分裝成async的形式了,你為了等他們結束獲取結果,需要await他們,而await關鍵字只能在async函數中使用,以此類推。。所以如果你看到一個async函數,一直向下查看的話,應該能看到最底層的應該是我剛才說的調用的別人封裝好的異步函數。比如下圖這個是dapper(一個輕量級ORM)對於執行一些數據庫query的異步方法,為了調用他們可能會發生上述情況。
那怎么解決這個無限的循環呢?因為大家都知道一直往上肯定是到main函數了,main函數是不能被標記為async的,總得有一個async函數被包含在沒有async標記的函數里。當然這里有一種情況就是最底層的async函數層層異步到最上端,作為API被暴露給外界,這也就是我們調用的異步API同理,適用於REST API的那種項目。那么如果不是從頂層async到底層,像一個UI函數,怎么調用一個異步函數呢?
有兩種方法:
public static void main() { //some code myFuncAsync1(); } public async Task myFuncAsync1() { //some code string s= await myFuncAsync2(); someLogic(s); } public async Task<string> myFuncAsync2() { //some code int num = await myFuncAsync3(); string s = someLogic(num); return s; }
第一個是封裝到一個沒有返回值的異步函數里頭, 比如我們的邏輯從底層的myFuncAsyncN一直返回值到myFuncAsync1,在myFuncAsync1中,拿到myFuncAsync2的數據並且完成最后所有操作,不需要再返回任何值進行進一步的操作,那么myFuncAsync1雖然被標記為async函數,但是在main函數調用他的時候,因為沒有返回值,所以不需要用await關鍵字。事實上async/await的循環鏈都可以止步於一個沒有返回值的async函數。
public String DownloadString(String url) { var request = HttpClient.GetAsync(url).Result; var download = request.Content.ReadAsStringAsync().Result; return download; }
第二個是利用Task.Result來直接獲取結果(會阻塞主進程,慎用!)。如上段代碼,本來的async函數,需要用await修飾來異步獲取結果,然后再繼續執行,現在用result直接等到當前進程把這個異步函數運行完並且拿到結果,result關鍵字其實是執行完這個task並且拿到結果,否則針對task返回型我們只能await修飾來等待結果。這樣做的話避免了await,母函數也就不用async修飾,中斷了async/await鏈。但是用result關鍵字,其實是阻塞的主進程,然后在一個新進程上運行異步task,得到結果之后,返回給主進程,主進程再繼續往下走,注意主進程在新進程去獲取結果的這段時間,是不能做別的事的,只能干等在這里,所以和主進程自己去做這個task然后得到結果並沒有卵區別,可以說就是一個多浪費了一個進程的同步。而await修飾的話,主進程到了await這里就可以被釋放干別的去,如果主進程是UI進程的話,UI就不會卡頓。事實上await保存了上下文,封裝了后半部分代碼,等到await等來了結果,他會安排一個新的進程,給他上下文讓他繼續運行,所以await才真正做到了充分利用進程。
public String DownloadString(String url) { var runInBackground = Task.Run(()=>HttpClient.GetAsync(url)); //假設GetAsync耗時5秒 var runInBackground2 = Task.Run(()=>sql.QueryAsync(q)) //也耗時5秒 //上邊兩個task並行 var request = runInBackground.Result; //5s之后拿到結果,主進程阻塞了5秒 var db = runInBackground.Result; //同時拿到結果,無需等待 //兩個task共耗時5秒 ... }
那有人就問了,既然這樣,result關鍵字好像有百害而無一利,為什么還要用,其實我們可以配合Task.Run()使用,Task.Run()會在后台開一個新進程1去運行GetAsync這個task需要5秒,同時因為沒有使用result關鍵字,主進程並沒有馬上需要用到result,會繼續往下執行,假設下邊有另外一個Task.Run()開啟新進程2去執行另外一個task,也耗時5秒,然后到獲取第一個result時候,主進程阻塞5秒,新進程1返回結果,在新進程1獲取結果這5秒,新進程2也同時拿到了結果,所以第二個result主進程不會阻塞,直接拿到了結果,這也是完成了兩個並行的異步任務。和await相比主進程只是在被阻塞的5秒內不能做別的事。如果是UI進程的話,相對於上段代碼的阻塞10秒,這里就只阻塞5秒。