理解C#中的ValueTask

xiaoxiaotank發表於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<TResult>已然成為.NET程式設計的主力,也是以async/await(C# 5引入的)語法糖為代表的非同步程式設計模型的核心。

隨後,我會向大家介紹.NET Core 2.0中的新成員ValueTask/ValueTask<TResult>,來幫助你在日常開發用例中降低記憶體分配開銷,提升非同步效能。

Task

雖然Task的用法有很多,但其最核心的是“承諾(promise)”,用來表示某個操作最終完成。

當你初始化一個操作後,會獲取一個與該操作相關的Task,當這個操作完成時,Task也同樣會完成。這個操作的完成情況可能有以下幾種:

  • 作為初始化操作的一部分同步完成,例如:訪問一些已被快取的資料
  • 恰好在你獲取到Task例項的時候非同步完成,例如:訪問雖然沒被快取但是訪問速度非常快的資料
  • 你已經獲取到了Task例項,並等待了一段時間後,才非同步完成,例如:訪問一些網路資料

由於操作可能會非同步完成,所以當你想要使用最終結果時,你可以通過阻塞來等待結果返回(不過這違背了非同步操作的初衷);或者,使用回撥方法,它會在操作完成時被呼叫,.NET 4通過Task.ContinueWith方法顯式實現了這個回撥方法,如:

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<bool> MoveNextAsync()
{
    if (_bufferedCount == 0)
    {
        // 快取資料
        await FillBuffer();
    }
    return _bufferedCount > 0;
}

一般來說,我們想的是會有一些快取資料,這樣_bufferedCount就不會等於0,直接返回true就可以了;只有當沒有快取資料(即_bufferedCount == 0)時,才需要執行可能非同步完成的操作。而且,由於只有truefalse這兩種可能的結果,所以只需要兩個Task<bool>物件來分別表示truefalse,因此執行時可以將這兩個物件快取下來,避免記憶體分配。只有當操作非同步完成時,該方法才需要分配新的Task<bool>,因為呼叫方在知道操作結果之前,就要得到Task<bool>物件,並且要求該物件是唯一的,這樣在操作完成後,就可以將結果儲存到該物件中。

執行時也為其他型別型維護了一個類似的小型快取,但是想要快取所有內容是不切實際的。例如下面這個方法:

public async Task<int> ReadNextByteAsync()
{
    if (_bufferedCount == 0)
    {
        await FillBuffer();
    }

    if (_bufferedCount == 0)
    {
        return -1;
    }

    _bufferedCount--;
    return _buffer[_position++];
}

通常情況下,上面的案例也會同步完成。但是與上一個返回Task<bool>的案例不同,該方法返回的Int32的可能值約有40億個結果,如果將它們都快取下來,大概會消耗數百GB的記憶體。雖然執行時保留了一個小型快取,但也只保留了一小部分結果值,因此,如果該方法同步完成(緩衝區中有資料)的返回值是4,它會返回快取的Task<int>,但是如果它同步完成的返回值是42,那就會分配一個新的Task<int>,相當於呼叫了Task.FromResult(42)

許多框架庫的實現也嘗試通過維護自己的快取來進一步緩解這種情況。例如,.NET Framework 4.5中引入的MemoryStream.ReadAsync過載方法總是會同步完成,因為它只從記憶體中讀取資料。它返回一個Task<int>物件,其中Int32結果表示讀取的位元組數。ReadAsync常常用在迴圈中,並且每次呼叫時請求的位元組數是相同的(僅讀取到資料末尾時才有可能不同)。因此,重複呼叫通常會返回同步結果,其結果與上一次呼叫相同。這樣,可以維護單個Task例項的快取,即快取最後一次成功返回的Task例項。然後在後續呼叫中,如果新結果與其快取的結果相匹配,它還是返回快取的Task例項;否則,它會建立一個新的Task例項,並把它作為新的快取Task,然後將其返回。

即使這樣,在許多操作同步完成的情況下,仍需強制分配Task<TResult>例項並返回。

同步完成時的ValueTask<TResult>

正因如此,在.NET Core 2.0 中引入了一個新型別——ValueTask<TResult>,用來優化效能。之前的.NET版本可以通過引用NuGet包使用:System.Threading.Tasks.Extensions

ValueTask<TResult>是一個結構體(struct),用來包裝TResultTask<TResult>,因此它可以從非同步方法中返回。並且,如果方法是同步成功完成的,則不需要分配任何東西:我們可以簡單地使用TResult來初始化ValueTask<TResult>並返回它。只有當方法非同步完成時,才需要分配一個Task<TResult>例項,並使用ValueTask<TResult>來包裝該例項。另外,為了使ValueTask<TResult>更加輕量化,併為成功情形進行優化,所以丟擲未處理異常的非同步方法也會分配一個Task<TResult>例項,以方便ValueTask<TResult>包裝Task<TResult>,而不是增加一個附加欄位來儲存異常(Exception)。

這樣,像MemoryStream.ReadAsync這類方法將返回ValueTask<int>而不需要關注快取,現在可以使用以下程式碼:

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

非同步完成時的ValueTask<TResult>

能夠編寫出在同步完成時無需為結果型別產生額外記憶體分配的非同步方法是一項很大的突破,.NET Core 2.0引入ValueTask<TResult>的目的,就是將頻繁使用的新方法定義為返回ValueTask<TResult>而不是Task<TResult>

例如,我們在.NET Core 2.1中的Stream類中新增了新的ReadAsync過載方法,以傳遞Memory<byte>來替代byte[],該方法的返回型別就是ValueTask<int>。這樣,Streams(一般都有一種同步完成的ReadAsync方法,如前面的MemoryStream示例中所示)現在可以在使用過程中更少的分配記憶體。

但是,在處理高吞吐量服務時,我們依舊需要考慮如何儘可能地避免額外記憶體分配,這就要想辦法減少或消除非同步完成時的記憶體分配。

使用await非同步程式設計模型時,對於任何非同步完成的操作,我們都需要返回代表該操作最終完成的物件:呼叫者需要能夠傳遞在操作完成時呼叫的回撥方法,這就要求在堆上有一個唯一的物件,用作這種特定操作的管道,但是,這並不意味著有關操作完成後能否重用該物件的任何資訊。如果物件可以重複使用,則API可以維護一個或多個此類物件的快取,並將其複用於序列化操作,也就是說,它不能將同一物件用於多個同時進行中的非同步操作,但可以複用於非並行訪問下的物件。

在.NET Core 2.1中,為了支援這種池化和複用,ValueTask<TResult>進行了增強,不僅可以包裝TResultTask<TResult>,還可以包裝新引入的介面IValueTaskSource<TResult>。類似於Task<TResult>IValueTaskSource<TResult>提供表示非同步操作所需的核心支援;

public interface IValueTaskSource<out TResult>
{
    ValueTaskSourceStatus GetStatus(short token);
    void OnCompleted(Action<object> continuation, object state, short token, ValueTaskSourceOnCompletedFlags flags);
    TResult GetResult(short token);
}
  • GetStatus用於實現諸如ValueTask<TResult>.IsCompleted之類的屬性,返回指示非同步操作是否仍在掛起或是否已完成以及完成情況(成功或失敗)的指示。
  • OnCompleted用於ValueTask<TResult>的等待者(awaiter),它與呼叫者提供的回撥方法掛鉤,當非同步操作完成時,等待者繼續執行回撥方法。
  • GetResult用於檢索操作的結果,以便在操作完成後,等待者可以獲取TResult或傳播可能發生的任何異常。

大多數開發人員永遠都不需要用到此介面(指IValueTaskSource<TResult>):方法只是簡單地將包裝該介面例項的ValueTask<TResult>例項返回給呼叫者,而呼叫者並不需要知道內部細節。該介面的主要作用是為了讓開發人員在編寫效能敏感的API時可以儘可能地避免額外記憶體分配。

.NET Core 2.1中有幾個類似的API。最值得關注的是Socket.ReceiveAsyncSocket.SendAsync,新增了新的過載,例如:

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

此過載返回ValueTask<int>

如果操作同步完成,則可以簡單地構造具有正確結果的ValueTask<int>,例如:

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

如果它非同步完成,則可以使用實現此介面的池物件:

IValueTaskSource<int> vts = …;
return new ValueTask<int>(vts);

Socket實現維護了兩個這樣的池物件,一個用於Receive,一個用於Send,這樣,每次未完成的物件只要不超過一個,即使這些過載是非同步完成的,它們最終也不會額外分配記憶體。NetworkStream也因此受益。

例如,在.NET Core 2.1中,Stream公開了一個方法:

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

NetworkStream的過載方法NetworkStream.ReadAsync,內部實際邏輯只是交給了Socket.ReceiveAsync去處理,所以將優勢從Socket帶到了NetworkStream中,使得NetworkStream.ReadAsync也有效地不進行額外記憶體分配了。

非泛型的ValueTask

當在.NET Core 2.0中引入ValueTask<TResult>時,它純粹是為了優化非同步方法同步完成的情況——避免必須分配一個Task<TResult>例項用於儲存TResult。這也意味著非泛型的ValueTask是不必要的(因為沒有TResult):對於同步完成的情況,返回值為Task的方法可以返回Task.CompletedTask單例,此單例由async Task方法的執行時隱式返回。

然而,隨著即使非同步完成也要避免額外記憶體分配需求的出現,非泛型的ValueTask又變得必不可少。因此,在.NET Core 2.1中,我們還引入了非泛型的ValueTaskIValueTaskSource。它們提供泛型版本對應的非泛型版本,使用方式類似,只是GetResult返回void

實現IValueTaskSource / IValueTaskSource<TResult>

大多數開發人員都不需要實現這兩個介面,它們也不是特別容易實現。如果您需要的話,.NET Core 2.1的內部有幾種實現可以用作參考,例如

為了使想要這樣做的開發人員更輕鬆地進行開發,將在.NET Core 3.0中計劃引入ManualResetValueTaskSourceCore<TResult>結構體(譯註:目前已引入),用於實現介面的所有邏輯,並可以被包裝到其他實現了IValueTaskSourceIValueTaskSource<TResult>的包裝器物件中,這個包裝器物件只需要單純地將大部分實現交給該結構體就可以了。

ValueTask的有效消費模式

從表面上看,ValueTaskValueTask<TResult>的使用限制要比TaskTask<TResult>大得多 。不過沒關係,這甚至就是我們想要的,因為主要的消費方式就是簡單地await它們。

但是,由於ValueTaskValueTask<TResult>可能包裝可複用的物件,因此,與TaskTask<TResult>相比,如果呼叫者偏離了僅await它們的設計目的,則它們在使用上實際回受到很大的限制。通常,以下操作絕對不能用在ValueTask/ValueTask<TResult>上:

  • await ValueTask/ValueTask<TResult>多次。

    因為底層物件可能已經被回收了,並已由其他操作使用。而Task/Task<TResult>永遠不會從完成狀態轉換為未完成狀態,因此您可以根據需要等待多次,並且每次都會得到相同的結果。

  • 併發await ValueTask/ValueTask<TResult>

    底層物件期望一次只有單個呼叫者的單個回撥來使用,並且嘗試同時等待它可能很容易引入競爭條件和細微的程式錯誤。這也是第一個錯誤操作的一個更具體的情況——await ValueTask/ValueTask<TResult>多次。相反,Task/Task<TResult>支援任意數量的併發等待

  • 使用.GetAwaiter().GetResult()時操作尚未完成。

    IValueTaskSource / IValueTaskSource<TResult>介面的實現中,在操作完成前是沒有強制要求支援阻塞的,並且很可能不會支援,所以這種操作本質上是一種競爭狀態,也不可能按照呼叫方的意願去執行。相反,Task/Task<TResult>支援此功能,可以阻塞呼叫者,直到任務完成。

如果您使用ValueTask/ValueTask<TResult>,並且您確實需要執行上述任一操作,則應使用.AsTask()獲取Task/Task<TResult>例項,然後對該例項進行操作。並且,在之後的程式碼中您再也不應該與該ValueTask/ValueTask<TResult>進行互動。
簡單說就是使用ValueTask/ValueTask<TResult>時,您應該直接await它(可以有選擇地加上.ConfigureAwait(false)),或直接呼叫AsTask()且再也不要使用它,例如:

// 以這個方法為例
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();
... // 將例項儲存到本地會使它被濫用的可能性更大,
    // 不過這還好,適當使用沒啥問題

// BAD: await 多次
ValueTask<int> vt = SomeValueTaskReturningMethodAsync();
int result = await vt;
int result2 = await vt;

// BAD: 併發 await (and, by definition then, multiple times)
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>Result被訪問或自身被await之後,不會再被使用了。

新非同步API都應返回ValueTask / ValueTask<TResult>嗎?

當然不是,Task/Task<TResult>仍然是預設選擇

正如上文所強調的那樣,Task/Task<TResult>ValueTask/ValueTask<TResult>更加容易正確使用,所以除非對效能的影響大於可用性的影響,否則Task/Task<TResult>仍然是最優的。

此外,返回ValueTask<TResult>會比返回Task<TResult>多一些小開銷,例如,await Task<TResult>await ValueTask<TResult>會更快一些,所以如果你可以使用快取的Task例項(例如,你的API返回TaskTask<bool>),你或許應該為了更好地效能而仍使用TaskTask<bool>。而且,ValueTask/ValueTask<TResult>相比Task/Task<TResult>有更多的欄位,所以當它們被await、並將它們的欄位儲存在呼叫非同步方法的狀態機中時,它們會在該狀態機物件中佔用更多的空間。

但是,如果是以下情況,那你應該使用ValueTask/ValueTask<TResult>

  1. 你希望API的呼叫者只能直接await
  2. 避免額外的記憶體分配的開銷對API很重要
  3. 你預期該API常常是同步完成的,或者在非同步完成時你可以有效地池化物件。

在新增抽象、虛擬或介面方法時,您還需要考慮這些方法的過載/實現是否存在這些情況。

ValueTask和ValueTask<TResult>的下一步是什麼?

對於.NET Core庫,我們將依然會看到新的API被新增進來,其返回值是Task/Task<TResult>,但在適當的地方,我們也將看到新增了新的以ValueTask/ValueTask<TResult>為返回值的API。

ValueTask/ValueTask<TResult>的一個關鍵例子就是在.NET Core 3.0新增新的IAsyncEnumerator<T>支援。IEnumerator<T>公開了一個返回boolMoveNext方法,非同步IAsyncEnumerator<T>則會公開一個MoveNextAsync方法。剛開始設計此功能時,我們認為MoveNextAsync 應返回Task<bool>,一般情況下,通過快取的Task<bool>在同步完成時可以非常高效地執行此操作。但是,考慮到我們期望的非同步列舉的廣泛性,並且考慮到它們基於是基於介面的,其可能有許多不同的實現方式(其中一些可能會非常關注效能和記憶體分配),並且鑑於絕大多數的消費者將通過await foreach來使用,我們決定MoveNextAsync返回ValueTask<bool>。這樣既可以使同步完成案例變得很快,又可以使用可重用的物件來使非同步完成案例的記憶體分配也減少。實際上,在實現非同步迭代器時,C#編譯器會利用此優勢,以使非同步迭代器儘可能免於額外記憶體分配。

相關文章