深入理解 ValueTask


深入理解 ValueTask

.NET Framework 4 里面的命名空間為 System.Threading.TasksTask 類。這個類以及它派生的 Task<TResult> 早已成為編程的主要部分,在 C#5 中的異步編程模式當作介紹了 async/await。在這篇文章里,我會覆蓋新的類 ValueTask / ValueTask<TResult>,介紹它們在通用的使用上降低內存消耗來提高異步性能,這是非常重要的。

Task

Task 有多種用途,但是它的核心就是 “promise”,它表示最終完成的一些操作。你初始化一個操作,並返回給它一個 Task,它當操作完成的時候它會完成,這可能作為初始化操作的一部分同步發生(比如訪問一個早就緩沖好了的緩沖區),也有能是異步的,在你完成這個任務時(比如訪問一些還沒有緩沖好的字節,但是很快就緩沖好了可以訪問),或者是在你已經接收 Task 的時候異步完成(比如通過網絡訪問數據)。因為操作完成可能是異步的,所以你需要為結果等待它(但這經常違背異步編程的初衷)或者你必須提供一個回調函數來調用,當這個操作完成的時候。在 .NET 4 中,提供了如回調函數一樣的來實現如 Task.ContinueWith 方法,它暴露通過傳遞一個委托的回調函數,這個函數在 Task 完成時觸發:

SomeOperationAsync().ContinueWith(task =>{
    try {
        TResult result = task.Result;
        UseResult(result);
    } catch (Exception e) {
        HandleException(e);
    }
})

但是在 C# 5 以及 .NET Framwrok 4.5 中,Task 只需要 await 這樣就能很簡單的獲取這個異步操作完成返回的結果,它生成的代碼能夠優化上述所有情況,無論操作是否同步完成,是否異步完成,還是在已經提供的回調中異步完成,都能得到正確地處理。

TResult result = await SomeOperationAsync();
UseResult(result);

Task 很靈活,並且有很多好處。例如你可以被多個消費者並行的等待多次。你可以存儲一個到字典中,以便后面的任意數量的消費者等待,它允許為異步結果像緩存一樣使用。如果場景需要,你可以阻塞一個等待完成。並且你可以在這些任務上編寫和使用各種操作(就像組合器),例如 WhenAny 操作,它可以異步等待第一個完成的操作。

然而,這種靈活性對於大多數情況下是不需要的:僅僅只是調用異步操作並且等待結果:

TResult result = await SomeOperationAsync();
UseResult(result);

如上用法,我們根本不需要多次等待,我們不需要處理並行等待,我們也不需要處理異步阻塞,我們更不需要去寫組合器。我們只是簡單的等待異步操作 promise 返回的結果。這就是我們怎么寫異步代碼的全部(例如 TResult = SomeOperation();),最終也能很自然的轉換成 async / await

盡管如此,Task 也會有潛在的副作用,在高吞吐和高性能為關鍵的特定的場景中,這個例子被大量創建:Task 是一個類。作為一個類,就是說任意操作創建一個 Task 都會分配一個對象,越來越多的這個對象都會被分配,所以 GC 操作也會越來越頻繁,也就會消耗越來越多的資源,本來它應該是去做其他事的。

運行時和核心庫能減輕大多數這種場景。舉個例子,如果你寫了如下代碼:

public async Task WriteAsync(byte value)
{
    if(_bufferedCount == _buffer.Length)
    {
        await FlushAsync();
    }
    _buffer[_bufferedCount++] = value;
}

在常規的例子中,緩沖區有可用空間的,並且操作是同步完成。當這樣運行的時候,這里返回的 Task 沒有任何特別之處,因為它不會返回值:這個返回 Task 就等價於返回一個 void 的同步方法。盡管如此,運行時還是會簡單緩存這個單個非泛型的 Task 以及對於所有的 async Task 同步完成的方法都能重復使用它(暴露的緩存的單例就像是 Task.CompletedTask)。例如你的代碼可以這樣:

public async Task<bool> MoveNextAsync()
{
    if(_bufferedCount == 0)
    {
        await FillBuffer();
    }
    return _bufferedCount > 0;
}

通常情況下,我們期望會有一些數據被緩沖,在這個例子中,這個方法檢查 _bufferedCount,檢驗值大於 0,返回 true;只有當前緩沖區域沒有緩沖數據時,它才需要執行一個操作,這個操作是異步的。由於這里是 Boolean 存在兩個可能的結果(truefalse),它所有可能的結果值,這里就只有兩個對象 Task<bool>。所以運行時會緩存兩個對象,以及簡單返回一個 Task<bool> 的緩存對象,它的結果值為 true 來避免必要的分配。只有當操作異步完成時,這個方法需要分配一個新的 Task<bool>,因為在它知道這個操作的結果之前,它需要將對象返回給調用者,所以還要必須有一個唯一的對象,當操作完成的時候靠它將值存進去。

運行時為其他的類型很好的維護一個很小的緩存,但是用它來存儲所有是行不通的。例如下面方法:

public async Task<int> ReadNextByteAsync()
{
    if(_bufferedCount == 0)
    {
        await FillBuffer();
    }
    if(_bufferedCount == 0) {
        return -1;
    }
    _bufferedCount--;
    return _buffer[_pisition++];
}

也經常會同步完成。但是不像 Boolean 那個例子,這個方法返回一個 Int32 的值,它大約有 40 億中可能的值,並且為所有的情況緩存一個 Task<int>,將會消耗可能數以百計的千兆字節內存。運行時為 Task<int> 負責維護一個小的緩存,但是只有很小部分結果的值有用到,例如如果是同步完成的(數據緩存到緩存區),返回值如 4,它最后使用了緩存 task,但是如果這個操作是同步完成返回結果值如 42,它最后將分配一個新的 Task<int>,就類似調用 Task.FromResult(42)

很多庫實現了嘗試通過維護它們自己的緩存來降低這個特性。例如 .NET Framwork 4.5 的 MemoryStream.ReadAsync 重載函數總是同步完成的,因為它只是從內存中讀數據。ReadAsync 返回一個 Task<int>,這個 Int32 結果值表示讀的字節數。ReadAsync 經常用在循環中,每次調用請求的字節數通常相同,ReadAsync 能夠完全滿足這個請求。因此,通常情況下的請求重復調用 ReadAsync 來同步返回一個 Task<int>,其結果與之前的調用相同。因此,MemoryStream 維護單個 task 的緩存,它成功返回最后一個 task。然后再接着調用,如果這個新的結果與緩存的 Task<int> 匹配,它只返回已經緩存的。否則,它會使用 Task.FromResult 來創建一個新的,存儲到新的緩存 task 並返回。

即使如此,還有很多案例,這些操作同步完成並且強制分配一個 Task<TResult> 返回。

ValueTask<TResult> 和同步完成

所有的這些都引發 .NET CORE 2.0 引入了一個新類型,可用於之前的 .NET 版本 ,在System.Threading.Tasks.Extensions Nuget 包中:ValueTask<TResult>

ValueTask<TResult> 在 .NET Core 2.0 作為結構體引入的,它是 TResultTask<TResult> 包裝器。也就是說它能從異步方法返回並且如果這個方法同步成功完成,不需要分配任何內存:我們只是簡單的初始化這個 ValueTask<TResult> 結構體,它返回 TResult。只有當這個方法異步完成時,Task<Result> 才需要被分配,通過 被創建的ValueTask<TResult> 來包裝這個實例對象(為了最小化的大小的 ValueTask<TResult> 以及優化成功路徑,一個異步方法它出現故障,並且出現未處理的異常,它還是會分配一個 Task<TResult> 對象,所以 ValueTask<TResult>能簡單的包裝 Task<TResult> 而不是必須添加額外的字段來存儲異常信息)。

於是,像 MemoryStream.ReadAsync 這個方法,它返回一個 ValueTask<int>,它沒有緩存的概念,並且能像下面代碼一樣編碼:

public override ValueTask<int> ReadAsync(byte[] buffer, int offset, int count)
{
    try {
        int butyRead = Read(buffer, offset, count);
        return new ValueTask<int>(bytesRead);
    }
    catch (Exception e)
    {
        return new ValueTask<int>(Task.FromException<int>(e));
    }
}

ValueTask 和 異步完成

為了寫一個異步方法不需要為結果類型占用額外的分配的情況下完成同步,是一個巨大的勝利。這就是為什么我們把 ValueTask<TResult> 添加到 .NET Core 2.0 的原因以及為什么我們期望去使用的新的方法返回 ValueTask<TResult> 而不是 Task<TResult>。例如,當我們添加新的 ReadAsync 重載函數到 Stream 類中是為了能夠傳遞給 Memory<byte> 而不是 byte[],我們使它返回的類型是 ValueTask<TResult>。這樣,Stream(它提供 ReadAsync 同步完成方法)和之前的 MemoryStream 的例子一樣,使用這個簽名(ValueTask)能夠減少內存分配。

然而,工作在高吞吐的服務時,我們還是要考慮盡可能的減少分配,也就是說要考慮減少以及移除異步完成相關的內存分配。

對於 await 模式,對於所有的異步完成的操作,我們都需要能夠去處理返回表示完成事件的操作的對象:調用者必須能夠傳遞當操作完成時要被調用的回調函數以及要求有一個唯一對象能夠被重用,這需要有一個唯一的對象在堆上,它能夠作為特定操作的管道。但是,這並不以為這一旦這個操作完成所有關於這個對象都能被重用。如果這個對象能夠被重用,那么這個 API 維護一個或多個這樣對象的緩存,並且為序列化操作重用,這意思就是說不能使用相同對象到多次異步操作,但是對於非並發訪問是可以重用的。

在 .NET Core 2.1,ValueTask<TResult> 增強功能支持池化和重用。而不只是包裝 TResultTask<TResult>,y引入了一個新的接口,IValueTaskSource<TResult>,增強 ValueTask<TResult> 能夠包裝的很好。IValueTaskSource<TResult> 提供必要的核心的支持,以類似於 Task<TResult> 的方式來表示 ValueTask<TResult> 的異步操作:

public interface IValueTaskSource<out TResult>
{
	ValueTaskSourceStatus GetStatus(short token);
	void OnCompleted(Action<object> continuation, object state, short token, ValueTaskSourceOmCompletedFlags flags);
	TResult GetResult(short token);
}

GetStatus 用來滿足像 ValueTask<TResult>.Completed 等屬性,返回一個表示異步操作是否正在執行中還是是否完成還是怎么樣(成功或失敗)。OnCompleted 是被 ValueTask<TResult> 的可等待者用於當操作完成時,從 await 中掛起的回調函數繼續運行。GetResult 用於獲取操作的結果,就像操作完成后,等待着能夠獲得 TResult 或傳播可能發生的所有異常。

絕大多數開發者不需要去看這個接口:方法簡單返回一個 ValueTask<TResult>,它被構造去包裝這個接口的實例,消費者並不知情(consumer is none-the-wiser)。這個接口主要就是讓開發者關注性能的 API 能夠避免內存分配。

在 .NET Core 2.1 有一些這樣的 API。最值得注意的是 Socket.ReceiveAsyncSocket.SendAsync,有新增的重載,例如

public ValueTask<int> ReceiveAsync(Momory<byte> buffer, SocketFlags socketFlags, CancellationToken cancellationToken = default);

這個重載返回 ValueTask<int>。如果這個操作同步 完成,它能構造一個 ValueTask<int> 並得到一個合適的結果。

int result = ...;
return new ValueTask<int>(result);

Socket 實現了維護一個用於接收和一個用來發送的池對象,這樣每次每個完成的對象不超過一個,這些重載函數都是 0 分配的,甚至是它們完成了異步操作。然后 NetworkStream 就出現了。舉個例子,在 .NET Core 2.1 中 Stream 暴露這樣一個方法:

public virtual ValueTask<int> ReadAsync(Memory<byte> buffer, cancellationToken cancellationToken);

這個復寫方法 NetworkStreamNetworkStream.ReadAsync 只是委托給 Socket.ReceiveAsync,所以從 Socket 轉成 NetworkStream,並且 NetworkStream.ReadAsync 是高效的,無分配的。

非泛型 ValueTask

當 .NET Core 2.0 引入 ValueTask<TResult> ,它純碎是為了優化同步完成的情況下,為了避免分配一個 Task<TResult> 存儲可用的 TResult。這也就是說非泛型的 ValueTask 是不必要的:對於同步完成的情況,從 Task 返回的方法返回 Task.CompletedTask 單例,並且為 async Task 方法在運行時隱式的返回。

隨着異步方法零開銷的實現,非泛型 ValueTask 變得再次重要起來。因此,在 .NET Core 2.1 中,我們也引入了非泛型的 ValueTaskIValueTaskSource。它們提供泛型的副本版本,以相同方式使用,沒有返回值。

IValueTaskSource / IValueTaskSource 實現

大多數開發者不需要實現這些接口。它們也不是那么容易實現的。如果你需要這么做,在 .NET Core 2.1 有一些內部實現作為參考。例如:

為了讓開發者想做的更加簡單,在 .NET Core 3.0 中,我們計划引入所有封裝這些邏輯到 ManualResetValueTaskSource<TResult> 類中去,這是一個結構體,能被封裝到另一個對象中,這個對象實現了 IValueTaskSource<TResult> 以及/或者 IValueTaskSource,這個包裝類簡單的將大部分實現委托給結構體即可。要了解更多相關的問題,詳見 dotnet/corefx 倉庫中的 issues

ValueTasks 有效的消費模式

從表面上來看,ValueTaskValueTask<TResult> 要比 TaskTask<TResult> 更加有限。沒錯,這個方法主要的消費就是簡單的等待它們。

但是,因為 ValueTaskValueTask<TResult> 可能封裝可重用的對象,因此與 TaskTask<TResult> 相比,如果有人偏離期望的路徑而只是等待它們,它們的消耗實際上受到了很大的限制。一般的,像下面的操作永遠不會執行在 ValueTask / ValueTask<TResult> 上:

  • 等待 ValueTask / ValueTask<TResult> 多次。底層對象可能已經回收了並被其他操作使用。與之對比,Task / Task<TResult> 將永不會把從完成狀態轉成未完成狀態,所以你能根據需要等待多次,並每次總是能得到相同的結果。
  • 並發等待 ValueTask / ValueTask<TResult>。底層對象期望一次只在從單個消費者的回調函數執行,如果同時等待它很容易發生條件爭用以及微妙的程序錯誤。這也是上述操作具體的例子:“等待 ValueTask / ValueTask<TResult> 多次。”,相反,Task / Task<TResult> 支持任何數量並發的等待。
  • 當操作還沒完成時調用 .GetAwaiter().GetResult()IValueTaskSource / IValueTaskSource<TResult> 的實現在操作還沒完成之前是不需要支持阻塞的,並且很可能不會,這樣的操作本質上就是條件爭用,不太可能按照調用者的意圖調用。相反,Task / Task<TResult> 能夠這樣做,阻塞調用者一直到任務完成。

如果你在使用 ValueTask / ValueTask<TResult> 以及你需要去做上述提到的,你應該使用它的 .AsTask() 方法獲得 Task / Task<TResult>,然后方法會返回一個 Task 對象。在那之后,你就不能再次使用 ValueTask / ValueTask<TResult>

簡而言之:對於 ValueTask / ValueTask<TResult>,你應該要么直接 await (可選 .ConfigureAwait(false))要么調用直接調用 AsTask(),並且不會再次使用它了。

// 給定一個返回 ValueTask<int> 的方法
public ValueTask<int> SomeValueTaskReturningMethodAsync();
...
// GOOD
int result = await SomeValueTaskReturningMethodAsync();
// GOOD
int result = await SomeValueTaskReturningMethodAsync().ConfigureAwait(false);
// GOOD
Task<int> t = SomeValueTaskReturningMethodAsync().AsTask();
// WARNING
ValueTask<int> vt = SomeValueTaskReturningMethodAsync();
... // 存儲實例至本地變量會使它更加可能會被濫用
	// 但是這樣寫還是 ok 的
// BAD:等待多次
ValueTask<int> vt = SomeValueTaskReturningMethodAsync();
int result = await vt;
int result2 = await vt;
// BAD: 並發等待(並且根據定義,多次等待)
ValueTask<int> vt = SomeValueTaskReturningMethodAsync();
Task.Run(async () => await vt);
Task.Run(async () => await vt);
// BAD: 在不知何時完成時使用 GetAwaiter().GetResult() 
ValueTask<int> vt = SomeValueTaskReturningMethodAsync();
int result = vt.GetAwaiter().GetResult();

還有一個高級模式開發者可以選擇使用,在自己衡量以及能找到它提供的好處才使用它。

特別的,ValueTask / ValueTask<TResult> 提供了一些屬性,他們能表明當前操作的狀態,例如如果操作還沒完成, IsCompleted 屬性返回 false ,以及如果完成則返回 true(意思是不會長時間運行以及可能成功完成或相反),如果只有在成功完成時(企圖等待它或訪問非拋出來的異常的結果)IsCompletedSuccessfully 屬性返回 true 。對於開發者所想的所有熱路徑,舉個例子:開發者想要避免一些額外的開銷,而這些開銷只在一些必要的徑上才會有,可以在執行這些操作之前檢查這些屬性,這些操作實際上使用 ValueTask / ValueTask<TResult>,如 .await,.AsTask()。例如,在 .NET Core 2.1 中 SocketsHttpHandler 的實現,代碼對連接讀操作,它返回 ValueTask<int>。如果操作同步完成,那么我們無需擔心這個操作是否能被取消。但是如果是異步完成的,那么在正在運行時,我們想要取消操作,那么這個取消請求將會關閉連接。這個代碼是非常常用的,並且分析顯示它只會有一點點不同,這個代碼本質上結構如下:

int bytesRead;
{
    ValueTask<int> readTask = _connection.ReadAsync(buffer);
    if(readTask.IsCompletedSuccessfully)
    {
        bytesRead = readTask.Result;
    }
    else
    {
        using(_connection.RegisterCancellation())
        {
            bytesRead = await readTask;
        }
    }
}

這種模式是可接受的,因為 ValueTask<int> 是不會在調用 .Resultawait 之后再次使用的。

是否每個新的異步 API 都應返回 ValueTask / ValueTask ?

不!默認的選擇任然還是 Task / Task<TResult>

正如上面強調的,Task / Task<TResult> 要比 ValueTask / ValueTask<TResult> 更容易正確使用,除非性能影響要大於可用性影響,Task / Task<TResult> 任然是優先考慮的。返回 ValueTask<TResult> 取代 Task<TResult> 會有一些較小的開銷,例如在微基准測試中,等待 Task<TResult> 要比等待 ValueTask<TResult> 快,所以如果你要使用緩存任務(如你返回 Task / Task<bool> 的 API),在性能方面,堅持使用 Task / Task<TResult> 可能會更好。ValueTask / ValueTask<TResult> 也是多字相同大小的,在他們等待的時候,它們的字段存儲在一個正在調用異步方法的狀態機中,它們會在相同的狀態機中消耗更多的空間。

然而,ValueTask / ValueTask<TResult> 有時也是更好的選擇,a)你期望在你的 API 中只用直接 await 他們,b)在你的 API 避免相關的分配開銷是重要的,c)無論你是否期望同步完成是通用情況,你都能夠有效的將對象池用於異步完成。當添加 abstract,virtual,interface 方法時,你也需要考慮這些場景將會在復寫/實現中存在。

ValueTask 和 ValueTask 的下一步是什么?

對於 .NET 核心庫,我們講會繼續看到新的 API 返回 Task / ValueTask<TResult>,但是我們也能看到在合適的地方返回 ValueTask / ValueTask<TResult> 的 API。據其中一個關鍵的例子,計划在 .NET Core 3.0 提供新的 IAsyncEnuerator 支持。IEnumerator<T> 暴露了一個返回 boolMoveNext 方法,並且異步 IAsyncEnumerator<T> 提供了 MoveNextAsync 方法。當我們初始化開始設計這個特性的時候,我們想過 MoveNextAsync 返回 Task<bool>,這樣能夠非常高效對比通用的 MoveNextAsync 同步完成的情況。但是,考慮到我們期望的異步枚舉影響是很廣泛的,並且它們都是基於接口,會有很多不同的實現(其中一些可能非常關注性能和內存分配),考慮到絕大多數的消費者都將通過 await fearch 語言支持,我們將 MoveNextAsync 改成返回類型為 ValueTask<bool>。這樣就允許在同步完成場景下更快,也能優化實現可重用對象能夠使異步完成更加減少分配。實際上,當實現異步迭代器時,C# 編譯器就會利用這點能讓異步迭代器盡可能降低分配。

本文同步至:https://github.com/MarsonShine/Books/blob/master/CSharpGuide/docs/7.0/understanding-task-valuetask.md

原文地址:https://devblogs.microsoft.com/dotnet/understanding-the-whys-whats-and-whens-of-valuetask/


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM