The Will Will Web

記載著 Will 在網路世界的學習心得與技術分享

釐清非同步程式設計模型下 Task 與 ValueTask 的使用時機

在 .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 是找不到這個型別的。

效能差異

首先,這兩個型別有個本質上的差異:

  1. Task<TResult> 類別

    所有的類別(class)都是一種參考型別 (Reference Type),這意味著,當你在執行一個標示 async 的非同步方法時,若該方法會透過 Task<TResult> 物件立即回應一個,你就必須先在 Heap 記憶體中先將 Task 物件保存,然後再將該物件的記憶體位址參考放入 Stack 記憶體中,而最後保存在變數中的資料其實是該物件的記憶體位址參考。因此,你要透過 Task 物件取得執行結果時,會有一點點點點的額外開銷(overhead)。

  2. 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> 並沒有比較快啊!🔥

BenchmarkDotNet: ValueTask vs. Task

驚不驚喜?意不意外?😆

老實說,我認真覺得要學好非同步程式開發不是一件很容易的事,在非同步的世界裡,有太多的變因(或迷因?),有時候會因為硬體環境、軟體設定不同,就會有截然不同的執行結果,以致於許多人在研讀龐大文件時,經常無法有效的吸收知識,更嚴重的就是建立了錯誤的觀念而不自知。在多執行緒的執行環境下,有很多觀念需要事先建立,你才有辦法好好的、正確的思考,也才有辦法舉一反三,做出正確的非同步設計決策。也因此我設計了 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.

簡單翻譯一下重點知識:

  1. 所有非同步方法都應該使用 TaskTask<TResult> 當作非同步方法的預設回傳型別。

    所以並不是什麼程式都可以用 ValueTask<TResult> 啦!

  2. 除了有明確的數據證明用 ValueTask<TResult> 的效能比 Task 好,你才去用,否則不要!

    換句話說,背後原理很複雜,我不想跟你說這麼多,真的有數據證明效能 ValueTask<TResult> 比較快你才考慮用看看。

  3. 非泛型的 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);
    }
}

評測結果如下:

BenchmarkDotNet: ValueTask vs. Task

這個結果終於沒那麼毀人三觀了! 😄🤟

使用 ValueTask 的限制

使用 ValueTask<TResult> 有許多限制條件,只要你無法接受這些限制,建議就不要用了:

  1. 你不能在一個 ValueTask<TResult> 物件上使用 await 等待兩次以上!

  2. 你不能在一個 ValueTask<TResult> 物件上使用 .AsTask() 兩次以上!

  3. 如果你的 ValueTask<TResult> 還沒有跑出結果就執行 .Result.GetAwaiter().GetResult() 的話,是掛掉的!

    因為 IValueTaskSource / IValueTaskSource<TResult> 的實作不支援 blocking 操作,因此不能在 ValueTask<TResult> 尚未完成之前進行任何 blocking 的操作。

  4. 如果你的 ValueTask<TResult> 執行了兩次以上 .Result.GetAwaiter().GetResult() 的話,是掛掉的!

  5. 上述所有的使用方式只要用超過一次都會掛掉!

上述任何一點限制你無法接受,就不要用!

使用 ValueTask 的時機

以下是我個人認為使用的 ValueTask 時機點,僅供大家參考:

  1. 如果你可以接受 ValueTask 的使用限制,才考慮用 ValueTask<TResult> 來實現,否則就用 Task<TResult> 即可。

  2. 如果你的非同步方法可以預期的很快會執行完畢,可以考慮用 ValueTask<TResult> 來實現。

  3. 如果你的非同步方法中可能會混用「同步」與「非同步」的執行模式,也意味著在「同步」執行時為了擁有更好的效率,那麼可以考慮用 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;
    }
    
  4. 如果你在一個物件中有一個或多個 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);

相關連結