深入理解 ValueTask
.NET Framework 4 里面的命名空間為?System.Threading.Tasks的?Task?類。這個類以及它派生的?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?存在兩個可能的結果(true?和?false),這里可能只有兩個對象?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 作為結構體引入的,它是?TResult?或?Task<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>?增強功能支持池化和重用。而不只是包裝?TResult?或?Task<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.ReceiveAsync?和?Socket.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);這個復寫方法?NetworkStream。NetworkStream.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 中,我們也引入了非泛型的?ValueTask?和?IValueTaskSource。它們提供泛型的副本版本,相同方式使用,在 void 類型使用。
IValueTaskSource / IValueTaskSource?實現
大多數開發者不需要實現這些接口。它們也不是那么容易實現的。如果你需要這么做,在 .NET Core 2.1 有一些內部實現作為參考。例如:
AwaitableSocketAsyncEventArgs
AsyncOperation
DefaultPipeReader
為了讓開發者想做的更加簡單,在 .NET Core 3.0 中,我們計劃引入所有封裝這些邏輯到?ManualResetValueTaskSource<TResult>?類中去,這是一個結構體,能被封裝到另一個對象中,這個對象實現了?IValueTaskSource<TResult>?以及/或者?IValueTaskSource,這個包裝類簡單的將大部分實現委托給結構體即可。要了解更多相關的問題,詳見 dotnet/corefx 倉庫中的 issues。
ValueTasks 有效的消費模式
從表面上來看,ValueTask?和?ValueTask<TResult>?要比?Task?和?Task<TResult>?更加有限。沒錯,這個方法主要的消費就是簡單的等待它們。
但是,因為?ValueTask?和?ValueTask<TResult>?可能封裝可重用的對象,因此與?Task?和?Task<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(),并且不會再次使用它了。
public ValueTask<int> SomeValueTaskReturningMethodAsync(); ...int result = await SomeValueTaskReturningMethodAsync();int result = await SomeValueTaskReturningMethodAsync().ConfigureAwait(false);Task<int> t = SomeValueTaskReturningMethodAsync().AsTask();ValueTask<int> vt = SomeValueTaskReturningMethodAsync(); ...ValueTask<int> vt = SomeValueTaskReturningMethodAsync(); int result = await vt; int result2 = await vt;ValueTask<int> vt = SomeValueTaskReturningMethodAsync(); Task.Run(async () => await vt); Task.Run(async () => await vt);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>?是不會在調用?.Result?或?await?之后再次使用的。
是否每個新的異步 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>?暴露了一個返回?bool?的MoveNext?方法,并且異步?IAsyncEnumerator<T>?提供了?MoveNextAsync?方法。當我們初始化開始設計這個特性的時候,我們想過?MoveNextAsync?返回?Task<bool>,這樣能夠非常高效對比通用的?MoveNextAsync?同步完成的情況。但是,考慮到我們期望的異步枚舉影響是很廣泛的,并且它們都是基于接口,會有很多不同的實現(其中一些可能非常關注性能和內存分配),考慮到絕大多數的消費者都將通過?await fearch?語言支持,我們將?MoveNextAsync?改成返回類型為?ValueTask<bool>。這樣就允許在同步完成場景下更快,也能優化實現可重用對象能夠使異步完成更加減少分配。實際上,當實現異步迭代器時,C# 編譯器就會利用這點能讓異步迭代器盡可能降低分配。
原文地址:https://devblogs.microsoft.com/dotnet/understanding-the-whys-whats-and-whens-of-valuetask/
.NET社區新聞,深度好文,歡迎訪問公眾號文章匯總?http://www.csharpkit.com?
總結
以上是生活随笔為你收集整理的深入理解 ValueTask的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 使用.net core3.0 正式版创建
- 下一篇: 谓词筛选表达式的扩展库Predicate