深入理解.NET中的異步
一、前言
網(wǎng)上有很多關(guān)于 .NET async
/await
的介紹,但是很遺憾,很少有正確的,甚至說(shuō)大多都是“從現(xiàn)象編原理”都不過(guò)分。
最典型的比如通過(guò)前后線程 ID 來(lái)推斷其工作方式、在 async
方法中用 Thread.Sleep
來(lái)解釋 Task
機(jī)制而導(dǎo)出多線程模型的結(jié)論、在 Task.Run
中包含 IO bound 任務(wù)來(lái)推出這是開(kāi)了一個(gè)多線程在執(zhí)行任務(wù)的結(jié)論等等。
看上去似乎可以解釋的通,可是很遺憾,無(wú)論是從原理還是結(jié)論上看都是錯(cuò)誤的。
要了解 .NET 中的 async
/await
機(jī)制,首先需要有操作系統(tǒng)原理的基礎(chǔ),否則的話是很難理解清楚的,如果沒(méi)有這些基礎(chǔ)而試圖向他人解釋,大多也只是基于現(xiàn)象得到的錯(cuò)誤猜想。
二、初看異步
說(shuō)到異步大家應(yīng)該都很熟悉了,2012 年 C# 5 引入了新的異步機(jī)制:Task
,并且還有兩個(gè)新的關(guān)鍵字 await
和 async
,這已經(jīng)不是什么新鮮事了,而且如今這個(gè)異步機(jī)制已經(jīng)被各大語(yǔ)言借鑒,如 JavaScript、TypeScript、Rust、C++ 等等。
下面給出一個(gè)簡(jiǎn)單的對(duì)照:
語(yǔ)言 | 調(diào)度單位 | 關(guān)鍵字/方法 |
---|---|---|
C# | Task<>、ValueTask<> | async、await |
C++ | std::future<> | co_await |
Rust | std::future::Future<> | .await |
JavaScript、TypeScript | Promise<> | async、await |
當(dāng)然,這里這并不是本文的重點(diǎn),只是提一下,方便大家在有其他語(yǔ)言經(jīng)驗(yàn)的情況下(如果有),可以認(rèn)識(shí)到 C# 中 Task
和 async
/await
究竟是一個(gè)和什么可以相提并論的東西。
三、多線程編程
在該異步編程模型誕生之前,多線程編程模型是很多人所熟知的。一般來(lái)說(shuō),開(kāi)發(fā)者會(huì)使用 Thread
、std::thread
之類的東西作為線程的調(diào)度單位來(lái)進(jìn)行多線程開(kāi)發(fā),每一個(gè)這樣的結(jié)構(gòu)表示一個(gè)對(duì)等線程,線程之間采用互斥或者信號(hào)量等方式進(jìn)行同步。
多線程對(duì)于科學(xué)計(jì)算速度提升等方面效果顯著,但是對(duì)于 IO 負(fù)荷的任務(wù),例如從讀取文件或者 TCP 流,大多數(shù)方案只是分配一個(gè)線程進(jìn)行讀取,讀取過(guò)程中阻塞該線程:
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
函數(shù)在接收客戶端之后即分配了一個(gè)新的用戶線程用于處理該客戶端,從客戶端接收數(shù)據(jù)。client.Read()
執(zhí)行后,該線程即被阻塞,即使阻塞期間該線程沒(méi)有任何的操作,該用戶線程也不會(huì)被釋放,并被操作系統(tǒng)不斷輪轉(zhuǎn)調(diào)度,這顯然浪費(fèi)了資源。
另外,如果線程數(shù)量多起來(lái),頻繁在不同線程之間輪轉(zhuǎn)切換上下文,線程的上下文也不小,會(huì)浪費(fèi)掉大量的性能。
四、異步編程
因此對(duì)于此工作內(nèi)容(IO),我們?cè)?Linux 上有了 epoll/io_uring 技術(shù),在 Windows 上有了 IOCP 技術(shù)用以實(shí)現(xiàn)異步 IO 操作。
(這里插句題外話,吐槽一句,Linux 終于知道從 Windows 抄作業(yè)了。先前的 epoll 對(duì)比 IOCP 簡(jiǎn)直不能打,被 IOCP 全面打壓,io_uring 出來(lái)了才好不容易能追上 IOCP,不過(guò) IOCP 從 Windows Vista 時(shí)代開(kāi)始每一代都有很大的優(yōu)化,io_uring 能不能追得上還有待商榷)
這類 API 有一個(gè)共同的特性就是,在操作 IO 的時(shí)候,調(diào)用方控制權(quán)被讓出,等待 IO 操作完成之后恢復(fù)先前的上下文,重新被調(diào)度繼續(xù)運(yùn)行。
所以表現(xiàn)就是這樣的:
假設(shè)我現(xiàn)在需要從某設(shè)備中讀取 1024 個(gè)字節(jié)長(zhǎng)度的數(shù)據(jù),于是我們將緩沖區(qū)的地址和內(nèi)容長(zhǎng)度等信息封裝好傳遞給操作系統(tǒng)之后我們就不管了,讀取什么的讓操作系統(tǒng)去做就好了。
操作系統(tǒng)在內(nèi)核態(tài)下利用 DMA 等方式將數(shù)據(jù)讀取了 1024 個(gè)字節(jié)并寫入到我們先前的 buffer 地址下,然后切換到用戶態(tài)將從我們先前讓出控制權(quán)的位置,對(duì)其進(jìn)行調(diào)度使其繼續(xù)執(zhí)行。
你可以發(fā)現(xiàn)這么一來(lái),在讀取數(shù)據(jù)期間就沒(méi)有任何的線程被阻塞,也不存在被頻繁調(diào)度和切換上下文的情況,只有當(dāng) IO 操作完成之后才會(huì)被重新調(diào)度并恢復(fù)先前讓出控制權(quán)時(shí)的上下文,使得后面的代碼繼續(xù)執(zhí)行。
當(dāng)然,這里說(shuō)的是操作系統(tǒng)的異步 IO 實(shí)現(xiàn)方式,以便于讀者對(duì)異步這個(gè)行為本身進(jìn)行理解,和 .NET 中的異步還是有區(qū)別,Task
本身和操作系統(tǒng)也沒(méi)什么關(guān)系。
五、Task (ValueTask)
說(shuō)了這么久還是沒(méi)有解釋 Task
到底是個(gè)什么東西,從上面的分析就可以得出,Task
其實(shí)就是一個(gè)所謂的調(diào)度單位,每個(gè)異步任務(wù)被封裝為一個(gè) Task
在 CLR 中被調(diào)度,而 Task
本身會(huì)運(yùn)行在 CLR 中的預(yù)先分配好的線程池中。
總有很多人因?yàn)?Task
借助線程池執(zhí)行而把 Task
歸結(jié)為多線程模型,這是完全錯(cuò)誤的。
這個(gè)時(shí)候有人跳出來(lái)了,說(shuō):你看下面這個(gè)代碼
static async Task Main() { while (true) { Console.WriteLine(Environment.CurrentManagedThreadId); await Task.Delay(1000); } }
輸出的線程 ID 不一樣欸,你騙人,這明明就是多線程!對(duì)于這種言論,我也只能說(shuō)這些人從原理上理解的就是錯(cuò)誤的。
當(dāng)代碼執(zhí)行到 await
的時(shí)候,此時(shí)當(dāng)前的控制權(quán)就已經(jīng)被讓出了,當(dāng)前線程并沒(méi)有在阻塞地等待延時(shí)結(jié)束;待 Task.Delay()
完畢后,CLR 從線程池當(dāng)中挑起了一個(gè)先前分配好的已有的但是空閑的線程,將讓出控制權(quán)前的上下文信息恢復(fù),使得該線程恰好可以從先前讓出的位置繼續(xù)執(zhí)行下去。這個(gè)時(shí)候,可能挑到了先前讓出前所在的那個(gè)線程,導(dǎo)致前后線程 ID 一致;也有可能挑到了另外一個(gè)和之前不一樣的線程執(zhí)行下面的代碼,使得前后的線程 ID 不一致。在此過(guò)程中并沒(méi)有任何的新線程被分配了出去。
當(dāng)然,在 WPF 等地方,因?yàn)槔昧?SynchronizationContext
對(duì)調(diào)度行為進(jìn)行了控制,所以可以得到和上述不同的結(jié)論,和這個(gè)相關(guān)的還有 .ConfigureAwait()
的用法,但是這里不是本文重點(diǎn),因此就不做展開(kāi)。
在 .NET 中由于采用 stackless 的做法,這里需要用到 CPS 變換,大概是這么個(gè)流程:
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; } }
可以看到,原來(lái)的變量 a
被塞到了 <a>5__2
里面去(相當(dāng)于備份上下文),Task 狀態(tài)的轉(zhuǎn)換后也是靠著調(diào)用 MoveNext
(相當(dāng)于狀態(tài)轉(zhuǎn)換后被重新調(diào)度)來(lái)接著驅(qū)動(dòng)代碼執(zhí)行的,里面的 num
就表示當(dāng)前的狀態(tài),num
如果為 0 表示 Task 完成了,于是接著執(zhí)行下面的代碼 Console.WriteLine(<a>5__2);
。
但是上面和經(jīng)典的多線程編程的那一套一樣嗎?不一樣。
至于 ValueTask
是個(gè)什么玩意,官方發(fā)現(xiàn),Task
由于本身是一個(gè) class
,在運(yùn)行時(shí)如果頻繁反復(fù)的分配和回收會(huì)給 GC 造成不小的壓力,因此出了一個(gè) ValueTask
,這個(gè)東西是 struct
,分配在棧上,這樣的話就不會(huì)給 GC 造成壓力了,減輕了開(kāi)銷。不過(guò)也正因?yàn)?ValueTask
是會(huì)在棧上分配的值類型結(jié)構(gòu),因此提供的功能也不如 Task
全面。
六、Task.Run
由于 .NET 是允許有多個(gè)線程的,因此也提供了 Task.Run
這個(gè)方法,允許我們將 CPU bound 的任務(wù)放在上述的線程池之中的某個(gè)線程上執(zhí)行,并且允許我們將該負(fù)載作為一個(gè) Task
進(jìn)行管理,僅在這一點(diǎn)才和多線程的采用線程池的編程比較像。
對(duì)于瀏覽器環(huán)境(v8),這個(gè)時(shí)候是完全沒(méi)有多線程這一說(shuō)的,因此你開(kāi)的新的 Promise
其實(shí)是后面利用事件循環(huán)機(jī)制,將該微任務(wù)以異步的方式執(zhí)行。
想一想在 JavaScript 中,Promise
是怎么用的:
let p = new Promise((resolve, reject) => { // do something let success = true; let result = 123456; if (success) { resolve(result); } else { reject("failed"); } })
然后調(diào)用:
let r = await p; console.log(r); // 輸出 123456
你只需要把這一套背后的驅(qū)動(dòng)器:事件循環(huán)隊(duì)列,替換成 CLR 的線程池,就差不多是 .NET 的 Task
相對(duì) JavaScript 的 Promise
的工作方式了。
如果你把 CLR 線程池線程數(shù)量設(shè)置為 1,那就和 JavaScript 這套幾乎差不多了(雖然實(shí)現(xiàn)上還是有差異)。
這時(shí)有人要問(wèn)了:“我在 Task.Run 里面套了好幾層 Task.Run,可是為什么層數(shù)深了之后里面的不執(zhí)行了呢?” 這是因?yàn)樯厦嫠f(shuō)的線程池被耗盡了,后面的 Task
還在排著隊(duì)等待被調(diào)度。
七、自己封裝異步邏輯
了解了上面的東西之后,相信對(duì) .NET 中的異步機(jī)制應(yīng)該理解得差不多了,可以看出來(lái)這一套是名副其實(shí)的 coroutine,并且在實(shí)現(xiàn)上是 stackless 的。至于有的人說(shuō)的什么狀態(tài)機(jī)什么的,只是實(shí)現(xiàn)過(guò)程中利用的手段而已,并不是什么重要的東西。
那我們要怎么樣使用 Task
來(lái)編寫我們自己的異步代碼呢?
事件驅(qū)動(dòng)其實(shí)也可以算是一種異步模型,例如以下情景:
A
函數(shù)調(diào)用 B
函數(shù),調(diào)用發(fā)起后就直接返回不管了(BeginInvoke
),B
函數(shù)執(zhí)行完成后觸發(fā)事件執(zhí)行 C
函數(shù)。
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"); }
那么我們現(xiàn)在想要做一件事,就是把上面的事件驅(qū)動(dòng)改造為利用 async
/await
的異步編程模型,改造后的代碼就是簡(jiǎn)單的:
async Task A() { Console.WriteLine("begin"); await B(); Console.WriteLine("end"); } Task B() { Console.WriteLine("running"); return Task.CompletedTask; }
你可以看到,原本 C
函數(shù)的內(nèi)容被放到了 A
調(diào)用 B
的下面,為什么呢?其實(shí)很簡(jiǎn)單,因?yàn)檫@里 await B();
這一行以后的內(nèi)容,本身就可以理解為 B
函數(shù)的回調(diào)了,只不過(guò)在內(nèi)部實(shí)現(xiàn)上,不是直接從 B
進(jìn)行調(diào)用的回調(diào),而是 A
先讓出控制權(quán),B
執(zhí)行完成后,CLR 切換上下文,將 A
調(diào)度回來(lái)繼續(xù)執(zhí)行剩下的代碼。
如果事件相關(guān)的代碼已經(jīng)確定不可改動(dòng)(即不能改動(dòng) B 函數(shù)),我們想將其封裝為異步調(diào)用的模式,那只需要利用 TaskCompletionSource
即可:
private event Action CompletedEvent; async Task A() { // 因?yàn)?TaskCompletionSource 要求必須有一個(gè)泛型參數(shù) // 因此就隨便指定了一個(gè) bool // 本例中其實(shí)是不需要這樣的一個(gè)結(jié)果的 // 需要注意的是從 .NET 5 開(kāi)始 // TaskCompletionSource 不再?gòu)?qiáng)制需要泛型參數(shù) var tsc = new TaskCompletionSource<bool>(); // 隨便寫一個(gè)結(jié)果作為 Task 的結(jié)果 CompletedEvent += () => tsc.SetResult(false); Console.WriteLine("begin"); ((Action)B).BeginInvoke(); await tsc.Task; Console.WriteLine("end"); } void B() { Console.WriteLine("running"); CompletedEvent?.Invoke(); }
順便提一句,這個(gè) TaskCompletionSource<T>
其實(shí)和 JavaScript 中的 Promise<T>
更像。SetResult()
方法對(duì)應(yīng) resolve()
,SetException()
方法對(duì)應(yīng) reject()
。.NET 比 JavaScript 還多了一個(gè)取消狀態(tài),因此還可以 SetCancelled()
表示任務(wù)被取消了。
八、同步方式調(diào)用異步代碼
說(shuō)句真的,一般能有這個(gè)需求,都說(shuō)明你的代碼寫的有問(wèn)題,但是如果你無(wú)論如何都想以阻塞的方式去等待一個(gè)異步任務(wù)完成的話:
Task t = ... t.GetAwaiter().GetResult();
祝你好運(yùn),這相當(dāng)于,t
中的異步任務(wù)開(kāi)始執(zhí)行后,你將當(dāng)前線程阻塞,然后等到 t
完成之后再喚醒,可以說(shuō)是:毫無(wú)意義,而且很有可能因?yàn)榇a編寫不當(dāng)而導(dǎo)致死鎖的發(fā)生。
九、void async 是什么?
最后有人會(huì)問(wèn)了,函數(shù)可以寫 async Task Foo()
,還可以寫 async void Bar()
,這有什么區(qū)別呢?
對(duì)于上述代碼,我們一般調(diào)用的時(shí)候,分別這么寫:
await Foo(); Bar();
可以發(fā)現(xiàn),誒這個(gè) Bar
函數(shù)不需要 await
誒。為什么呢?
其實(shí)這和用以下方式調(diào)用 Foo
是一樣的:
_ = Foo();
換句話說(shuō)就是調(diào)用后瞬間就直接拋掉不管了,不過(guò)這樣你也就沒(méi)法知道這個(gè)異步任務(wù)的狀態(tài)和結(jié)果了。
十、await 必須配合 Task/ValueTask 才能用嗎?
當(dāng)然不是。
在 C# 中只要你的類中包含 GetAwaiter()
方法和 bool IsCompleted
屬性,并且 GetAwaiter()
返回的東西包含一個(gè) GetResult()
方法、一個(gè) bool IsCompleted
屬性和實(shí)現(xiàn)了 INotifyCompletion
,那么這個(gè)類的對(duì)象就是可以 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; } }
十一、結(jié)語(yǔ)
本文至此就結(jié)束了,感興趣的小伙伴可以多多學(xué)習(xí)一下操作系統(tǒng)原理,對(duì) CLR 感興趣也可以去研究其源代碼:https://github.com/dotnet/runtime 。
.NET 的異步和線程密不可分,但是和多線程編程方式和思想是有本質(zhì)不同的,也希望大家不要將異步和多線程混淆了,而這有聯(lián)系也有區(qū)別。
從現(xiàn)象猜測(cè)本質(zhì)是大忌,可能解釋的通但是終究只是偶然現(xiàn)象,而且從原理上看也是完全錯(cuò)誤的,甚至官方的實(shí)現(xiàn)代碼稍微變一下可能立馬就無(wú)法解釋的通了。
總之,通過(guò)本文希望大家能對(duì)異步和 .NET 中的異步有一個(gè)更清晰的理解。
以上就是深入理解.NET中的異步的詳細(xì)內(nèi)容,更多關(guān)于.NET 異步的資料請(qǐng)關(guān)注腳本之家其它相關(guān)文章!
- ASP.Net中的async+await異步編程的實(shí)現(xiàn)
- 詳解ASP.NET MVC下的異步Action的定義和執(zhí)行原理
- .NET異步編程總結(jié)----四種實(shí)現(xiàn)模式代碼總結(jié)
- asp.net下的異步加載
- asp.net+jquery.form實(shí)現(xiàn)圖片異步上傳的方法(附j(luò)query.form.js下載)
- .NET中的async和await關(guān)鍵字使用及Task異步調(diào)用實(shí)例
- .NET 4.5 異步IO 相關(guān)實(shí)例
- .net4.5使用async和await異步編程實(shí)例
相關(guān)文章
C# TextBox 擴(kuò)展方法數(shù)據(jù)驗(yàn)證詳細(xì)說(shuō)明
C# TextBox 擴(kuò)展方法數(shù)據(jù)驗(yàn)證詳細(xì)說(shuō)明,需要的朋友可以參考一下2013-03-03在unity腳本中控制Inspector面板的參數(shù)操作
這篇文章主要介紹了在unity腳本中控制Inspector面板的參數(shù)操作,具有很好的參考價(jià)值,希望對(duì)大家有所幫助。一起跟隨小編過(guò)來(lái)看看吧2021-04-04解析c#顯示友好時(shí)間的實(shí)現(xiàn)代碼
本篇文章是對(duì)c#中顯示友好時(shí)間的實(shí)現(xiàn)代碼進(jìn)行了介紹,需要的朋友參考下2013-05-05基于WPF實(shí)現(xiàn)簡(jiǎn)單的下拉篩選控件
這篇文章主要為大家詳細(xì)介紹了如何基于WPF實(shí)現(xiàn)簡(jiǎn)單的下拉篩選控件,文中的示例代碼講解詳細(xì),對(duì)我們學(xué)習(xí)或工作有一定幫助,感興趣的小伙伴可以了解一下2023-04-04C# Socket連接請(qǐng)求超時(shí)機(jī)制實(shí)現(xiàn)代碼分享
這篇文章主要介紹了C# Socket連接請(qǐng)求超時(shí)機(jī)制實(shí)現(xiàn),下面提供代碼分享,大家可以參考使用2013-12-12