注: 最近Java 19引入的虛擬線程火熱,還有很多人羨慕 go的 coroutine,很多同學一直有一個疑問: C# 有 虛擬線程或者 coroutine嗎,下面的這個回答可以解決問題。這里節選的是知乎上的hez2010 的高贊回答:https://www.zhihu.com/question/554133167/answer/2690808608?
C# 的 async/await 其實就是一個通用的異步編程模型,編譯器會對 async 方法采用 CPS 變換,以 await 為分界線將方法進行拆分,然后使用一個狀態機來驅動執行。用現在比較潮的說法就是 stackless coroutine。
例如說以下代碼:
編譯完之后就變成類似這樣的玩意(極度簡化版)
其中的 Scheduler 是交由用戶自己實現的,.NET 默認用線程池來做 Scheduler,但并不妨礙一些框架可以自行設計一個事件循環來做成 JavaScript 的 Promise。.NET 還提供了 AsyncMethodBuilder 的 type trait 來讓你自己實現這個狀態機和你自己的 Task 類型,因此你可以最大程度發揮想象來編寫你想控制的一切。
你可以發現 async/await 本身并沒有涉及到任何線程、調度相關的細節內容。換言之,async/await 是一個純編譯器特性。C# 在推出 async/await 的時候,考慮到方便用戶的使用內置了 Task 和基于線程池的調度器滿足一般使用,我們一般叫這個為 async runtime,其實就是根據上面所說的 type trait 實現出來的東西,后來為了減少分配又有了 ValueTask,以及 ASP .NET Core 為了性能又自己實現了個 PooledValueTask,Blazor WebAssembly 又因為瀏覽器平臺不支持多線程于是實現了個基于事件循環的單線程 Scheduler,Unity 社區還有自己做的 UniTask 等等。當然,用戶自己也可以實現自己的 async runtime。而 C++、Rust 則一開始只是將其作為語言特性推出,async runtime 則完全交給用戶實現,標準庫里除了一些 async primitives 之外什么都不帶,于是 Rust 里出現了 async-std 和 tokio 等 async runtime,而 C++ 的話社區實現了一大堆的 async runtime,然后標準委員會還沒吵出要怎么實現 STL 里的 async runtime。
啊,扯遠了,繼續說優缺點。
stackless coroutine 不需要寄存器上下文備份和恢復,需要引用什么局部變量只需要很簡單的將它們提升到這個狀態機的閉包里即可,而且是需要什么才提升什么,如果只有一個 int 需要引用,那就只需要一個裝箱后的 int 那么多字節的堆內存,大概也就十幾 B。此外,它遵循正常的分支判斷和函數調用,因此不會打斷 CPU 的控制流,分支預測和緩存友好。
而類似 goroutine 的 stackful coroutine 方案則是在用戶態自己模擬系統線程來做了個用戶態線程,然后在運行時上自己調度,于是每一個 goroutine 都需要創建自己的 stack 用來保存上下文(對應線程的 stack),這一個 stack 就是至少 8K,開多了占用會變得非常大。而且這種方案需要操作寄存器來進行上下文的備份和恢復,會打斷正常的 CPU 控制流,使得分支預測失誤和緩存缺失問題非常嚴重。唯一的好處就是不需要修改代碼,對已有老代碼改造起來非常方便。但是如果不存在已有老代碼這種東西的話,這種方案可以說一點優勢都沒有,我始終認為 stackful coroutine 只是一個在已有老代碼已經不方便修改了才應該使用的東西,否則完全沒有意義。
綜上所述,async/await 就是一個通用的異步編程方案,這個“異步”和它的調度方式是不綁定的,由用戶想怎么實現就怎么實現,因此可以做到最高的靈活度;并且由于不需要維護所謂的虛擬線程的 stack,只需要將用到的局部變量提升到閉包內即可,資源占用也很低;并且由于不打斷 CPU 控制流,性能更高,而且一些情況下是可以直接把 coroutine inline 掉的。
缺點自然也很明顯,那就是代碼不好編寫,而且對代碼有侵入性。如果要用 async/await,那必然需要將所有需要改造的阻塞調用改成 async/await,否則就約等于沒有異步。
最后提一嘴性能問題,網上大為流傳的 Go vs C#, part 1: Goroutines vs Async-Await | by Alex Yakunin | Medium,其中給 C# 測試代碼加入了毫無意義的 await Task.YieldAsync(),帶來了大量無意義的調度開銷,這等價于給測試代碼里面加 Thread.Sleep(1),何必呢。況且這個測試當時甚至用的是幾乎一點優化都沒有的 .NET Core 1.1,把原文用的 Go 和 .NET 升級到今天的 Go 1.19 和 .NET 7.0,C# 帶著 await Task.YieldAsync() 這一行故意負優化都能跑到跟 Go 不相上下,去掉之后更是只需要不到 Go 一半的時間。
而且由于前面所說的 stackful coroutine 內存問題,沒有大內存是沒法流暢跑完 Go 的測試的,因為 goroutine 的內存開銷非常大,在這個測試中需要耗費幾個 G 的內存,如果內存不夠會導致大量 GC 使得效率非常低下;對應 C# 的版本幾乎沒有什么內存消耗,十幾 M 內存就夠跑完了。