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

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

接《async/await 在 C# 語言中是如何工作的?(上)》、《async/await 在 C# 語言中是如何工作的?(中)》,今天我們繼續介紹 SynchronizationContext 和 ConfigureAwait。

▌SynchronizationContext 和 ConfigureAwait

我們之前在 EAP 模式的上下文中討論過 SynchronizationContext,並提到它將再次出現。SynchronizationContext 使得呼叫可重用的輔助函式成為可能,並自動被排程回撥用環境認為合適的任何地方。因此,我們很自然地認為 async/await 能“正常工作”,事實也的確如此。回到前面的按鈕單擊處理程式:

ThreadPool.QueueUserWorkItem(_ =>
{
    string message = ComputeMessage();
    button1.BeginInvoke(() =>
    {
        button1.Text = message;
    });
});

使用 async/await,我們可以這樣寫:

button1.Text = await Task.Run(() => ComputeMessage());

對 ComputeMessage 的呼叫被轉移到執行緒池中,這個方法執行完畢後,執行又轉移回與按鈕關聯的 UI 執行緒,設定按鈕的 Text 屬性就是在這個執行緒中進行的。

與 SynchronizationContext 的整合由 awaiter 實現(為狀態機生成的程式碼對 SynchronizationContext 一無所知),因為當所表示的非同步操作完成時,是 awaiter 負責實際呼叫或將所提供的 continuation 排隊。而自定義 awaiter 不需要考慮 SynchronizationContext。目前,Task、Task<TResult>、ValueTask、ValueTask<TResult> 的等待器都是 do。這意味著,預設情況下,當你等待一個任務,一個 Task<TResult>,一個 ValueTask,一個 ValueTask<TResult>,甚至 Task. yield() 呼叫的結果時,awaiter 預設會查詢當前的 SynchronizationContext,如果它成功地獲得了一個非預設的同步上下文,最終會將 continuation 排隊到該上下文。

如果我們檢視 TaskAwaiter 中涉及的程式碼,就可以看到這一點。以下是 Corelib 中的相關程式碼片段:

internal void UnsafeSetContinuationForAwait(IAsyncStateMachineBox stateMachineBox, bool continueOnCapturedContext)
{
    if (continueOnCapturedContext)
    {
        SynchronizationContext? syncCtx = SynchronizationContext.Current;
        if (syncCtx != null && syncCtx.GetType() != typeof(SynchronizationContext))
        {
            var tc = new SynchronizationContextAwaitTaskContinuation(syncCtx, stateMachineBox.MoveNextAction, flowExecutionContext: false);
            if (!AddTaskContinuation(tc, addBeforeOthers: false))
            {
                tc.Run(this, canInlineContinuationTask: false);
            }
            return;
        }
        else
        {
            TaskScheduler? scheduler = TaskScheduler.InternalCurrent;
            if (scheduler != null && scheduler != TaskScheduler.Default)
            {
                var tc = new TaskSchedulerAwaitTaskContinuation(scheduler, stateMachineBox.MoveNextAction, flowExecutionContext: false);
                if (!AddTaskContinuation(tc, addBeforeOthers: false))
                {
                    tc.Run(this, canInlineContinuationTask: false);
                }
                return;
            }
        }
    }

    ...
}

這是一個方法的一部分,用於確定將哪個物件作為 continuation 儲存到任務中。它被傳遞給 stateMachineBox,如前所述,它可以直接儲存到任務的 continuation 列表中。但是,這個特殊的邏輯可能會將 IAsyncStateMachineBox 封裝起來,以合併一個排程程式(如果存在的話)。它檢查當前是否有非預設的 SynchronizationContext,如果有,它會建立一個 SynchronizationContextAwaitTaskContinuation 作為實際的物件,它會被儲存為 continuation;該物件依次包裝了原始的和捕獲的 SynchronizationContext,並知道如何在與後者排隊的工作項中呼叫前者的 MoveNext。這就是如何在 UI 應用程式中作為事件處理程式的一部分等待,並在等待完成後讓程式碼繼續在正確的執行緒上執行。這裡要注意的下一個有趣的事情是,它不僅僅關注一個 SynchronizationContext:如果它找不到一個自定義的 SynchronizationContext 來使用,它還會檢視 Tasks 使用的 TaskScheduler 型別是否有一個需要考慮的自定義型別。和 SynchronizationContext 一樣,如果有一個非預設值,它就會和原始框一起包裝在 TaskSchedulerAwaitTaskContinuation 中,用作 continuation 物件。

但這裡最值得注意的可能是方法主體的第一行:if (continueOnCapturedContext)。我們只在 continueOnCapturedContext 為 true 時才對 SynchronizationContext/TaskScheduler 進行這些檢查;如果這個值為 false,實現方式就好像兩者都是預設值一樣,會忽略它們。請問是什麼將 continueOnCapturedContext 設定為 false?你可能已經猜到了:使用非常流行的 ConfigureAwait(false)。

可以這樣說,作為 await 的一部分,ConfigureAwait(false) 做的唯一一件事是將它的引數布林值作為 continueOnCapturedContext 值提供給這個函式(以及其他類似的函式),以便跳過對 SynchronizationContext/TaskScheduler 的檢查,表現得好像它們都不存在一樣。對於程式來說,這允許 Task 在它認為合適的地方呼叫其 continuation,而不是強制將它們排隊在某個特定的排程器上執行。

我之前提到過 SynchronizationContext 的另一個方面,我說過我們會再次看到它:OperationStarted/OperationCompleted。現在是時候了。這是沒那麼受歡迎的特性:非同步 void。除了 configureawait 之外,async void 可以說是 async/await 中最具爭議性的特性之一。它被新增的原因只有一個:事件處理程式。在 UI 應用程式中,你可以編寫如下程式碼:

button1.Click += async (sender, eventArgs) =>
{
  button1.Text = await Task.Run(() => ComputeMessage());  
};

但如果所有的非同步方法都必須有一個像 Task 這樣的返回型別,你就不能這樣做了。Click 事件有一個簽名 public event EventHandler? Click;,其中 EventHandler 定義為 public delegate void EventHandler(object? sender, EventArgs e);,因此要提供一個符合該簽名的方法,該方法需要是 void-returning。

有各種各樣的理由認為 async void 是不好的,為什麼文章建議儘可能避免使用它,以及為什麼出現了各種 analyzers 來標記使用 async void。最大的問題之一是委託推理。考慮下面的程式:

using System.Diagnostics;

Time(async () =>
{
    Console.WriteLine("Enter");
    await Task.Delay(TimeSpan.FromSeconds(10));
    Console.WriteLine("Exit");
});

static void Time(Action action)
{
    Console.WriteLine("Timing...");
    Stopwatch sw = Stopwatch.StartNew();
    action();
    Console.WriteLine($"...done timing: {sw.Elapsed}");
}

人們很容易期望它輸出至少10秒的執行時間,但如果你執行它,你會發現輸出是這樣的:

Timing...
Enter
...done timing: 00:00:00.0037550

async lambda 實際上是一個非同步 void 方法。非同步方法會在遇到第一個暫停點時返回撥用者。如果這是一個非同步 Task 方法,Task 就會在這個時間點返回。但對於 async void,什麼都不會返回。Time 方法只知道它呼叫了 action();委託呼叫返回;它不知道 async 方法實際上仍在“執行”,並將在稍後非同步完成。

這就是 OperationStarted/OperationCompleted 的作用。這種非同步 void 方法本質上與前面討論的 EAP 方法類似:這種方法的初始化是 void,因此需要一些其他機制來跟蹤所有此類操作。因此,EAP 實現在操作啟動時呼叫當前 SynchronizationContext 的 OperationStarted,在操作完成時呼叫 OperationCompleted,async void 也做同樣的事情。與 async void 相關的構建器是 AsyncVoidMethodBuilder。還記得在 async 方法的入口,編譯器生成的程式碼如何呼叫構建器的靜態 Create 方法來獲得適當的構建器例項嗎?AsyncVoidMethodBuilder 利用了這一點來掛鉤建立和呼叫 OperationStarted:

public static AsyncVoidMethodBuilder Create()
{
    SynchronizationContext? sc = SynchronizationContext.Current;
    sc?.OperationStarted();
    return new AsyncVoidMethodBuilder() { _synchronizationContext = sc };
}

類似地,當透過 SetResult 或 SetException 將構建器標記為完成時,它會呼叫相應的 OperationCompleted 方法。這就是像 xunit 這樣的單元測試框架如何能夠具有非同步 void 測試方法,並仍然在併發測試執行中使用最大程度的併發,例如在 xunit 的 AsyncTestSyncContext 中。

有了這些知識,現在可以重寫我們的 timing 示例:

using System.Diagnostics;

Time(async () =>
{
    Console.WriteLine("Enter");
    await Task.Delay(TimeSpan.FromSeconds(10));
    Console.WriteLine("Exit");
});

static void Time(Action action)
{
    var oldCtx = SynchronizationContext.Current;
    try
    {
        var newCtx = new CountdownContext();
        SynchronizationContext.SetSynchronizationContext(newCtx);

        Console.WriteLine("Timing...");
        Stopwatch sw = Stopwatch.StartNew();
        
        action();
        newCtx.SignalAndWait();

        Console.WriteLine($"...done timing: {sw.Elapsed}");
    }
    finally
    {
        SynchronizationContext.SetSynchronizationContext(oldCtx);
    }
}

sealed class CountdownContext : SynchronizationContext
{
    private readonly ManualResetEventSlim _mres = new ManualResetEventSlim(false);
    private int _remaining = 1;

    public override void OperationStarted() => Interlocked.Increment(ref _remaining);

    public override void OperationCompleted()
    {
        if (Interlocked.Decrement(ref _remaining) == 0)
        {
            _mres.Set();
        }
    }

    public void SignalAndWait()
    {
        OperationCompleted();
        _mres.Wait();
    }
}

在這裡,我已經建立了一個 SynchronizationContext,它跟蹤了一個待定操作的計數,並支援阻塞等待它們全部完成。當我執行它時,我得到這樣的輸出:

Timing...
Enter
Exit
...done timing: 00:00:10.0149074

▌State Machine Fields

至此,我們已經看到了生成的入口點方法,以及 MoveNext 實現中的一切是如何工作的。我們還了解了在狀態機上定義的一些欄位。讓我們仔細看看這些。

對於前面給出的 CopyStreamToStream 方法:

public async Task CopyStreamToStreamAsync(Stream source, Stream destination)
{
    var buffer = new byte[0x1000];
    int numRead;
    while ((numRead = await source.ReadAsync(buffer, 0, buffer.Length)) != 0)
    {
        await destination.WriteAsync(buffer, 0, numRead);
    }
}

下面是我們最終得到的欄位:

private struct <CopyStreamToStreamAsync>d__0 : IAsyncStateMachine
{
    public int <>1__state;
    public AsyncTaskMethodBuilder <>t__builder;
    public Stream source;
    public Stream destination;
    private byte[] <buffer>5__2;
    private TaskAwaiter <>u__1;
    private TaskAwaiter<int> <>u__2;

    ...
}

< > 1 __state。是“狀態機”中的“狀態”。它定義了狀態機所處的當前狀態,最重要的是下次呼叫 MoveNext 時應該做什麼。如果狀態為-2,則操作完成。如果狀態是-1,要麼是我們第一次呼叫 MoveNext,要麼是 MoveNext 程式碼正在某個執行緒上執行。如果你正在除錯一個 async 方法的處理過程,並且你看到狀態為-1,這意味著在某處有某個執行緒正在執行包含在方法中的程式碼。如果狀態大於等於0,方法會被掛起,狀態的值會告訴你在什麼時候掛起。雖然這不是一個嚴格的規則(某些程式碼模式可能會混淆編號),但通常情況下,分配的狀態對應於從0開始的 await 編號,按照原始碼從上到下的順序排列。例如,如果 async 方法的函式體完全是:

await A();
await B();
await C();
await D();

你發現狀態值是2,這幾乎肯定意味著 async 方法當前被掛起,等待從 C() 返回的任務完成。

< > t__builder。這是狀態機的構建器,例如用於 Task 的 AsyncTaskMethodBuilder,用於 ValueTask 的 AsyncValueTaskMethodBuilder<TResult>,用於 async void 方法的 AsyncVoidMethodBuilder,或用於 async 返回型別的 AsyncMethodBuilder(…)] 或透過 async 方法本身的屬性覆蓋的任何構建器。如前所述,構建器負責 async 方法的生命週期,包括建立 return 任務,最終完成該任務,並充當暫停的中介,async 方法中的程式碼要求構建器暫停,直到特定的 awaiter 完成。

編譯器完全按照引數名稱的指定來命名它們。如前所述,所有被方法主體使用的引數都需要被儲存到狀態機中,以便 MoveNext 方法能夠訪問它們。注意我說的是 "被使用"。如果編譯器發現一個引數沒有被非同步方法的主體使用,它就可以最佳化,不需要儲存這個欄位。例如,給定下面的方法:

public async Task M(int someArgument)
{
    await Task.Yield();
}

編譯器會將這些欄位傳送到狀態機:

private struct <M>d__0 : IAsyncStateMachine
{
    public int <>1__state;
    public AsyncTaskMethodBuilder <>t__builder;
    private YieldAwaitable.YieldAwaiter <>u__1;
    ...
}

請注意,這裡明顯缺少名為 someArgument 的引數。但是,如果我們改變 async 方法,讓它以任何方式使用實參:

public async Task M(int someArgument)
{
    Console.WriteLine(someArgument);
    await Task.Yield();
}

它顯示:

private struct <M>d__0 : IAsyncStateMachine
{
    public int <>1__state;
    public AsyncTaskMethodBuilder <>t__builder;
    public int someArgument;
    private YieldAwaitable.YieldAwaiter <>u__1;
    ...
}

<buffer>5__2;。這是緩衝區的 "區域性",它被提升為一個欄位,這樣它就可以在等待點上存活。編譯器相當努力地防止狀態被不必要地提升。注意,在原始碼中還有一個區域性變數 numRead,在狀態機中沒有相應的欄位。為什麼?因為它沒有必要。這個區域性變數被設定為 ReadAsync 呼叫的結果,然後被用作 WriteAsync 呼叫的輸入。在這兩者之間沒有 await,因此 numRead 的值需要被儲存。就像在一個同步方法中,JIT 編譯器可以選擇將這樣的值完全儲存在一個暫存器中,而不會真正將其溢位到堆疊中,C# 編譯器可以避免將這個區域性變數提升為一個欄位,因為它不需要在任何等待中儲存它的值。一般來說,如果 C# 編譯器能夠證明區域性變數的值不需要在等待中儲存,它就可以省略區域性變數的提升。

<>u__1和<>u__2。async 方法中有兩個 await:一個用於 ReadAsync 返回的 Task<int>,另一個用於 WriteAsync 返回的 Task。Task. getawaiter() 返回一個 TaskAwaiter,Task<TResult>. getawaiter() 返回一個 TaskAwaiter<TResult>,兩者都是不同的結構體型別。由於編譯器需要在 await (IsCompleted, UnsafeOnCompleted) 之前獲取這些 awaiter,然後需要在 await (GetResult) 之後訪問它們,因此需要儲存這些 awaiter。由於它們是不同的結構型別,編譯器需要維護兩個單獨的欄位來做到這一點(另一種選擇是將它們裝箱,併為 awaiter 提供一個物件欄位,但這會導致額外的分配成本)。不過,編譯器會盡可能地重複使用欄位。如果我有:

public async Task M()
{
    await Task.FromResult(1);
    await Task.FromResult(true);
    await Task.FromResult(2);
    await Task.FromResult(false);
    await Task.FromResult(3);
}

有五個等待,但只涉及兩種不同型別的等待者:三個是 TaskAwaiter<int>,兩個是 TaskAwaiter<bool>。因此,狀態機上最終只有兩個等待者欄位:

private struct <M>d__0 : IAsyncStateMachine
{
    public int <>1__state;
    public AsyncTaskMethodBuilder <>t__builder;
    private TaskAwaiter<int> <>u__1;
    private TaskAwaiter<bool> <>u__2;
    ...
}

然後,如果我將我的示例改為:

public async Task M()
{
    await Task.FromResult(1);
    await Task.FromResult(true);
    await Task.FromResult(2).ConfigureAwait(false);
    await Task.FromResult(false).ConfigureAwait(false);
    await Task.FromResult(3);
}

仍然只涉及 Task<int>s 和 Task<bool>s,但實際上我使用了四個不同的 struct awaiter 型別,因為從 ConfigureAwait 返回的東西上的 GetAwaiter() 呼叫返回的 awaiter 與 Task.GetAwaiter() 返回的是不同的型別…從編譯器建立的 awaiter 欄位可以再次很明顯的看出:

private struct <M>d__0 : IAsyncStateMachine
{
    public int <>1__state;
    public AsyncTaskMethodBuilder <>t__builder;
    private TaskAwaiter<int> <>u__1;
    private TaskAwaiter<bool> <>u__2;
    private ConfiguredTaskAwaitable<int>.ConfiguredTaskAwaiter <>u__3;
    private ConfiguredTaskAwaitable<bool>.ConfiguredTaskAwaiter <>u__4;
    ...
}

如果您發現自己想要最佳化與非同步狀態機相關的大小,您可以檢視的一件事是是否可以合併正在等待的事情,從而合併這些 awaiter 欄位。

您可能還會看到在狀態機上定義的其他型別的欄位。值得注意的是,您可能會看到一些欄位包含單詞“wrap”。考慮下面這個例子:

public async Task<int> M() => await Task.FromResult(42) + DateTime.Now.Second;

這將生成一個包含以下欄位的狀態機:

private struct <M>d__0 : IAsyncStateMachine
{
    public int <>1__state;
    public AsyncTaskMethodBuilder<int> <>t__builder;
    private TaskAwaiter<int> <>u__1;
    ...
}

到目前為止沒有什麼特別的。現在顛倒一下新增表示式的順序:

public async Task<int> M() => DateTime.Now.Second + await Task.FromResult(42);

這樣,你就得到了這些欄位:

private struct <M>d__0 : IAsyncStateMachine
{
    public int <>1__state;
    public AsyncTaskMethodBuilder<int> <>t__builder;
    private int <>7__wrap1;
    private TaskAwaiter<int> <>u__1;
    ...
}

我們現在有了另一個函式:<>7__wrap1。為什麼?因為我們計算了 DateTime.Now 的值。其次,只有在計算完它之後,我們才需要等待一些東西,並且需要保留第一個表示式的值,以便將其與第二個表示式的結果相加。因此,編譯器需要確保第一個表示式的臨時結果可以新增到 await 的結果中,這意味著它需要將表示式的結果溢位到臨時中,它使用 <>7__wrap1 欄位做到了這一點。如果你發現自己對非同步方法的實現進行了超最佳化,以減少分配的記憶體量,你可以尋找這樣的欄位,並檢視對原始碼的微調是否可以避免溢位的需要,從而避免這種臨時的需要。

我希望這篇文章有助於解釋當你使用 async/await 時背後到底發生了什麼。這裡有很多變化,所有這些結合在一起,建立了一個高效的解決方案,可以編寫可擴充的非同步程式碼,而不必處理回撥。然而歸根結底,這些部分實際上是相對簡單的:任何非同步操作的通用表示,一種能夠將普通控制流重寫為協程的狀態機實現的語言和編譯器,以及將它們繫結在一起的模式。其他一切都是最佳化的額外收穫。

程式設計愉快!

點我前往原部落格~

相關文章