如何使用 C# 中的 ValueTask

在 C# 中利用 ValueTask 避免從非同步方法返回 Task 對象時分配

翻譯自 Joydip Kanjilal 2020年7月6日 的文章 《How to use ValueTask in C#》

非同步編程已經使用了相當長一段時間了。近年來,隨著 asyncawait 關鍵字的引入,它變得更加強大。您可以利用非同步編程來提高應用程式的響應能力和吞吐量。

C# 中非同步方法的推薦返回類型是 Task。如果您想編寫一個有返回值的非同步方法,那麼應該返回 Task<T>; 如果想編寫事件處理程式,則可以返回 void。在 C# 7.0 之前,非同步方法可以返回 TaskTask<T>void。從 C# 7.0 開始,非同步方法還可以返回 ValueTask(作為 System.Threading.Tasks.Extensions 包的一部分可用)或 ValueTask<T>。本文就討論一下如何在 C# 中使用 ValueTask

要使用本文提供的程式碼示例,您的系統中需要安裝 Visual Studio 2019。如果還沒有安裝,您可以在這裡下載 Visual Studio 2019

在 Visual Studio 中創建一個 .NET Core 控制台應用程式項目

首先,讓我們在 Visual Studio 中創建一個 .NET Core 控制台應用程式項目。假設您的系統中安裝了 Visual Studio 2019,請按照下面描述的步驟在 Visual Studio 中創建一個新的 .NET Core 控制台應用程式項目。

  1. 啟動 Visual Studio IDE。
  2. 點擊 「創建新項目」。
  3. 在 「創建新項目」 窗口中,從顯示的模板列表中選擇 「控制台應用(.NET Core)」。
  4. 點擊 「下一步」。
  5. 在接下來顯示的 「配置新項目」 窗口,指定新項目的名稱和位置。
  6. 點擊 「創建」。

這將在 Visual Studio 2019 中創建一個新的 .NET Core 控制台應用程式項目。我們將在本文後面的部分中使用這個項目來說明 ValueTask 的用法。

為什麼要使用 ValueTask ?

Task 表示某個操作的狀態,即此操作是否完成、取消等。非同步方法可以返回 Task 或者 ValueTask

現在,由於 Task 是一個引用類型,從非同步方法返回一個 Task 對象意味著每次調用該方法時都會在託管堆(managed heap)上分配該對象。因此,在使用 Task 時需要注意的一點是,每次從方法返回 Task 對象時都需要在託管堆中分配記憶體。如果你的方法執行的操作的結果立即可用或同步完成,則不需要這種分配,因此代價很高。

這正是 ValueTask 要出手相助的目的,ValueTask<T> 提供了兩個主要好處。首先,ValueTask<T> 提高了性能,因為它不需要在堆(heap)中分配; 其次,它的實現既簡單又靈活。當結果立即可用時,通過從非同步方法返回 ValueTask<T> 代替 Task<T>,你可以避免不必要的分配開銷,因為這裡的 「T」 表示一個結構,而 C# 中的結構體(struct)是一個值類型(與 Task<T> 中表示類的 「T」 不同)。

C# 中 TaskValueTask 表示兩種主要的 「可等待(awaitable)」 類型。請注意,您不能阻塞(block)一個 ValueTask。如果需要阻塞,則應使用 AsTask 方法將 ValueTask 轉換為 Task,然後在該引用 Task 對象上進行阻塞。

另外請注意,每個 ValueTask 只能被消費(consumed)一次。這裡的單詞 「消費(consume)」 是指 ValueTask 可以非同步等待(await)操作完成,或者利用 AsTaskValueTask 轉換為 Task。但是,ValueTask 只應被消費(consumed)一次,之後 ValueTask<T> 應被忽略。

C# 中的 ValueTask 示例

假設有一個非同步方法返回一個 Task。你可以利用 Task.FromResult 創建 Task 對象,如下面給出的程式碼片段所示。

public Task<int> GetCustomerIdAsync()
{
    return Task.FromResult(1);
}

上面的程式碼片段並沒有創建整個非同步狀態機制,但它在託管堆(managed heap)中分配了一個 Task 對象。為了避免這種分配,您可能希望利用 ValueTask 代替,像下面給出的程式碼片段所示的那樣。

public ValueTask<int> GetCustomerIdAsync()
{
    return new ValueTask<int>(1);
}

下面的程式碼片段演示了 ValueTask 的同步實現。

public interface IRepository<T>
{
    ValueTask<T> GetData();
}

Repository 類擴展了 IRepository 介面,並實現了如下所示的方法。

public class Repository<T> : IRepository<T>
{
    public ValueTask<T> GetData()
    {
        var value = default(T);
        return new ValueTask<T>(value);
    }
}

下面是如何從 Main 方法調用 GetData 方法。

static void Main(string[] args)
{
    IRepository<int> repository = new Repository<int>();
    var result = repository.GetData();
    if (result.IsCompleted)
        Console.WriteLine("Operation complete...");
    else
        Console.WriteLine("Operation incomplete...");
    Console.ReadKey();
}

現在讓我們將另一個方法添加到我們的存儲庫(repository)中,這次是一個名為 GetDataAsync 的非同步方法。以下是修改後的 IRepository 介面的樣子。

public interface IRepository<T>
{
    ValueTask<T> GetData();

    ValueTask<T> GetDataAsync();
}

GetDataAsync 方法由 Repository 類實現,如下面給出的程式碼片段所示。

public class Repository<T> : IRepository<T>
{
    public ValueTask<T> GetData()
    {
        var value = default(T);
        return new ValueTask<T>(value);
    }

    public async ValueTask<T> GetDataAsync()
    {
        var value = default(T);
        await Task.Delay(100);
        return value;
    }
}

C# 中應該在什麼時候使用 ValueTask ?

儘管 ValueTask 提供了一些好處,但是使用 ValueTask 代替 Task 有一定的權衡。ValueTask 是具有兩個欄位的值類型,而 Task 是具有單個欄位的引用類型。因此,使用 ValueTask 意味著要處理更多的數據,因為方法調用將返回兩個數據欄位而不是一個。另外,如果您等待(await)一個返回 ValueTask 的方法,那麼該非同步方法的狀態機也會更大,因為它必須容納一個包含兩個欄位的結構體而不是在使用 Task 時的單個引用。

此外,如果非同步方法的使用者使用 Task.WhenAll 或者 Task.WhenAny,在非同步方法中使用 ValueTask<T> 作為返回類型可能會代價很高。這是因為您需要使用 AsTask 方法將 ValueTask<T> 轉換為 Task<T>,這會引發一個分配,而如果使用起初快取的 Task<T>,則可以輕鬆避免這種分配。

經驗法則是這樣的:當您有一段程式碼總是非同步的時,即當操作(總是)不能立即完成時,請使用 Task。當非同步操作的結果已經可用時,或者當您已經快取了結果時,請利用 ValueTask。不管怎樣,在考慮使用 ValueTask 之前,您都應該執行必要的性能分析。

ValueTaskreadonly struct 類型,Taskclass 類型。
相關鏈接:C# 中 Struct 和 Class 的區別總結

作者 : Joydip Kanjilal
譯者 : 技術譯民
出品 : 技術譯站
鏈接 : 英文原文