理解C#中的ValueTask
阿新 • • 發佈:2020-06-29
> 原文:https://devblogs.microsoft.com/dotnet/understanding-the-whys-whats-and-whens-of-valuetask/
作者:Stephen
翻譯:xiaoxiaotank
備註:本文要求讀者對`Task`有一定的瞭解,文章文字描述較多,但內容十分充實,相信你認真閱讀後,一定讓你受益匪淺。
#### 前言
`Task`類是在.NET Framework 4引入的,位於`System.Threading.Tasks`名稱空間下,它與派生的泛型類`Task`已然成為.NET程式設計的主力,也是以`async/await`(C# 5引入的)語法糖為代表的非同步程式設計模型的核心。
隨後,我會向大家介紹.NET Core 2.0中的新成員`ValueTask/ValueTask`,來幫助你在日常開發用例中降低記憶體分配開銷,提升非同步效能。
## Task
雖然`Task`的用法有很多,但其最核心的是“承諾(promise)”,用來表示某個操作最終完成。
當你初始化一個操作後,會獲取一個與該操作相關的`Task`,當這個操作完成時,`Task`也同樣會完成。這個操作的完成情況可能有以下幾種:
- 作為初始化操作的一部分同步完成,例如:訪問一些已被快取的資料
- 恰好在你獲取到`Task`例項的時候非同步完成,例如:訪問雖然沒被快取但是訪問速度非常快的資料
- 你已經獲取到了`Task`例項,並等待了一段時間後,才非同步完成,例如:訪問一些網路資料
由於操作可能會非同步完成,所以當你想要使用最終結果時,你可以通過阻塞來等待結果返回(不過這違背了非同步操作的初衷);或者,使用回撥方法,它會在操作完成時被呼叫,.NET 4通過`Task.ContinueWith`方法顯式實現了這個回撥方法,如:
```csharp
SomeOperationAsync().ContinueWith(task =>
{
try
{
TResult result = task.Result;
UseResult(result);
}
catch(Exception ex)
{
HandleException(ex);
}
})
```
而在.NET 4.5中,`Task`通過結合`await`,大大簡化了對非同步操作結果的使用,它能夠優化上面說的所有情況,無論操作是同步完成、快速非同步完成還是已經(隱式地)提供回撥之後非同步完成,都不在話下,寫法如下:
```
TResult result = await SomeOperationAsync();
UseResult(result);
```
`Task`作為一個類(class),非常靈活,並因此帶來了很多好處。例如:
- 它可以被任意數量的呼叫者併發`await`多次
- 你可以把它儲存到字典中,以便任意數量的後續使用者對其進行`await`,進而把這個字典當成非同步結果的快取
- 如果需要的話,你可以通過阻塞等待操作完成
- 另外,你還可以對`Task`使用各種各樣的操作(稱為“組合器”,combinators),例如使用`Task.WhenAny`非同步等待任意一個操作率先完成。
不過,在大多數情況下其實用不到這種靈活性,只需要簡單地呼叫非同步操作並`await`獲取結果就好了:
```
TResult result = await SomeOperationAsync();
UseResult(result);
```
在這種用法中,我們不需要多次`await task`,不需要處理併發`await`,不需要處理同步阻塞,也不需要編寫組合器,我們只是非同步等待操作的結果。這就是我們編寫同步程式碼(例如`TResult result = SomeOperation()`)的方式,它很自然地轉換為了`async/await`的方式。
此外,`Task`也確實存在潛在缺陷,特別是在需要建立大量`Task`例項且要求高吞吐量和高效能的場景下。`Task` 是一個類(class),作為一個類,這意味著每建立一個操作,都需要分配一個物件,而且分配的物件越多,垃圾回收器(GC)的工作量也會越大,我們花在這個上面的資源也就越多,本來這些資源可以用於做其他事情。慶幸的是,執行時(Runtime)和核心庫在許多情況下都可以緩解這種情況。
例如,你寫了如下方法:
```
public async Task WriteAsync(byte value)
{
if (_bufferedCount == _buffer.Length)
{
await FlushAsync();
}
_buffer[_bufferedCount++] = value;
}
```
一般來說,緩衝區中會有可用空間,也就無需`Flush`,這樣操作就會同步完成。這時,不需要`Task`返回任何特殊資訊,因為沒有返回值,返回`Task`與同步方法返回`void`沒什麼區別。因此,執行時可以簡單地快取單個非泛型Task,並將其反覆用作任何同步完成的方法的結果(該單例是通過`Task.CompletedTask`公開的)。
或者,你的方法是這樣的:
```
public async Task MoveNextAsync()
{
if (_bufferedCount == 0)
{
// 快取資料
await FillBuffer();
}
return _bufferedCount > 0;
}
```
一般來說,我們想的是會有一些快取資料,這樣`_bufferedCount`就不會等於0,直接返回`true`就可以了;只有當沒有快取資料(即_bufferedCount == 0)時,才需要執行可能非同步完成的操作。而且,由於只有`true`和`false`這兩種可能的結果,所以只需要兩個`Task`物件來分別表示`true`和`false`,因此執行時可以將這兩個物件快取下來,避免記憶體分配。只有當操作非同步完成時,該方法才需要分配新的`Task`,因為呼叫方在知道操作結果之前,就要得到`Task`物件,並且要求該物件是唯一的,這樣在操作完成後,就可以將結果儲存到該物件中。
執行時也為其他型別型維護了一個類似的小型快取,但是想要快取所有內容是不切實際的。例如下面這個方法:
```
public async Task ReadNextByteAsync()
{
if (_bufferedCount == 0)
{
await FillBuffer();
}
if (_bufferedCount == 0)
{
return -1;
}
_bufferedCount--;
return _buffer[_position++];
}
```
通常情況下,上面的案例也會同步完成。但是與上一個返回`Task`的案例不同,該方法返回的`Int32`的可能值約有40億個結果,如果將它們都快取下來,大概會消耗數百GB的記憶體。雖然執行時保留了一個小型快取,但也只保留了一小部分結果值,因此,如果該方法同步完成(緩衝區中有資料)的返回值是4,它會返回快取的`Task`,但是如果它同步完成的返回值是42,那就會分配一個新的`Task`,相當於呼叫了`Task.FromResult(42)`。
許多框架庫的實現也嘗試通過維護自己的快取來進一步緩解這種情況。例如,.NET Framework 4.5中引入的`MemoryStream.ReadAsync`過載方法總是會同步完成,因為它只從記憶體中讀取資料。它返回一個`Task`物件,其中`Int32`結果表示讀取的位元組數。`ReadAsync`常常用在迴圈中,並且每次呼叫時請求的位元組數是相同的(僅讀取到資料末尾時才有可能不同)。因此,重複呼叫通常會返回同步結果,其結果與上一次呼叫相同。這樣,可以維護單個`Task`例項的快取,即快取最後一次成功返回的`Task`例項。然後在後續呼叫中,如果新結果與其快取的結果相匹配,它還是返回快取的`Task`例項;否則,它會建立一個新的`Task`例項,並把它作為新的快取`Task`,然後將其返回。
即使這樣,在許多操作同步完成的情況下,仍需強制分配`Task`例項並返回。
## 同步完成時的ValueTask
正因如此,在.NET Core 2.0 中引入了一個新型別——`ValueTask`,用來優化效能。之前的.NET版本可以通過引用NuGet包使用:`System.Threading.Tasks.Extensions`
`ValueTask`是一個結構體(struct),用來包裝`TResult`或`Task`,因此它可以從非同步方法中返回。並且,如果方法是同步成功完成的,則不需要分配任何東西:我們可以簡單地使用`TResult`來初始化`ValueTask`並返回它。只有當方法非同步完成時,才需要分配一個`Task`例項,並使用`ValueTask`來包裝該例項。另外,為了使`ValueTask`更加輕量化,併為成功情形進行優化,所以丟擲未處理異常的非同步方法也會分配一個`Task`例項,以方便`ValueTask`包裝`Task`,而不是增加一個附加欄位來儲存異常(Exception)。
這樣,像`MemoryStream.ReadAsync`這類方法將返回`ValueTask`而不需要關注快取,現在可以使用以下程式碼:
```
public override ValueTask ReadAsync(byte[] buffer, int offset, int count)
{
try
{
int bytesRead = Read(buffer, offset, count);
return new ValueTask(bytesRead);
}
catch (Exception e)
{
return new ValueTask(Task.FromException(e));
}
}
```
## 非同步完成時的ValueTask
能夠編寫出在同步完成時無需為結果型別產生額外記憶體分配的非同步方法是一項很大的突破,.NET Core 2.0引入`ValueTask`的目的,就是將頻繁使用的新方法定義為返回`ValueTask`而不是`Task`。
例如,我們在.NET Core 2.1中的`Stream`類中添加了新的`ReadAsync`過載方法,以傳遞`Memory`來替代`byte[]`,該方法的返回型別就是`ValueTask`。這樣,Streams(一般都有一種同步完成的`ReadAsync`方法,如前面的`MemoryStream`示例中所示)現在可以在使用過程中更少的分配記憶體。
但是,在處理高吞吐量服務時,我們依舊需要考慮如何儘可能地避免額外記憶體分配,這就要想辦法減少或消除非同步完成時的記憶體分配。
使用`await`非同步程式設計模型時,對於任何非同步完成的操作,我們都需要返回代表該操作最終完成的物件:呼叫者需要能夠傳遞在操作完成時呼叫的回撥方法,這就要求在堆上有一個唯一的物件,用作這種特定操作的管道,但是,這並不意味著有關操作完成後能否重用該物件的任何資訊。如果物件可以重複使用,則API可以維護一個或多個此類物件的快取,並將其複用於序列化操作,也就是說,它不能將同一物件用於多個同時進行中的非同步操作,但可以複用於非並行訪問下的物件。
在.NET Core 2.1中,為了支援這種池化和複用,`ValueTask`進行了增強,不僅可以包裝`TResult`和`Task`,還可以包裝新引入的介面`IValueTaskSource`。類似於`Task`,`IValueTaskSource`提供表示非同步操作所需的核心支援;
```
public interface IValueTaskSource
{
ValueTaskSourceStatus GetStatus(short token);
void OnCompleted(Action