.NET 异步解说
前言
要了解 .NET 中的?async/await?機制,首先需要有操作系統原理的基礎,否則的話是很難理解清楚的,如果沒有這些基礎而試圖向他人解釋,大多也只是基于現象得到的錯誤猜想。
初看異步
說到異步大家應該都很熟悉了,2012 年 C# 5 引入了新的異步機制:Task,并且還有兩個新的關鍵字?await?和?async,這已經不是什么新鮮事了,而且如今這個異步機制已經被各大語言借鑒,如 JavaScript、TypeScript、Rust、C++ 等等。
下面給出一個簡單的對照:
| C# | Task<>、ValueTask<> | async、await |
| C++ | std::future<> | co_await |
| Rust | std::future::Future<> | .await |
| JavaScript、TypeScript | Promise<> | async、await |
當然,這里這并不是本文的重點,只是提一下,方便大家在有其他語言經驗的情況下(如果有),可以認識到 C# 中?Task?和?async/await?究竟是一個和什么可以相提并論的東西。
多線程編程
在該異步編程模型誕生之前,多線程編程模型是很多人所熟知的。一般來說,開發者會使用?Thread、std::thread?之類的東西作為線程的調度單位來進行多線程開發,每一個這樣的結構表示一個對等線程,線程之間采用互斥或者信號量等方式進行同步。
多線程對于科學計算速度提升等方面效果顯著,但是對于 IO 負荷的任務,例如從讀取文件或者 TCP 流,大多數方案只是分配一個線程進行讀取,讀取過程中阻塞該線程:
void Main() { ????while (true) ????{ ????????var client = socket.Accept(); ????????new Thread(() => ClientThread(client)).Start(); ????} } ? void ClientThread(Socket client) { ????var buffer = new byte[1024]; ????while (...) ????{ ????????// read and block ????????client.Read(buffer, 0, 1024); ????} } |
上述代碼中,Main?函數在接收客戶端之后即分配了一個新的用戶線程用于處理該客戶端,從客戶端接收數據。client.Read()?執行后,該線程即被阻塞,即使阻塞期間該線程沒有任何的操作,該用戶線程也不會被釋放,并被操作系統不斷輪轉調度,這顯然浪費了資源。
另外,如果線程數量多起來,頻繁在不同線程之間輪轉切換上下文,線程的上下文也不小,會浪費掉大量的性能。
異步編程
因此對于此工作內容(IO),我們在 Linux 上有了 epoll/io_uring 技術,在 Windows 上有了 IOCP 技術用以實現異步 IO 操作。
(這里插句題外話,吐槽一句,Linux 終于知道從 Windows 抄作業了。先前的 epoll 對比 IOCP 簡直不能打,被 IOCP 全面打壓,io_uring 出來了才好不容易能追上 IOCP,不過 IOCP 從 Windows Vista 時代開始每一代都有很大的優化,io_uring 能不能追得上還有待商榷)
這類 API 有一個共同的特性就是,在操作 IO 的時候,調用方控制權被讓出,等待 IO 操作完成之后恢復先前的上下文,重新被調度繼續運行。
所以表現就是這樣的:
假設我現在需要從某設備中讀取 1024 個字節長度的數據,于是我們將緩沖區的地址和內容長度等信息封裝好傳遞給操作系統之后我們就不管了,讀取什么的讓操作系統去做就好了。
操作系統在內核態下利用 DMA 等方式將數據讀取了 1024 個字節并寫入到我們先前的 buffer 地址下,然后切換到用戶態將從我們先前讓出控制權的位置,對其進行調度使其繼續執行。
你可以發現這么一來,在讀取數據期間就沒有任何的線程被阻塞,也不存在被頻繁調度和切換上下文的情況,只有當 IO 操作完成之后才會被重新調度并恢復先前讓出控制權時的上下文,使得后面的代碼繼續執行。
當然,這里說的是操作系統的異步 IO 實現方式,以便于讀者對異步這個行為本身進行理解,和 .NET 中的異步還是有區別,Task?本身和操作系統也沒什么關系。
Task (ValueTask)
說了這么久還是沒有解釋?Task?到底是個什么東西,從上面的分析就可以得出,Task?其實就是一個所謂的調度單位,每個異步任務被封裝為一個?Task?在 CLR 中被調度,而?Task?本身會運行在 CLR 中的預先分配好的線程池中。
總有很多人因為?Task?借助線程池執行而把?Task?歸結為多線程模型,這是完全錯誤的。
這個時候有人跳出來了,說:你看下面這個代碼
static async Task Main() { ????while (true) ????{ ????????Console.WriteLine(Environment.CurrentManagedThreadId); ????????await Task.Delay(1000); ????} } |
輸出的線程 ID 不一樣欸,你騙人,這明明就是多線程!對于這種言論,我也只能說這些人從原理上理解的就是錯誤的。
當代碼執行到?await?的時候,此時當前的控制權就已經被讓出了,當前線程并沒有在阻塞地等待延時結束;待?Task.Delay()?完畢后,CLR 從線程池當中挑起了一個先前分配好的已有的但是空閑的線程,將讓出控制權前的上下文信息恢復,使得該線程恰好可以從先前讓出的位置繼續執行下去。這個時候,可能挑到了先前讓出前所在的那個線程,導致前后線程 ID 一致;也有可能挑到了另外一個和之前不一樣的線程執行下面的代碼,使得前后的線程 ID 不一致。在此過程中并沒有任何的新線程被分配了出去。
當然,在 WPF 等地方,因為利用了?SynchronizationContext?對調度行為進行了控制,所以可以得到和上述不同的結論,和這個相關的還有?.ConfigureAwait()?的用法,但是這里不是本文重點,因此就不做展開。
在 .NET 中由于采用 stackless 的做法,這里需要用到 CPS 變換,大概是這么個流程:
using System; using System.Threading.Tasks; ? public class C { ????public async Task M() ????{ ????????var a = 1; ????????await Task.Delay(1000); ????????Console.WriteLine(a); ????} } |
編譯后:
public class C { ????[StructLayout(LayoutKind.Auto)] ????[CompilerGenerated] ????private struct <M>d__0 : IAsyncStateMachine ????{ ????????public int <>1__state; ? ????????public AsyncTaskMethodBuilder <>t__builder; ? ????????private int <a>5__2; ? ????????private TaskAwaiter <>u__1; ? ????????private void MoveNext() ????????{ ????????????int num = <>1__state; ????????????try ????????????{ ????????????????TaskAwaiter awaiter; ????????????????if (num != 0) ????????????????{ ????????????????????<a>5__2 = 1; ????????????????????awaiter = Task.Delay(1000).GetAwaiter(); ????????????????????if (!awaiter.IsCompleted) ????????????????????{ ????????????????????????num = (<>1__state = 0); ????????????????????????<>u__1 = awaiter; ????????????????????????<>t__builder.AwaitUnsafeOnCompleted(ref awaiter, ref this); ????????????????????????return; ????????????????????} ????????????????} ????????????????else ????????????????{ ????????????????????awaiter = <>u__1; ????????????????????<>u__1 = default(TaskAwaiter); ????????????????????num = (<>1__state = -1); ????????????????} ????????????????awaiter.GetResult(); ????????????????Console.WriteLine(<a>5__2); ????????????} ????????????catch (Exception exception) ????????????{ ????????????????<>1__state = -2; ????????????????<>t__builder.SetException(exception); ????????????????return; ????????????} ????????????<>1__state = -2; ????????????<>t__builder.SetResult(); ????????} ? ????????void IAsyncStateMachine.MoveNext() ????????{ ????????????//ILSpy generated this explicit interface implementation from .override directive in MoveNext ????????????this.MoveNext(); ????????} ? ????????[DebuggerHidden] ????????private void SetStateMachine(IAsyncStateMachine stateMachine) ????????{ ????????????<>t__builder.SetStateMachine(stateMachine); ????????} ? ????????void IAsyncStateMachine.SetStateMachine(IAsyncStateMachine stateMachine) ????????{ ????????????//ILSpy generated this explicit interface implementation from .override directive in SetStateMachine ????????????this.SetStateMachine(stateMachine); ????????} ????} ? ????[AsyncStateMachine(typeof(<M>d__0))] ????public Task M() ????{ ????????<M>d__0 stateMachine = default(<M>d__0); ????????stateMachine.<>t__builder = AsyncTaskMethodBuilder.Create(); ????????stateMachine.<>1__state = -1; ????????stateMachine.<>t__builder.Start(ref stateMachine); ????????return stateMachine.<>t__builder.Task; ????} } |
可以看到,原來的變量?a?被塞到了?<a>5__2?里面去(相當于備份上下文),Task 狀態的轉換后也是靠著調用?MoveNext(相當于狀態轉換后被重新調度)來接著驅動代碼執行的,里面的?num?就表示當前的狀態,num?如果為 0 表示 Task 完成了,于是接著執行下面的代碼?Console.WriteLine(<a>5__2);。
但是上面和經典的多線程編程的那一套一樣嗎?不一樣。
至于?ValueTask?是個什么玩意,官方發現,Task?由于本身是一個?class,在運行時如果頻繁反復的分配和回收會給 GC 造成不小的壓力,因此出了一個?ValueTask,這個東西是?struct,分配在棧上,這樣的話就不會給 GC 造成壓力了,減輕了開銷。不過也正因為?ValueTask?是會在棧上分配的值類型結構,因此提供的功能也不如?Task?全面。
Task.Run
由于 .NET 是允許有多個線程的,因此也提供了?Task.Run?這個方法,允許我們將 CPU bound 的任務放在上述的線程池之中的某個線程上執行,并且允許我們將該負載作為一個?Task?進行管理,僅在這一點才和多線程的采用線程池的編程比較像。
對于瀏覽器環境(v8),這個時候是完全沒有多線程這一說的,因此你開的新的?Promise?其實是后面利用事件循環機制,將該微任務以異步的方式執行。
想一想在 JavaScript 中,Promise?是怎么用的:
let p = new Promise((resolve, reject) => { ????// do something ????let success = true; ????let result = 123456; ? ????if (success) { ????????resolve(result); ????} ????else { ????????reject("failed"); ????} }) |
然后調用:
1 2 | let r = await p; console.log(r); // 輸出 123456 |
你只需要把這一套背后的驅動器:事件循環隊列,替換成 CLR 的線程池,就差不多是 .NET 的?Task?相對 JavaScript 的?Promise?的工作方式了。
如果你把 CLR 線程池線程數量設置為 1,那就和 JavaScript 這套幾乎差不多了(雖然實現上還是有差異)。
這時有人要問了:“我在 Task.Run 里面套了好幾層 Task.Run,可是為什么層數深了之后里面的不執行了呢?” 這是因為上面所說的線程池被耗盡了,后面的?Task?還在排著隊等待被調度。
自己封裝異步邏輯
了解了上面的東西之后,相信對 .NET 中的異步機制應該理解得差不多了,可以看出來這一套是名副其實的 coroutine,并且在實現上是 stackless 的。至于有的人說的什么狀態機什么的,只是實現過程中利用的手段而已,并不是什么重要的東西。
那我們要怎么樣使用?Task?來編寫我們自己的異步代碼呢?
事件驅動其實也可以算是一種異步模型,例如以下情景:
A?函數調用?B?函數,調用發起后就直接返回不管了(BeginInvoke),B?函數執行完成后觸發事件執行?C?函數。
private event Action CompletedEvent; ? void A() { ????CompletedEvent += C; ????Console.WriteLine("begin"); ????((Action)B).BeginInvoke(); } ? void B() { ????Console.WriteLine("running"); ????CompletedEvent?.Invoke(); } ? void C() { ????Console.WriteLine("end"); } |
那么我們現在想要做一件事,就是把上面的事件驅動改造為利用?async/await?的異步編程模型,改造后的代碼就是簡單的:
async Task A() { ????Console.WriteLine("begin"); ????await B(); ????Console.WriteLine("end"); } ? Task B() { ????Console.WriteLine("running"); ????return Task.CompletedTask; } |
你可以看到,原本?C?函數的內容被放到了?A?調用?B?的下面,為什么呢?其實很簡單,因為這里?await B();?這一行以后的內容,本身就可以理解為?B?函數的回調了,只不過在內部實現上,不是直接從?B?進行調用的回調,而是?A?先讓出控制權,B?執行完成后,CLR 切換上下文,將?A?調度回來繼續執行剩下的代碼。
如果事件相關的代碼已經確定不可改動(即不能改動 B 函數),我們想將其封裝為異步調用的模式,那只需要利用?TaskCompletionSource?即可:
private event Action CompletedEvent; ? async Task A() { ????// 因為 TaskCompletionSource 要求必須有一個泛型參數 ????// 因此就隨便指定了一個 bool ????// 本例中其實是不需要這樣的一個結果的 ????// 需要注意的是從 .NET 5 開始 ????// TaskCompletionSource 不再強制需要泛型參數 ????var tsc = new TaskCompletionSource<bool>(); ????// 隨便寫一個結果作為 Task 的結果 ????CompletedEvent += () => tsc.SetResult(false); ? ????Console.WriteLine("begin"); ????((Action)B).BeginInvoke(); ????await tsc.Task; ????Console.WriteLine("end"); } ? void B() { ????Console.WriteLine("running"); ????CompletedEvent?.Invoke(); } |
順便提一句,這個?TaskCompletionSource<T>?其實和 JavaScript 中的?Promise<T>?更像。SetResult()?方法對應?resolve(),SetException()?方法對應?reject()。.NET 比 JavaScript 還多了一個取消狀態,因此還可以?SetCancelled()?表示任務被取消了。
同步方式調用異步代碼
說句真的,一般能有這個需求,都說明你的代碼寫的有問題,但是如果你無論如何都想以阻塞的方式去等待一個異步任務完成的話:
1 2 | Task t = ... t.GetAwaiter().GetResult(); |
祝你好運,這相當于,t?中的異步任務開始執行后,你將當前線程阻塞,然后等到?t?完成之后再喚醒,可以說是:毫無意義,而且很有可能因為代碼編寫不當而導致死鎖的發生。
void async 是什么?
最后有人會問了,函數可以寫?async Task Foo(),還可以寫?async void Bar(),這有什么區別呢?
對于上述代碼,我們一般調用的時候,分別這么寫:
1 2 | await Foo(); Bar(); |
可以發現,誒這個?Bar?函數不需要?await?誒。為什么呢?
其實這和用以下方式調用?Foo?是一樣的:
1 | _ = Foo(); |
換句話說就是調用后瞬間就直接拋掉不管了,不過這樣你也就沒法知道這個異步任務的狀態和結果了。
await 必須配合 Task/ValueTask 才能用嗎?
當然不是。
在 C# 中只要你的類中包含?GetAwaiter()?方法和?bool IsCompleted?屬性,并且?GetAwaiter()?返回的東西包含一個?GetResult()?方法、一個?bool IsCompleted?屬性和實現了?INotifyCompletion,那么這個類的對象就是可以?await?的。
public class MyTask<T> { ????public MyAwaiter<T> GetAwaiter() ????{ ????????return new MyAwaiter<T>(); ????} } ? public class MyAwaiter<T> : INotifyCompletion { ????public bool IsCompleted { get; private set; } ????public T GetResult() ????{ ????????throw new NotImplementedException(); ????} ????public void OnCompleted(Action continuation) ????{ ????????throw new NotImplementedException(); ????} } ? public class Program { ????static async Task Main(string[] args) ????{ ????????var obj = new MyTask<int>(); ????????await obj; ????} } |
結語
本文至此就結束了,感興趣的小伙伴可以多多學習一下操作系統原理,對 CLR 感興趣也可以去研究其源代碼:https://github.com/dotnet/runtime?。
.NET 的異步和線程密不可分,但是和多線程編程方式和思想是有本質不同的,也希望大家不要將異步和多線程混淆了,而這有聯系也有區別。
從現象猜測本質是大忌,可能解釋的通但是終究只是偶然現象,而且從原理上看也是完全錯誤的,甚至官方的實現代碼稍微變一下可能立馬就無法解釋的通了。
總之,通過本文希望大家能對異步和 .NET 中的異步有一個更清晰的理解。
創作挑戰賽新人創作獎勵來咯,堅持創作打卡瓜分現金大獎總結
- 上一篇: Magicodes.IE 2.4发布
- 下一篇: Java 生态碎片化 和 .NET生态的