一、難以被接受的async
自從C#5.0,語法糖大家庭又加入了兩位新成員: async和await。
然而從我知道這兩個家伙之後的很長一段時間,我甚至都沒搞明白應該怎麼使用它們,這種全新的異步編程模式對於習慣了傳統模式的人來說實在是有些難以接受,不難想象有多少人仍然在使用手工回調委托的方式來進行異步編程。
C#中的語法糖非常多,從自動屬性到lock、using,感覺都很好理解很容易就接受了,為什麼偏偏async和await就這麼讓人又愛又恨呢?
我想,不是因為它不好用(相反,理解了它們之後是非常實用又易用的),而是因為它來得太遲了!
傳統的異步編程在各種語言各種平台前端後端差不多都是同一種模式,給異步請求傳遞一個回調函數,回調函數中再對響應進行處理,發起異步請求的地方對於返回值是一無所知的。我們早就習慣了這樣的模式,即使這種模式十分蹩腳。
而async和await則打破了請求發起與響應接收之間的壁壘,讓整個處理的邏輯不再跳過來跳過去,成為了完全的線性流程!線性才是人腦最容易理解的模式!
二、理解async,誰被異步了
如果對於Java有一定認識,看到async的使用方法應該會覺得有些眼熟吧?
//Java synchronized void sampleMethod() { }
// C# async void SampleMethod() { }
說到這裡我想對MS表示萬分的感謝,幸好MS的設計師采用的簡寫而不是全拼,不然在沒有IDE的時候(比如寫上面這兩個示例的時候)我不知道得檢查多少次有沒有拼錯同步或者異步的單詞。。。
Java中的synchronized關鍵字用於標識一個同步塊,類似C#的lock,但是synchronized可以用於修飾整個方法塊。
而C#中async的作用就是正好相反的了,它是用於標識一個異步方法。
同步塊很好理解,多個線程不能同時進入這一區塊,就是同步塊。而異步塊這個新東西就得重新理解一番了。
先看看async到底被編譯成了什麼吧:
1 .method private hidebysig 2 instance void SampleMethod () cil managed 3 { 4 .custom instance void [mscorlib]System.Runtime.CompilerServices.AsyncStateMachineAttribute::.ctor(class [mscorlib]System.Type) = ( 5 01 00 1f 54 65 73 74 2e 50 72 6f 67 72 61 6d 2b 6 3c 53 61 6d 70 6c 65 4d 65 74 68 6f 64 3e 64 5f 7 5f 30 00 00 8 ) 9 .custom instance void [mscorlib]System.Diagnostics.DebuggerStepThroughAttribute::.ctor() = ( 10 01 00 00 00 11 ) 12 // Method begins at RVA 0x20b0 13 // Code size 46 (0x2e) 14 .maxstack 2 15 .locals init ( 16 [0] valuetype Test.Program/'<SampleMethod>d__0', 17 [1] valuetype [mscorlib]System.Runtime.CompilerServices.AsyncVoidMethodBuilder 18 ) 19 20 IL_0000: ldloca.s 0 21 IL_0002: ldarg.0 22 IL_0003: stfld class Test.Program Test.Program/'<SampleMethod>d__0'::'<>4__this' 23 IL_0008: ldloca.s 0 24 IL_000a: call valuetype [mscorlib]System.Runtime.CompilerServices.AsyncVoidMethodBuilder [mscorlib]System.Runtime.CompilerServices.AsyncVoidMethodBuilder::Create() 25 IL_000f: stfld valuetype [mscorlib]System.Runtime.CompilerServices.AsyncVoidMethodBuilder Test.Program/'<SampleMethod>d__0'::'<>t__builder' 26 IL_0014: ldloca.s 0 27 IL_0016: ldc.i4.m1 28 IL_0017: stfld int32 Test.Program/'<SampleMethod>d__0'::'<>1__state' 29 IL_001c: ldloca.s 0 30 IL_001e: ldfld valuetype [mscorlib]System.Runtime.CompilerServices.AsyncVoidMethodBuilder Test.Program/'<SampleMethod>d__0'::'<>t__builder' 31 IL_0023: stloc.1 32 IL_0024: ldloca.s 1 33 IL_0026: ldloca.s 0 34 IL_0028: call instance void [mscorlib]System.Runtime.CompilerServices.AsyncVoidMethodBuilder::Start<valuetype Test.Program/'<SampleMethod>d__0'>(!!0&) 35 IL_002d: ret 36 } // end of method Program::SampleMethod
不管你們嚇沒嚇到,反正我第一次看到是嚇了一大跳。。。之前的空方法SampleMethod被編譯成了這麼一大段玩意。
另外還生成了一個名叫'<SampleMethod>d__0'的內部結構體,整個Program類的結構就像這樣:
其他的暫時不管,先嘗試把上面這段IL還原為C#代碼:
1 void SampleMethod() 2 { 3 '<SampleMethod>d__0' local0; 4 AsyncVoidMethodBuilder local1; 5 6 local0.'<>4_this' = this; 7 local0.'<>t__builder' = AsyncVoidMethodBuilder.Create(); 8 local0.'<>1_state' = -1; 9 10 local1 = local0.'<>t__builder'; 11 local1.Start(ref local0); 12 }
跟進看Start方法:
1 // System.Runtime.CompilerServices.AsyncVoidMethodBuilder 2 [__DynamicallyInvokable, DebuggerStepThrough] 3 public void Start<TStateMachine>(ref TStateMachine stateMachine) where TStateMachine : IAsyncStateMachine 4 { 5 this.m_coreState.Start<TStateMachine>(ref stateMachine); 6 }
繼續跟進:
1 // System.Runtime.CompilerServices.AsyncMethodBuilderCore 2 [DebuggerStepThrough, SecuritySafeCritical] 3 internal void Start<TStateMachine>(ref TStateMachine stateMachine) where TStateMachine : IAsyncStateMachine 4 { 5 if (stateMachine == null) 6 { 7 throw new ArgumentNullException("stateMachine"); 8 } 9 Thread currentThread = Thread.CurrentThread; 10 ExecutionContextSwitcher executionContextSwitcher = default(ExecutionContextSwitcher); 11 RuntimeHelpers.PrepareConstrainedRegions(); 12 try 13 { 14 ExecutionContext.EstablishCopyOnWriteScope(currentThread, false, ref executionContextSwitcher); 15 stateMachine.MoveNext(); 16 } 17 finally 18 { 19 executionContextSwitcher.Undo(currentThread); 20 } 21 }
注意到上面黃底色的stateMachine就是自動生成的內部結構體'<SampleMethod>d__0',再看看自動生成的MoveNext方法,IL就省了吧,直接上C#代碼:
1 void MoveNext() 2 { 3 bool local0; 4 Exception local1; 5 6 try 7 { 8 local0 = true; 9 } 10 catch (Exception e) 11 { 12 local1 = e; 13 this.'<>1__state' = -2; 14 this.'<>t__builder'.SetException(local1); 15 return; 16 } 17 18 this.'<>1__state' = -2; 19 this.'<>t__builder'.SetResult() 20 }
因為示例是返回void的空方法,所以啥也看不出來,如果在方法裡頭稍微加一點東西,比如這樣:
async void SampleMethod() { Thread.Sleep(1000);
Console.WriteLine("HERE"); }
然後再看看SampleMethod的IL:
1 .method private hidebysig 2 instance void SampleMethod () cil managed 3 { 4 .custom instance void [mscorlib]System.Runtime.CompilerServices.AsyncStateMachineAttribute::.ctor(class [mscorlib]System.Type) = ( 5 01 00 1f 54 65 73 74 2e 50 72 6f 67 72 61 6d 2b 6 3c 53 61 6d 70 6c 65 4d 65 74 68 6f 64 3e 64 5f 7 5f 30 00 00 8 ) 9 .custom instance void [mscorlib]System.Diagnostics.DebuggerStepThroughAttribute::.ctor() = ( 10 01 00 00 00 11 ) 12 // Method begins at RVA 0x20bc 13 // Code size 46 (0x2e) 14 .maxstack 2 15 .locals init ( 16 [0] valuetype Test.Program/'<SampleMethod>d__0', 17 [1] valuetype [mscorlib]System.Runtime.CompilerServices.AsyncVoidMethodBuilder 18 ) 19 20 IL_0000: ldloca.s 0 21 IL_0002: ldarg.0 22 IL_0003: stfld class Test.Program Test.Program/'<SampleMethod>d__0'::'<>4__this' 23 IL_0008: ldloca.s 0 24 IL_000a: call valuetype [mscorlib]System.Runtime.CompilerServices.AsyncVoidMethodBuilder [mscorlib]System.Runtime.CompilerServices.AsyncVoidMethodBuilder::Create() 25 IL_000f: stfld valuetype [mscorlib]System.Runtime.CompilerServices.AsyncVoidMethodBuilder Test.Program/'<SampleMethod>d__0'::'<>t__builder' 26 IL_0014: ldloca.s 0 27 IL_0016: ldc.i4.m1 28 IL_0017: stfld int32 Test.Program/'<SampleMethod>d__0'::'<>1__state' 29 IL_001c: ldloca.s 0 30 IL_001e: ldfld valuetype [mscorlib]System.Runtime.CompilerServices.AsyncVoidMethodBuilder Test.Program/'<SampleMethod>d__0'::'<>t__builder' 31 IL_0023: stloc.1 32 IL_0024: ldloca.s 1 33 IL_0026: ldloca.s 0 34 IL_0028: call instance void [mscorlib]System.Runtime.CompilerServices.AsyncVoidMethodBuilder::Start<valuetype Test.Program/'<SampleMethod>d__0'>(!!0&) 35 IL_002d: ret 36 } // end of method Program::SampleMethod
看出來什麼變化了嗎?????看不出來就對了,因為啥都沒變。
那追加的代碼跑哪去了?!在這呢:
1 void MoveNext() 2 { 3 bool local0; 4 Exception local1; 5 6 try 7 { 8 local0 = true; 9 Thread.Sleep(1000); 10 Console.WriteLine("HERE"); 11 } 12 catch (Exception e) 13 { 14 local1 = e; 15 this.'<>1__state' = -2; 16 this.'<>t__builder'.SetException(local1); 17 return; 18 } 19 20 this.'<>1__state' = -2; 21 this.'<>t__builder'.SetResult() 22 }
至今為止都沒看到異步在哪發生,因為事實上一直到現在確實都是同步過程。Main方法裡這麼寫:
static void Main(string[] args) { new Program().SampleMethod(); Console.WriteLine("THERE"); Console.Read(); }
運行結果是這樣的:
HERE THERE
"THERE"被"HERE"阻塞了,並沒有異步先行。
雖然到此為止還沒看到異步發生,但是我們可以得出一個結論:
async不會導致異步
到底怎麼才能異步?還是得有多個線程才能異步嘛,是時候引入Task了:
async void SampleMethod() { Task.Run(() => { Thread.Sleep(1000); Console.WriteLine("HERE"); }); }
Main方法不變,運行結果是這樣的:
THERE HERE
當然,把SampleMethod前頭的async去掉也可以得到同樣的結果。。。
所以async貌似是個雞肋啊?然而並不是這樣的!
三、理解await,是誰在等
繼續改造上面的SampleMethod,不過現在還得加一個GetHere的方法了:
async void SampleMethod() { Console.WriteLine(await GetHere()); } Task<string> GetHere() { return Task.Run(() => { Thread.Sleep(1000); return "HERE"; }); }
Main方法仍然不變,運行結果也沒有變化。但是現在就不能去掉async了,因為沒有async的方法裡頭不允許await!
首先要注意的是,GetHere方法的返回值是Task<string>,而從運行結果可以看出來WriteLine的重載版本是string參數,至於為什麼,之後再看。
這一次的結論很容易就得出了,很明顯主線程沒有等SampleMethod返回就繼續往下走了,而調用WriteLine的線程則必須等到"HERE"返回才能接收到實參。
那麼,WriteLine又是哪個線程調用的?
這一次可以輕車熟路直接找MoveNext方法了。需要注意的是,現在Program類裡頭已經變成了這副德性:
這個時候try塊裡頭的IL已經膨脹到了50行。。。還原為C#後如下:
1 bool '<>t__doFinallyBodies'; 2 Exception '<>t__ex'; 3 int CS$0$0000; 4 TaskAwaiter<string> CS$0$0001; 5 TaskAwaiter<string> CS$0$0002; 6 7 try 8 { 9 '<>t__doFinallyBodies' = true; 10 CS$0$0000 = this.'<>1__state'; 11 if (CS$0$0000 != 0) 12 { 13 CS$0$0001 = this.'<>4__this'.GetHere().GetAwaiter(); 14 if (!CS$0$0001.IsCompleted) 15 { 16 this.'<>1__state' = 0; 17 this.'<>u__$awaiter1' = CS$0$0001; 18 this.'<>t__builder'.AwaitUnsafeOnCompleted(ref CS$0$0001, ref this); 19 '<>t__doFinallyBodies' = false; 20 return; 21 } 22 } 23 else 24 { 25 CS$0$0001 = this.'<>u__$awaiter1'; 26 this.'<>u__$awaiter1' = CS$0$0002; 27 this.'<>1__state' = -1; 28 } 29 30 Console.WriteLine(CS$0$0001.GetResult()); 31 }
貌似WriteLine仍然是主線程調用的?!苦苦等待返回值的難道還是主線程?!
四、異步如何出現
感覺越看越奇怪了,既然主線程沒有等SampleMethod返回,但是主線程又得等到GetResult返回,那麼異步到底是怎麼出現的呢?
注意到第20行的return,主線程跑進了這一行自然就直接返回了,從而不會發生阻塞。
那麼新的問題又來了,既然MoveNext在第20行就直接return了,誰來再次調用MoveNext並走到第30行?
MoveNext方法是實現自IAsyncStateMachine接口,借助於ILSpy的代碼解析,找到了三個調用方:
第一個是之前看到的,SampleMethod內部調用到的方法,後兩個是接下來需要跟蹤的目標。
調試模式跟到AsyncMethodBuilderCore的內部,然後在InvokeMoveNext和Run方法的首行打斷點,設置命中條件為打印默認消息並繼續執行。
最後在Main函數和lambda表達式的首行也打上同樣的斷點並設置打印消息。F5執行,然後可以在即時窗口中看到如下信息:
Function: Test.Program.Main(string[]), Thread: 0xE88 主線程
Function: Test.Program.GetHere.AnonymousMethod__3(), Thread: 0x37DC 工作線程
Function: System.Runtime.CompilerServices.AsyncMethodBuilderCore.MoveNextRunner.Run(), Thread: 0x37DC 工作線程
Function: System.Runtime.CompilerServices.AsyncMethodBuilderCore.MoveNextRunner.InvokeMoveNext(object), Thread: 0x37DC 工作線程
這樣至少弄明白了一點,"HERE"是由另一個工作線程返回的。
看不明白的是,為什麼lambda的執行在兩次MoveNext被調用之前。。。從調用堆棧也得到有用的信息,這個問題以後有空再深究吧。。。
五、Task<TResult> to TResult
正如之前所說,GetHere方法的返回值是Task<string>,WriteLine接收的實參是string,這是怎麼做到的呢?
關鍵當然就是調用GetHere時候用的await了,如果去掉await,就會看到這樣的結果:
System.Threading.Tasks.Task`1[System.String] THERE
這一次GetHere的返回又跑到"THERE"的前頭了,因為沒有await就沒有阻塞,同時GetHere的本質也暴露了,返回值確確實實就是個Task。
這個時候再去看MoveNext裡頭的代碼就會發現,try塊裡的代碼再次變清淨了。。。而這一次WriteLine的泛型參數就變成了object。
關鍵中的關鍵在於,這一個版本中不存在TaskAwaiter,也不存在TaskAwaiter.GetResult(詳情參見上一段代碼第30行)。
GetResult的實現如下:
1 // System.Runtime.CompilerServices.TaskAwaiter<TResult> 2 [__DynamicallyInvokable, TargetedPatchingOptOut("Performance critical to inline across NGen image boundaries")] 3 public TResult GetResult() 4 { 5 TaskAwaiter.ValidateEnd(this.m_task); 6 return this.m_task.ResultOnSuccess; 7 }
這就是Task<TResult>轉變為TResult的地方了。
六、使用示例
扯了這麼多,扯得這麼亂,我自己都暈乎了。。。
到底該怎麼用嘛,看示例吧:
1 void PagePaint() 2 { 3 Console.WriteLine("Paint Start"); 4 Paint(); 5 Console.WriteLine("Paint End"); 6 } 7 8 void Paint() 9 { 10 Rendering("Header"); 11 Rendering(RequestBody()); 12 Rendering("Footer"); 13 } 14 15 string RequestBody() 16 { 17 Thread.Sleep(1000); 18 return "Body"; 19 }
假設有這麼個頁面布局的方法,依次對頭部、主體和底部進行渲染,頭部和底部是固定的內容,而主體需要額外請求。
這裡用Sleep模擬網絡延時,Rendering方法其實也就是對Console.WriteLine的簡單封裝而已。。。
PagePaint運行過後,結果是這樣的:
Paint Start Header Body Footer Paint End
挺正常的結果,但是Header渲染完以後頁面就阻塞了,這個時候用戶沒法對Header進行操作。
於是就進行這樣的修正:
1 async void Paint() 2 { 3 Rendering("Header"); 4 Rendering(await RequestBody()); 5 Rendering("Footer"); 6 } 7 8 async Task<string> RequestBody() 9 { 10 return await Task.Run(() => 11 { 12 Thread.Sleep(1000); 13 return "Body"; 14 }); 15 }
運行結果變成了這樣:
Paint Start Header Paint End Body Footer
這樣就能在Header出現之後不阻塞主線程了。
不過呢,Footer一直都得等到Body渲染完成後才能被渲染,這個邏輯現在看來還沒問題,因為底部要相對於主體進行布局。
然而我這時候又想給頁面加一個廣告,而且是fixed定位的那種,管啥頭部主體想蓋住就蓋住,你們在哪它不管。
比如這樣寫:
1 async void Paint() 2 { 3 Rendering(await RequestAds()); 4 Rendering("Header"); 5 Rendering(await RequestBody()); 6 Rendering("Footer"); 7 }
出現了很嚴重的問題,頭部都得等廣告加載好了才能渲染,這樣顯然是不對的。
所以應該改成這樣:
1 async void Paint() 2 { 3 PaintAds(); 4 Rendering("Header"); 5 Rendering(await RequestBody()); 6 Rendering("Footer"); 7 } 8 9 async void PaintAds() 10 { 11 string ads = await Task.Run(() => 12 { 13 Thread.Sleep(1000); 14 return "Ads"; 15 }); 16 Rendering(ads); 17 }
這樣的運行結果就算令人滿意了:
Paint Start Header Paint End Ads Body Footer
最後想說的是,看IL比看bytecode實在麻煩太多了,CSC對代碼動的手腳比JavaC多太多了。。。然而非常值得高興的是,MS所做的這一切,都是為了讓我們寫的代碼更簡潔易懂,我們需要做的,就是把這些語法糖好好地利用起來。