在 .NET 的世界裡,從 .NET Framework 4.0 問世之前,你只能使用 Thread、APM (Asynchronous Programming Model) 或 EAP (Event-based Asynchronous Pattern) 開發非同步程式碼,其實對不熟悉非同步開發的人來說,是有一點小小的進入門檻。但從 .NET Framework 4.0 開始推出的 TAP (Task-based Asynchronous Pattern) 這種以 Task 為基礎的非同步模式,不但可以透過 async/await 大幅簡化非同步開發的思維模式,還能產生更容易閱讀、好維護的程式碼。今天這篇文章,我將介紹 .NET Core 2.0 搭配 C# 7.0 推出的一個 ValueTask<TResult> 結構,並說明他跟 Task<TResult> 類別的不同之處!
注意: ValueTask<TResult> 只能用在 .NET Core / .NET 的應用程式中,若你用 .NET Framework 是找不到這個型別的。
效能差異
首先,這兩個型別有個本質上的差異:
-
Task<TResult> 類別
所有的類別(class)都是一種參考型別 (Reference Type),這意味著,當你在執行一個標示 async
的非同步方法時,若該方法會透過 Task<TResult>
物件立即回應一個值,你就必須先在 Heap 記憶體中先將 Task 物件保存,然後再將該物件的記憶體位址參考放入 Stack 記憶體中,而最後保存在變數中的資料其實是該物件的記憶體位址參考。因此,你要透過 Task 物件取得執行結果時,會有一點點點點的額外開銷(overhead)。
-
ValueTask<TResult> 結構
所有的結構(struct)都是一種實質型別 (Value Type),這意味著,當你在執行一個標示 async
的非同步方法時,若該方法會透過 ValueTask<TResult>
物件立即回應一個值,由於是實質型別的關係,這個 ValueTask<TResult>
物件會直接儲存在 Stack 記憶體中進行操作。簡單來說,就是記憶體操作的效率比較好!
有這麼好的東西,怎麼不用爆?正所謂理想很豐滿,現實很骨感,為了能夠理解「理想」與「現實」的差距,我們肯定要來效能比較一下!
我使用 BenchmarkDotNet 來評比一下彼此之間的效能差異:
#LINQPad optimize+ // Enable compiler optimizations
void Main()
{
Util.AutoScrollResults = true;
BenchmarkRunner.Run<TaskAndValueTaskComparsion>();
}
[ShortRunJob]
public class TaskAndValueTaskComparsion
{
[Benchmark]
public ValueTask<int> RunValueTaskWithNew()
{
return new ValueTask<int>(1);
}
[Benchmark]
public Task<int> RunTaskFromResult()
{
return Task.FromResult(1);
}
}
結果效能評比的結果竟然是 Task<int>
幾乎完勝 ValueTask<int>
耶,意思也就是說,使用 ValueTask<int>
並沒有比較快啊!🔥
驚不驚喜?意不意外?😆
老實說,我認真覺得要學好非同步程式開發不是一件很容易的事,在非同步的世界裡,有太多的變因(或迷因?),有時候會因為硬體環境、軟體設定不同,就會有截然不同的執行結果,以致於許多人在研讀龐大文件時,經常無法有效的吸收知識,更嚴重的就是建立了錯誤的觀念而不自知。在多執行緒的執行環境下,有很多觀念需要事先建立,你才有辦法好好的、正確的思考,也才有辦法舉一反三,做出正確的非同步設計決策。也因此我設計了 C# 開發實戰:非同步程式開發技巧 這堂課程,用兩天的時間,幫助你徹底搞懂 .NET 的非同步程式開發技巧!👍
我從 ValueTask<TResult> 官方文件看到以下說明:
As such, the default choice for any asynchronous method should be to return a Task or Task<TResult>. Only if performance analysis proves it worthwhile should a ValueTask<TResult> be used instead of a Task<TResult>. The non generic version of ValueTask is not recommended for most scenarios. The CompletedTask property should be used to hand back a successfully completed singleton in the case where a method returning a Task completes synchronously and successfully.
簡單翻譯一下重點知識:
-
所有非同步方法都應該使用 Task 或 Task<TResult> 當作非同步方法的預設回傳型別。
所以並不是什麼程式都可以用 ValueTask<TResult> 啦!
-
除了有明確的數據證明用 ValueTask<TResult> 的效能比 Task 好,你才去用,否則不要!
換句話說,背後原理很複雜,我不想跟你說這麼多,真的有數據證明效能 ValueTask<TResult> 比較快你才考慮用看看。
-
非泛型的 ValueTask 在大多數情境下都是不建議使用的。
雖然還是有個 ValueTask.CompletedTask 屬性可用,但你只會用在只想設定非同步工作完成的情境。
好吧!回歸正題!為什麼 Task<int>
幾乎完勝 ValueTask<int>
呢?這不合理啊!沒錯,確實不合理,我們來看一下 Task.FromResult 的原始碼,當設定非同步結果的物件屬於 實質型別 (Value Type) 的時候,他會對一些常見的數值設定 Task 快取 (TaskCache.cs),所以上述的效能評比並不公平,因為 Task<TResult> 的結果完全都被快取起來了!🔥
當使用 Task.FromResult()
的時候,預設 Int32 的結果只要介於 -1
~ 9
之間,都會自動回應快取的 Task 版本!🔥
我們重新修改一下測試程式:
#LINQPad optimize+ // Enable compiler optimizations
void Main()
{
Util.AutoScrollResults = true;
BenchmarkRunner.Run<TaskAndValueTaskComparsion>();
}
[ShortRunJob]
public class TaskAndValueTaskComparsion
{
[Benchmark]
public ValueTask<int> RunValueTaskWithNew()
{
return new ValueTask<int>(10);
}
[Benchmark]
public Task<int> RunTaskFromResult()
{
return Task.FromResult(10);
}
}
評測結果如下:
這個結果終於沒那麼毀人三觀了! 😄🤟
使用 ValueTask 的限制
使用 ValueTask<TResult> 有許多限制條件,只要你無法接受這些限制,建議就不要用了:
-
你不能在一個 ValueTask<TResult> 物件上使用 await
等待兩次以上!
-
你不能在一個 ValueTask<TResult> 物件上使用 .AsTask()
兩次以上!
-
如果你的 ValueTask<TResult> 還沒有跑出結果就執行 .Result
或 .GetAwaiter().GetResult()
的話,是會掛掉的!
因為 IValueTaskSource
/ IValueTaskSource<TResult>
的實作不支援 blocking 操作,因此不能在 ValueTask<TResult> 尚未完成之前進行任何 blocking 的操作。
-
如果你的 ValueTask<TResult> 執行了兩次以上 .Result
或 .GetAwaiter().GetResult()
的話,是會掛掉的!
-
上述所有的使用方式只要用超過一次都會掛掉!
上述任何一點限制你無法接受,就不要用!
使用 ValueTask 的時機
以下是我個人認為使用的 ValueTask
時機點,僅供大家參考:
-
如果你可以接受 ValueTask
的使用限制,才考慮用 ValueTask<TResult> 來實現,否則就用 Task<TResult> 即可。
-
如果你的非同步方法可以預期的很快會執行完畢,可以考慮用 ValueTask<TResult> 來實現。
-
如果你的非同步方法中可能會混用「同步」與「非同步」的執行模式,也意味著在「同步」執行時為了擁有更好的效率,那麼可以考慮用 ValueTask<TResult>,但請記得做好效能分析,讓數據說話。
這裡有個很簡單的例子如下:
public async Task<bool> MoveNextAsync()
{
if (_bufferedCount == 0)
{
await FillBuffer();
}
return _bufferedCount > 0;
}
這個 MoveNextAsync()
非同步方法,在沒有 Buffer 的時候,會需要執行另一個非同步方法,但是在有 Buffer 的時候,卻可以直接回傳結果 (同步執行)。這種情境下,最適合將非同步方法改用 ValueTask<TResult> 來回傳,記憶體使用率較佳,且執行速度也較快!
public async ValueTask<bool> MoveNextAsync()
{
if (_bufferedCount == 0)
{
await FillBuffer();
}
return _bufferedCount > 0;
}
-
如果你在一個物件中有一個或多個 Task<TResult>
欄位(Field)時,你在變數之間複製的時候,若改用 ValueTask<TResult> 將會導致複製過多的資料,會比較耗用記憶體,所以使用上需要考量一下。老話一句,讓數據說話,效能評測過再說。
由於 ValueTask<TResult> 屬於「實質型別」,因此你沒辦法快取這個物件,如果你想快取 ValueTask<TResult> 的話,可以考慮使用 ValueTask<TResult>.AsTask() 方法,將物件轉回 Task<TResult> 型別,參考型別就很容易快取了!
使用 new ValueTask<T>()
或 ValueTask.FromResult<T>()
我分析了一下 dotnet/aspnetcore 的所有原始碼,我發現這兩種語法都用蠻多的:
-
new ValueTask<T>()
public ValueTask<ChannelReader<int>> StreamChannelReaderValueTaskAsync()
{
var channel = Channel.CreateUnbounded<int>();
channel.Writer.Complete();
return new ValueTask<ChannelReader<int>>(channel);
}
-
ValueTask.FromResult<T>()
public ValueTask<ChannelReader<int>> StreamChannelReaderValueTaskAsync()
{
var channel = Channel.CreateUnbounded<int>();
channel.Writer.Complete();
return ValueTask.FromResult<ChannelReader<int>>(channel);
}
我也去查了一下 ValueTask.cs L119 原始碼,這兩種寫法根本沒有差異,但是用 ValueTask.FromResult<T>()
我覺得可讀性比較好,但用 new ValueTask<T>()
可以少一次 Method Call 方法呼叫。客官就自己選擇吧,我自己是選 ValueTask.FromResult<T>()
啦! 🤟
/// <summary>Creates a <see cref="ValueTask{TResult}"/> that's completed successfully with the specified result.</summary>
/// <typeparam name="TResult">The type of the result returned by the task.</typeparam>
/// <param name="result">The result to store into the completed task.</param>
/// <returns>The successfully completed task.</returns>
public static ValueTask<TResult> FromResult<TResult>(TResult result) =>
new ValueTask<TResult>(result);
相關連結