async/await 在 C# 語言中是如何工作的?(上)

微軟技術棧發表於2023-04-10

前不久,我們釋出了《選擇 .NET 的 n 個理由》。它提供了對平臺的高層次概述,總結了各種元件和設計決策,並承諾對所涉及的領域發表更深入的文章。這是第一篇這樣深入探討 C# 和 .NET 中 async/await 的歷史、背後的設計決策和實現細節的文章。

對 async/await 的支援已經存在了十年之久。在這段時間裡,它改變了為 .NET 編寫可擴充套件程式碼的方式,而在不瞭解其底層邏輯的情況下使用該功能是可行的,也是非常常見的。在這篇文章中,我們將深入探討 await 在語言、編譯器和庫級別的工作原理,以便你可以充分利用這些有價值的功能。

不過,要做到這一點,我們需要追溯到 async/await 之前,以瞭解在沒有它的情況下最先進的非同步程式碼是什麼樣子的。

最初的樣子

早在 .NET Framework 1.0中,就有非同步程式設計模型模式,又稱 APM 模式、Begin/End 模式、IAsyncResult 模式。在高層次上,該模式很簡單。對於同步操作 DoStuff:

class Handler
{
    public int DoStuff(string arg);
}

作為模式的一部分,將有兩個相應的方法:BeginDoStuff 方法和 EndDoStuff 方法:

class Handler
{
    public int DoStuff(string arg);
    public IAsyncResult BeginDoStuff(string arg, AsyncCallback? callback, object? state);
    public int EndDoStuff(IAsyncResult asyncResult);
}

BeginDoStuff 會像 DoStuff 一樣接受所有相同的引數,但除此之外,它還會接受 AsyncCallback 委託和一個不透明的狀態物件,其中一個或兩個都可以為 null。Begin 方法負責初始化非同步操作,如果提供了回撥(通常稱為初始操作的“延續”),它還負責確保在非同步操作完成時呼叫回撥。Begin 方法還將構造一個實現了 IAsyncResult 的型別例項,使用可選狀態填充 IAsyncResult 的 AsyncState 屬性:

namespace System
{
    public interface IAsyncResult
    {
        object? AsyncState { get; }
        WaitHandle AsyncWaitHandle { get; }
        bool IsCompleted { get; }
        bool CompletedSynchronously { get; }
    }
    public delegate void AsyncCallback(IAsyncResult ar);
}

然後,這個 IAsyncResult 例項將從 Begin 方法返回,並在最終呼叫 AsyncCallback 時傳遞給它。當準備使用操作的結果時,呼叫者將把 IAsyncResult 例項傳遞給 End 方法,該方法負責確保操作已完成(如果沒有完成,則透過阻塞同步等待操作完成),然後返回操作的任何結果,包括傳播可能發生的任何錯誤和異常。因此,不用像下面這樣寫程式碼來同步執行操作:

try
{
    int i = handler.DoStuff(arg);
    Use(i);
}
catch (Exception e)
{
    ... // handle exceptions from DoStuff and Use
}

可以按以下方式使用 Begin/End 方法非同步執行相同的操作:

try
{
    handler.BeginDoStuff(arg, iar =>
    {
        try
        {
            Handler handler = (Handler)iar.AsyncState!;
            int i = handler.EndDoStuff(iar);
            Use(i);
        }
        catch (Exception e2)
        {
            ... // handle exceptions from EndDoStuff and Use
        }
    }, handler);
}
catch (Exception e)
{
    ... // handle exceptions thrown from the synchronous call to BeginDoStuff
}

對於在任何語言中處理過基於回撥的 API 的人來說,這應該感覺很熟悉。

然而,事情從此變得更加複雜。例如,有一個"stack dives"的問題。stack dives 是指程式碼反覆呼叫,在堆疊中越陷越深,以至於可能出現堆疊溢位。如果操作同步完成,Begin 方法被允許同步呼叫回撥,這意味著對 Begin 的呼叫本身可能直接呼叫回撥。同步完成的 "非同步 "操作實際上是很常見的;它們不是 "非同步",因為它們被保證非同步完成,而只是被允許這樣做。

這是一種真實的可能性,很容易再現。在 .NET Core 上試試這個程式:

using System.NET;
using System.NET.Sockets;
using Socket listener = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
listener.Bind(new IPEndPoint(IPAddress.Loopback, 0));
listener.Listen();
using Socket client = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
client.Connect(listener.LocalEndPoint!);
using Socket server = listener.Accept();
_ = server.SendAsync(new byte[100_000]);
var mres = new ManualResetEventSlim();
byte[] buffer = new byte[1];
var stream = new NetworkStream(client);
void ReadAgain()
{
    stream.BeginRead(buffer, 0, 1, iar =>
    {
        if (stream.EndRead(iar) != 0)
        {
            ReadAgain(); // uh oh!
        }
        else
        {
            mres.Set();
        }
    }, null);
};
ReadAgain();
mres.Wait();

在這裡,我設定了一個相互連線的簡單客戶端套接字和伺服器套接字。伺服器向客戶端傳送100,000位元組,然後客戶端繼續使用 BeginRead/EndRead 來“非同步”地每次讀取一個位元組。傳給 BeginRead 的回撥函式透過呼叫 EndRead 來完成讀取,然後如果它成功讀取了所需的位元組,它會透過遞迴呼叫 ReadAgain 區域性函式來發出另一個 BeginRead。然而,在 .NET Core 中,套接字操作比在 .NET Framework 上快得多,並且如果作業系統能夠滿足同步操作,它將同步完成(注意核心本身有一個緩衝區用於滿足套接字接收操作)。因此,這個堆疊會溢位:

圖片

因此,APM 模型中內建了補償機制。有兩種可能的方法可以彌補這一點:

1.不要允許 AsyncCallback 被同步呼叫。如果一直非同步呼叫它,即使操作以同步方式完成,那麼 stack dives 的風險也會消失。但是效能也是如此,因為同步完成的操作(或者快到無法觀察到它們的區別)是非常常見的,強迫每個操作排隊回撥會增加可測量的開銷。
2.使用一種機制,允許呼叫方而不是回撥方在操作同步完成時執行延續工作。這樣,您就可以避開額外的方法框架,繼續執行後續工作,而不深入堆疊。

APM 模式與方法2一起使用。為此,IAsyncResult 介面公開了兩個相關但不同的成員:IsCompleted 和 CompletedSynchronously。IsCompleted 告訴你操作是否已經完成,可以多次檢查它,最終它會從 false 轉換為 true,然後保持不變。相比之下,CompletedSynchronously 永遠不會改變(如果改變了,那就是一個令人討厭的 bug)。它用於 Begin 方法的呼叫者和 AsyncCallback 之間的通訊,他們中的一個負責執行任何延續工作。如果 CompletedSynchronously 為 false,則操作是非同步完成的,響應操作完成的任何後續工作都應該留給回撥;畢竟,如果工作沒有同步完成,Begin 的呼叫方無法真正處理它,因為還不知道操作已經完成(如果呼叫方只是呼叫 End,它將阻塞直到操作完成)。然而,如果 CompletedSynchronously 為真,如果回撥要處理延續工作,那麼它就有 stack dives 的風險,因為它將在堆疊上執行比開始時更深的延續工作。因此,任何涉及到這種堆疊潛水的實現都需要檢查 CompletedSynchronously,並讓 Begin 方法的呼叫者執行延續工作(如果它為真),這意味著回撥不需要執行延續工作。這也是 CompletedSynchronously 永遠不能更改的原因,呼叫方和回撥方需要看到相同的值,以確保不管競爭條件如何,延續工作只執行一次。

我們都習慣了現代語言中的控制流結構為我們提供的強大和簡單性,一旦引入了任何合理的複雜性,而基於回撥的方法通常會與這種結構相沖突。其他主流語言也沒有更好的替代方案。

我們需要一種更好的方法,一種從 APM 模式中學習的方法,融合它正確的東西,同時避免它的陷阱。值得注意的是,APM 模式只是一種模式。執行時間、核心庫和編譯器在使用或實現該模式時並沒有提供任何幫助。

基於事件的非同步模式

.NET Framework 2.0引入了一些 API,實現了處理非同步操作的不同模式,這種模式主要用於在客戶端應用程式上下文中處理非同步操作。這種基於事件的非同步模式或 EAP 也作為一對成員出現,這次是一個用於初始化非同步操作的方法和一個用於偵聽其完成的事件。因此,我們之前的 DoStuff 示例可能被公開為一組成員,如下所示:

class Handler
{
    public int DoStuff(string arg);
    public void DoStuffAsync(string arg, object? userToken);
    public event DoStuffEventHandler? DoStuffCompleted;
}
public delegate void DoStuffEventHandler(object sender, DoStuffEventArgs e);
public class DoStuffEventArgs : AsyncCompletedEventArgs
{
    public DoStuffEventArgs(int result, Exception? error, bool canceled, object? userToken) :
        base(error, canceled, usertoken) => Result = result;
    public int Result { get; }
}

你需要用 DoStuffCompleted 事件註冊你的後續工作,然後呼叫 DoStuffAsync 方法;它將啟動該操作,並且在該操作完成時,呼叫者將非同步地引發 DoStuffCompleted 事件。然後,處理程式可以繼續執行後續工作,可能會驗證所提供的 userToken 與它所期望的進行匹配,從而允許多個處理程式同時連線到事件。

這種模式使一些用例變得更簡單,同時使其他用例變得更加困難(考慮到前面的 APM CopyStreamToStream 示例,這說明了一些問題)。它沒有以廣泛的方式推出,只是在一個單獨的 .NET Framework 版本中匆匆的出現又消失了,儘管留下了它使用期間新增的 api,如 Ping.SendAsync/Ping.PingCompleted:

public class Ping : Component
{
    public void SendAsync(string hostNameOrAddress, object? userToken);
    public event PingCompletedEventHandler? PingCompleted;
    ...
}

然而,它確實取得了一個 APM 模式完全沒有考慮到的顯著進步,並且這一點一直延續到我們今天所接受的模型中: SynchronizationContext

考慮到像 Windows Forms 這樣的 UI 框架。與 Windows 上的大多數 UI 框架一樣,控制元件與特定的執行緒相關聯,該執行緒執行一個訊息泵,該訊息泵執行能夠與這些控制元件互動的工作,只有該執行緒應該嘗試操作這些控制元件,而任何其他想要與控制元件互動的執行緒都應該透過傳送訊息由 UI 執行緒的泵消耗來完成操作。Windows 窗體使用 ControlBeginInvoke 等方法使這變得很容易,它將提供的委託和引數排隊,由與該控制元件相關聯的任何執行緒執行。因此,你可以這樣編寫程式碼:

private void button1_Click(object sender, EventArgs e)
{
    ThreadPool.QueueUserWorkItem(_ =>
    {
        string message = ComputeMessage();
        button1.BeginInvoke(() =>
        {
            button1.Text = message;
        });
    });
}

這將解除安裝在 ThreadPool 執行緒上完成的 ComputeMessage()工作(以便在處理 UI 的過程中保持 UI 的響應性),然後在工作完成時,將委託佇列返回到與 button1 相關的執行緒,以更新 button1 的標籤。這很簡單,WPF 也有類似的東西,只是用它的 Dispatcher 型別:

private void button1_Click(object sender, RoutedEventArgs e){
ThreadPool.QueueUserWorkItem(_ =>
{
string message = ComputeMessage();
button1.Dispatcher.InvokeAsync(() =>
{
button1.Content = message;
});
});}

.NET MAUI 也有類似的功能。但如果我想把這個邏輯放到輔助方法中呢?

E.g.

// Call ComputeMessage and then invoke the update action to update controls.internal static void ComputeMessageAndInvokeUpdate(Action<string> update) { ... }

然後我可以這樣使用它:

private void button1_Click(object sender, EventArgs e){
    ComputeMessageAndInvokeUpdate(message => button1.Text = message);}

但是如何實現 ComputeMessageAndInvokeUpdate,使其能夠在這些應用程式中工作呢?是否需要硬編碼才能瞭解每個可能的 UI 框架?這就是 SynchronizationContext 的魅力所在。我們可以這樣實現這個方法:

internal static void ComputeMessageAndInvokeUpdate(Action<string> update){
    SynchronizationContext? sc = SynchronizationContext.Current;
    ThreadPool.QueueUserWorkItem(_ =>
    {
        string message = ComputeMessage();
        if (sc is not null)
        {
            sc.Post(_ => update(message), null);
        }
        else
        {
            update(message);
        }
    });}

它使用 SynchronizationContext 作為一個抽象,目標是任何“排程器”,應該用於回到與 UI 互動的必要環境。然後,每個應用程式模型確保它作為 SynchronizationContext.Current 釋出一個 SynchronizationContext-derived 型別,去做 "正確的事情"。例如,Windows Forms 有這個:

public sealed class WindowsFormsSynchronizationContext : SynchronizationContext, IDisposable{
    public override void Post(SendOrPostCallback d, object? state) =>
        _controlToSendTo?.BeginInvoke(d, new object?[] { state });
    ...}

WPF 有這個:

public sealed class DispatcherSynchronizationContext : SynchronizationContext{
    public override void Post(SendOrPostCallback d, Object state) =>
        _dispatcher.BeginInvoke(_priority, d, state);
    ...}

ASP.NET 曾經有一個,它實際上並不關心工作在什麼執行緒上執行,而是關心給定的請求相關的工作被序列化,這樣多個執行緒就不會併發地訪問給定的 HttpContext:

internal sealed class AspNetSynchronizationContext : AspNetSynchronizationContextBase{
    public override void Post(SendOrPostCallback callback, Object state) =>
        _state.Helper.QueueAsynchronous(() => callback(state));
    ...}

這也不限於這些主要的應用程式模型。例如,xunit 是一個流行的單元測試框架,是 .NET 核心儲存庫用於單元測試的框架,它也採用了多個自定義的 SynchronizationContext。例如,你可以允許並行執行測試,但限制允許併發執行的測試數量。這是如何實現的呢?透過 SynchronizationContext:

public class MaxConcurrencySyncContext : SynchronizationContext, IDisposable{
    public override void Post(SendOrPostCallback d, object? state)
    {
        var context = ExecutionContext.Capture();
        workQueue.Enqueue((d, state, context));
        workReady.Set();
    }}

MaxConcurrencySyncContext 的 Post 方法只是將工作排到自己的內部工作佇列中,然後在它自己的工作執行緒上處理它,它根據所需的最大併發數來控制有多少工作執行緒。

這與基於事件的非同步模式有什麼聯絡?EAP 和 SynchronizationContext 是同時引入的,當非同步操作被啟動時,EAP 規定完成事件應該排隊到當前任何 SynchronizationContext 中。為了稍微簡化一下,System.ComponentModel 中也引入了一些輔助型別,尤其是 AsyncOperation 和 AsyncOperationManager。前者只是一個元組,封裝了使用者提供的狀態物件和捕獲的 SynchronizationContext,而後者只是作為一個簡單的工廠來捕獲並建立 AsyncOperation 例項。然後 EAP 實現將使用這些,例如 Ping.SendAsync 呼叫 AsyncOperationManager.CreateOperation 來捕獲 SynchronizationContext。當操作完成時,AsyncOperation 的 PostOperationCompleted 方法將被呼叫,以呼叫儲存的 SynchronizationContext 的 Post 方法。

我們需要比 APM 模式更好的東西,接下來出現的 EAP 引入了一些新的事務,但並沒有真正解決我們面臨的核心問題。我們仍然需要更好的東西。

輸入任務

.NET Framework 4.0 引入了 System.Threading.Tasks.Task 型別。從本質上講,Task 只是一個資料結構,表示某些非同步操作的最終完成(其他框架將類似的型別稱為“promise”或“future”)。建立 Task 是為了表示某些操作,然後當它表示的操作邏輯上完成時,結果儲存到該 Task 中。但是 Task 提供的關鍵特性使它比 IAsyncResult 更有用,它在自己內部內建了 continuation 的概念。這一特性意味著您可以訪問任何 Task,並在其完成時請求非同步通知,由任務本身處理同步,以確保繼續被呼叫,無論任務是否已經完成、尚未完成、還是與通知請求同時完成。為什麼會有如此大的影響?如果你還記得我們對舊 APM 模式的討論,有兩個主要問題。

  1. 你必須為每個操作實現一個自定義的 IAsyncResult 實現:沒有內建的 IAsyncResult 實現,任何人都可以根據需要使用。
  2. 在 Begin 方法被呼叫之前,你必須知道當它完成時要做什麼。這使得實現組合器和其他用於消耗和組合任意非同步實現的通用例程成為一個重大挑戰。

現在,讓我們更好地理解它的實際含義。我們先從幾個欄位開始:

class MyTask{
    private bool _completed;
    private Exception? _error;
    private Action<MyTask>? _continuation;
    private ExecutionContext? _ec;
    ...}

我們需要一個欄位來知道任務是否完成(_completed),還需要一個欄位來儲存導致任務失敗的任何錯誤(_error);如果我們還要實現一個通用的 MyTask<TResult>,那麼也會有一個私有的 TResult _result 欄位,用於儲存操作的成功結果。到目前為止,這看起來很像我們之前自定義的 IAsyncResult 實現(當然,這不是巧合)。但是現在最重要的部分,是 _continuation 欄位。在這個簡單的實現中,我們只支援一個 continuation,但對於解釋目的來說這已經足夠了(真正的任務使用了一個物件欄位,該欄位可以是單個 continuation 物件,也可以是 continuation 物件的 List<>)。這是一個委託,將在任務完成時呼叫。

如前所述,與以前的模型相比,Task 的一個基本進步是能夠在操作開始後提供延續工作(回撥)。我們需要一個方法來做到這一點,所以讓我們新增 ContinueWith:

public void ContinueWith(Action<MyTask> action){
    lock (this)
    {
        if (_completed)
        {
            ThreadPool.QueueUserWorkItem(_ => action(this));
        }
        else if (_continuation is not null)
        {
            throw new InvalidOperationException("Unlike Task, this implementation only supports a single continuation.");
        }
        else
        {
            _continuation = action;
            _ec = ExecutionContext.Capture();
        }
    }}

如果任務在 ContinueWith 被呼叫時已經被標記為完成,ContinueWith 只是排隊執行委託。否則,該方法將儲存該委託,以便在任務完成時可以排隊繼續執行(它還儲存了一個叫做 ExecutionContext 的東西,然後在以後呼叫該委託時使用它)。

然後,我們需要能夠將 MyTask 標記為完成,這意味著它所代表的非同步操作已經完成。為此,我們將提供兩個方法,一個用於標記完成(" SetResult "),另一個用於標記完成並返回錯誤(" SetException "):

public void SetResult() => Complete(null);
public void SetException(Exception error) => Complete(error);
private void Complete(Exception? error){
    lock (this)
    {
        if (_completed)
        {
            throw new InvalidOperationException("Already completed");
        }

        _error = error;
        _completed = true;

        if (_continuation is not null)
        {
            ThreadPool.QueueUserWorkItem(_ =>
            {
                if (_ec is not null)
                {
                    ExecutionContext.Run(_ec, _ => _continuation(this), null);
                }
                else
                {
                    _continuation(this);
                }
            });
        }
    }}

我們儲存任何錯誤,將任務標記為已完成,然後如果之前已經註冊了 continuation,則將其排隊等待呼叫。

最後,我們需要一種方法來傳播任務中可能發生的任何異常(並且,如果這是一個泛型 MyTask<T>,則返回其_result);為了方便某些情況,我們還允許此方法阻塞等待任務完成,這可以透過 ContinueWith 實現(continuation 只是發出 ManualResetEventSlim 訊號,然後呼叫者阻塞等待完成)。

public void Wait(){
    ManualResetEventSlim? mres = null;
    lock (this)
    {
        if (!_completed)
        {
            mres = new ManualResetEventSlim();
            ContinueWith(_ => mres.Set());
        }
    }

    mres?.Wait();
    if (_error is not null)
    {
        ExceptionDispatchInfo.Throw(_error);
    }}

基本上就是這樣。現在可以肯定的是,真正的 Task 要複雜得多,有更高效的實現,支援任意數量的 continuation,有大量關於它應該如何表現的按鈕(例如,continuation 應該像這裡所做的那樣排隊,還是應該作為任務完成的一部分同步呼叫),能夠儲存多個異常而不是一個異常,具有取消的特殊知識,有大量的輔助方法用於執行常見操作,例如 Task.Run,它建立一個 Task 來表示執行緒池上呼叫的委託佇列等等。

你可能還注意到,我簡單的 MyTask 直接有公共的 SetResult/SetException 方法,而 Task 沒有。實際上,Task 確實有這樣的方法,它們只是內部的,System.Threading.Tasks.TaskCompletionSource 型別作為任務及其完成的獨立“生產者”;這樣做不是出於技術上的需要,而是為了讓完成方法遠離只用於消費的東西。然後,你就可以把 Task 分發出去,而不必擔心它會在你下面完成;完成訊號是建立任務的實現細節,並且透過保留 TaskCompletionSource 本身來保留完成它的權利。(CancellationToken 和 CancellationTokenSource 遵循類似的模式:CancellationToken 只是 CancellationTokenSource 的一個結構封裝器,只提供與消費取消訊號相關的公共區域,但沒有產生取消訊號的能力,而產生取消訊號的能力僅限於能夠訪問 CancellationTokenSource的人。)

當然,我們可以為這個 MyTask 實現組合器和輔助器,就像 Task 提供的那樣。想要一個簡單的 MyTask.WhenAll?

public static MyTask WhenAll(MyTask t1, MyTask t2){
    var t = new MyTask();

    int remaining = 2;
    Exception? e = null;

    Action<MyTask> continuation = completed =>
    {
        e ??= completed._error; // just store a single exception for simplicity
        if (Interlocked.Decrement(ref remaining) == 0)
        {
            if (e is not null) t.SetException(e);
            else t.SetResult();
        }
    };

    t1.ContinueWith(continuation);
    t2.ContinueWith(continuation);

    return t;}

想要一個 MyTask.Run?你得到了它:

public static MyTask Run(Action action){
    var t = new MyTask();

    ThreadPool.QueueUserWorkItem(_ =>
    {
        try
        {
            action();
            t.SetResult();
        }
        catch (Exception e)
        {
            t.SetException(e);
        }
    });

    return t;}

一個 MyTask.Delay 怎麼樣?當然可以:

public static MyTask Delay(TimeSpan delay){
    var t = new MyTask();

    var timer = new Timer(_ => t.SetResult());
    timer.Change(delay, Timeout.InfiniteTimeSpan);

    return t;}

有了 Task,.NET 中之前的所有非同步模式都將成為過去。在以前使用 APM 模式或 EAP 模式實現非同步實現的地方,都會公開新的 Task 返回方法。

▌ValueTasks

時至今日,Task 仍然是 .NET 中非同步處理的主力,每次釋出都有新方法公開,並且在整個生態系統中都例行地返回 Task 和  Task<TResult>。然而,Task 是一個類,這意味著建立一個類需要分配記憶體。在大多數情況下,為一個長期非同步操作額外分配記憶體是微不足道的,除了對效能最敏感的操作之外,不會對所有操作的效能產生有意義的影響。不過,如前所述,非同步操作的同步完成是相當常見的。引入 Stream.ReadAsync 是為了返回一個 Task<int>,但如果你從一個 BufferedStream 中讀取資料,很有可能很多讀取都是同步完成的,因為只需要從記憶體中的緩衝區中讀取資料,而不是執行系統呼叫和真正的 I/O 操作。不得不分配一個額外的物件來返回這樣的資料是不幸的(注意,APM 也是這樣的情況)。對於返回 Task 的非泛型方法,該方法可以只返回一個已經完成的單例任務,而實際上 Task.CompletedTask 提供了一個這樣的單例 Task。但對於 Task<TResult> 來說,不可能為每個可能的結果快取一個 Task。我們可以做些什麼來讓這種同步完成更快呢?

快取一些 Task<TResult> 是可能的。例如,Task<bool> 非常常見,而且只有兩個有意義的東西需要快取:當結果為 true 時,一個 Task<bool>,當結果為 false 時,一個 Task<bool>。或者,雖然我們不想快取40億個 Task<int> 來容納所有可能的 Int32 結果,但小的 Int32 值是非常常見的,因此我們可以快取一些值,比如-1到8。或者對於任意型別,default 是一個合理的通用值,因此我們可以快取每個相關型別的 Task<TResult>,其中 Result 為 default(TResult)。事實上,Task.FromResult 今天也是這樣做的 (從最近的 .NET 版本開始),使用一個小型的可複用的 Task<TResult> 單例快取,並在適當時返回其中一個,或者為準確提供的結果值分配一個新的 Task<TResult>。可以建立其他方案來處理其他合理的常見情況。例如,當使用 Stream.ReadAsync 時,在同一個流上多次呼叫它是合理的,而且每次呼叫時允許讀取的位元組數都是相同的。實現能夠完全滿足 count 請求是合理的。這意味著 Stream.ReadAsync 重複返回相同的 int 值是很常見的。為了避免這種情況下的多次分配,多個 Stream 型別(如 MemoryStream)會快取它們最後成功返回的 Task<int>,如果下一次讀取也同步完成併成功獲得相同的結果,它可以只是再次返回相同的 Task<int>,而不是建立一個新的。但其他情況呢?在效能開銷非常重要的情況下,如何更普遍地避免對同步完成的這種分配?

這就是 ValueTask<TResult> 的作用。ValueTask<TResult> 最初是作為 TResult 和 Task<TResult> 之間的一個區分並集。說到底,拋開那些花哨的東西,這就是它的全部 (或者,更確切地說,曾經是),是一個即時的結果,或者是對未來某個時刻的一個結果的承諾:

public readonly struct ValueTask<TResult>{
   private readonly Task<TResult>? _task;
   private readonly TResult _result;
   ...}

然後,一個方法可以返回這樣一個 ValueTask<TResult>,而不是一個 Task<TResult>,如果 TResult 在需要返回的時候已經知道了,那麼就可以避免 Task<TResult> 的分配,代價是一個更大的返回型別和稍微多一點間接性。

然而,在一些超級極端的高效能場景中,即使在非同步完成的情況下,您也希望能夠避免 Task<TResult> 分配。例如,Socket 位於網路堆疊的底部,Socket 上的 SendAsync 和 ReceiveAsync 對於許多服務來說是非常熱門的路徑,同步和非同步完成都非常常見(大多數同步傳送完成,許多同步接收完成,因為資料已經在核心中緩衝了)。如果在一個給定的 Socket 上,我們可以使這樣的傳送和接收不受分配限制,而不管操作是同步完成還是非同步完成,這不是很好嗎?

這就是 System.Threading.Tasks.Sources.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);}

IValueTaskSource<TResult> 介面允許一個實現為 ValueTask<TResult> 提供自己的支援物件,使該物件能夠實現像 GetResult 這樣的方法來檢索操作的結果,以及 OnCompleted 來連線操作的延續。就這樣,ValueTask<TResult> 對其定義進行了一個小小的更改,其 Task<TResult>? _task 欄位替換為 object? _obj 欄位:

public readonly struct ValueTask<TResult>{
   private readonly object? _obj;
   private readonly TResult _result;
   ...}

以前 _task 欄位要麼是 Task<TResult> 要麼是 null,現在 _obj 欄位也可以是 IValueTaskSource<TResult>。一旦 Task<TResult> 被標記為已完成,它將保持完成狀態,並且永遠不會轉換回未完成的狀態。相比之下,實現 IValueTaskSource<TResult> 的物件對實現有完全的控制權,可以自由地在完成狀態和不完成狀態之間雙向轉換,因為 ValueTask<TResult> 的契約是一個給定的例項只能被消耗一次,因此從結構上看,它不應該觀察到底層例項的消耗後變化(這就是 CA2012等分析規則存在的原因)。這就使得像 Socket 這樣的型別能夠將 IValueTaskSource<TResult> 的例項集中起來,用於重複呼叫。Socket 最多可以快取兩個這樣的例項,一個用於讀,一個用於寫,因為99.999%的情況是在同一時間最多隻有一個接收和一個傳送。

我提到了 ValueTask<TResult>,但沒有提到 ValueTask。當只處理避免同步完成的分配時,使用非泛型 ValueTask(代表無結果的無效操作)在效能上沒有什麼好處,因為同樣的條件可以用 Task.CompletedTask 來表示。但是,一旦我們關心在非同步完成的情況下使用可池化的底層物件來避免分配的能力,那麼這對非泛型也很重要。因此,當 IValueTaskSource<TResult> 被引入時,IValueTaskSource 和 ValueTask 也被引入。

因此,我們有 Task、Task<TResult>、ValueTask 和 ValueTask<TResult>。我們能夠以各種方式與它們互動,表示任意的非同步操作,並連線 continuation 來處理這些非同步操作的完成。

下期文章,我們將繼續介紹 C# 迭代器,歡迎持續關注。

相關文章