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

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

接《async/await 在 C# 語言中是如何工作的?(上)》,今天我們繼續介紹 C# 迭代器和 async/await under the covers。

C# 迭代器

這個解決方案的伏筆實際上是在 Task 出現的幾年前,即 C# 2.0,當時它增加了對迭代器的支援。

迭代器允許你編寫一個方法,然後由編譯器用來實現 IEnumerable<T> 和/或 IEnumerator<T>。例如,如果我想建立一個產生斐波那契數列的列舉數,我可以這樣寫:

public static IEnumerable<int> Fib(){
    int prev = 0, next = 1;
    yield return prev;
    yield return next;

    while (true)
    {
        int sum = prev + next;
        yield return sum;
        prev = next;
        next = sum;
    }}

然後我可以用 foreach 列舉它:

foreach (int i in Fib()){
    if (i > 100) break;
    Console.Write($"{i} ");}

我可以透過像 System.Linq.Enumerable 上的組合器將它與其他 IEnumerable<T> 進行組合:

foreach (int i in Fib().Take(12)){
    Console.Write($"{i} ");}

或者我可以直接透過 IEnumerator<T> 來手動列舉它:

using IEnumerator<int> e = Fib().GetEnumerator();while (e.MoveNext()){
    int i = e.Current;
    if (i > 100) break;
    Console.Write($"{i} ");}

以上所有的結果是這樣的輸出:
0 1 1 2 3 5 8 13 21 34 55 89

真正有趣的是,為了實現上述目標,我們需要能夠多次進入和退出 Fib 方法。我們呼叫 MoveNext,它進入方法,然後該方法執行,直到它遇到 yield return,此時對 MoveNext 的呼叫需要返回 true,隨後對 Current 的訪問需要返回 yield value。然後我們再次呼叫 MoveNext,我們需要能夠在 Fib 中從我們上次停止的地方開始,並且保持上次呼叫的所有狀態不變。迭代器實際上是由 C# 語言/編譯器提供的協程,編譯器將 Fib 迭代器擴充套件為一個成熟的狀態機。

所有關於 Fib 的邏輯現在都在 MoveNext 方法中,但是作為跳轉表的一部分,它允許實現分支到它上次離開的位置,這在列舉器型別上生成的狀態欄位中被跟蹤。而我寫的區域性變數,如 prev、next 和 sum,已經被 "提升 "為列舉器上的欄位,這樣它們就可以在呼叫 MoveNext 時持續存在。

在我之前的例子中,我展示的最後一種列舉形式涉及手動使用 IEnumerator<T>。在那個層面上,我們手動呼叫 MoveNext(),決定何時是重新進入迴圈程式的適當時機。但是,如果不這樣呼叫它,而是讓 MoveNext 的下一次呼叫實際成為非同步操作完成時執行的延續工作的一部分呢?如果我可以 yield 返回一些代表非同步操作的東西,並讓消耗程式碼將 continuation 連線到該 yield 物件,然後在該 continuation 執行 MoveNext 時會怎麼樣?使用這種方法,我可以編寫一個輔助方法:

static Task IterateAsync(IEnumerable<Task> tasks){
    var tcs = new TaskCompletionSource();

    IEnumerator<Task> e = tasks.GetEnumerator();

    void Process()
    {
        try
        {
            if (e.MoveNext())
            {
                e.Current.ContinueWith(t => Process());
                return;
            }
        }
        catch (Exception e)
        {
            tcs.SetException(e);
            return;
        }
        tcs.SetResult();
    };
    Process();

    return tcs.Task;}

現在變得有趣了。我們得到了一個可迭代的任務列表。每次我們 MoveNext 到下一個 Task 並獲得一個時,我們將該任務的 continuation 連線起來;當這個 Task 完成時,它只會回過頭來呼叫執行 MoveNext、獲取下一個 Task 的相同邏輯,以此類推。這是建立在將 Task 作為任何非同步操作的單一表示的思想之上的,所以我們輸入的列舉表可以是一個任何非同步操作的序列。這樣的序列是從哪裡來的呢?當然是透過迭代器。還記得我們之前的 CopyStreamToStream 例子嗎?考慮一下這個:

static Task CopyStreamToStreamAsync(Stream source, Stream destination){
    return IterateAsync(Impl(source, destination));

    static IEnumerable<Task> Impl(Stream source, Stream destination)
    {
        var buffer = new byte[0x1000];
        while (true)
        {
            Task<int> read = source.ReadAsync(buffer, 0, buffer.Length);
            yield return read;
            int numRead = read.Result;
            if (numRead <= 0)
            {
                break;
            }

            Task write = destination.WriteAsync(buffer, 0, numRead);
            yield return write;
            write.Wait();
        }
    }}

我們正在呼叫那個 IterateAsync 助手,而我們提供給它的列舉表是由一個處理所有控制流的迭代器產生的。它呼叫 Stream.ReadAsync 然後 yield 返回 Task;yield task 在呼叫 MoveNext 之後會被傳遞給 IterateAsync,而 IterateAsync 會將一個 continuation 掛接到那個 task 上,當它完成時,它會回撥 MoveNext 並在 yield 之後回到這個迭代器。此時,Impl 邏輯獲得方法的結果,呼叫 WriteAsync,並再次生成它生成的 Task。以此類推。

這就是 C# 和 .NET 中 async/await 的開始。在 C# 編譯器中支援迭代器和 async/await 的邏輯中,大約有95%左右的邏輯是共享的。不同的語法,不同的型別,但本質上是相同的轉換。

事實上,在 async/await 出現之前,一些開發人員就以這種方式使用迭代器進行非同步程式設計。在實驗性的 Axum 程式語言中也有類似的轉換原型,這是 C# 支援非同步的關鍵靈感來源。Axum 提供了一個可以放在方法上的 async 關鍵字,就像 C# 中的 async 一樣。

Task 還不普遍,所以在非同步方法中,Axum 編譯器啟發式地將同步方法呼叫與 APM 對應的方法相匹配,例如,如果它看到你呼叫 stream.Read,它會找到並利用相應的 stream.BeginRead 和 stream.EndRead 方法,合成適當的委託傳遞給 Begin 方法,同時還為定義為可組合的 async 方法生成完整的 APM 實現。它甚至還整合了 SynchronizationContext!雖然 Axum 最終被擱置,但它為 C# 中的 async/await 提供了一個很棒的原型。

async/await under the covers

現在我們知道了我們是如何做到這一點的,讓我們深入研究它實際上是如何工作的。作為參考,下面是我們的同步方法示例:

public void CopyStreamToStream(Stream source, Stream destination){
    var buffer = new byte[0x1000];
    int numRead;
    while ((numRead = source.Read(buffer, 0, buffer.Length)) != 0)
    {
        destination.Write(buffer, 0, numRead);
    }}

下面是 async/await 對應的方法:

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);
    }}

簽名從 void 變成了 async Task,我們分別呼叫了 ReadAsync 和 WriteAsync,而不是 Read 和 Write,這兩個操作都帶 await 字首。編譯器和核心庫接管了其餘部分,從根本上改變了程式碼實際執行的方式。讓我們深入瞭解一下是如何做到的。

▌編譯器轉換

我們已經看到,和迭代器一樣,編譯器基於狀態機重寫了 async 方法。我們仍然有一個與開發人員寫的簽名相同的方法(public Task CopyStreamToStreamAsync(Stream source, Stream destination)),但該方法的主體完全不同:

[AsyncStateMachine(typeof(<CopyStreamToStreamAsync>d__0))]public Task CopyStreamToStreamAsync(Stream source, Stream destination){
    <CopyStreamToStreamAsync>d__0 stateMachine = default;
    stateMachine.<>t__builder = AsyncTaskMethodBuilder.Create();
    stateMachine.source = source;
    stateMachine.destination = destination;
    stateMachine.<>1__state = -1;
    stateMachine.<>t__builder.Start(ref stateMachine);
    return stateMachine.<>t__builder.Task;}
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;

    ...}

注意,與開發人員所寫的簽名的唯一區別是缺少 async 關鍵字本身。Async 實際上不是方法簽名的一部分;就像 unsafe 一樣,當你把它放在方法簽名中,你是在表達方法的實現細節,而不是作為契約的一部分實際公開出來的東西。使用 async/await 實現 task -return 方法是實現細節。

編譯器已經生成了一個名為 <CopyStreamToStreamAsync>d__0 的結構體,並且它在堆疊上對該結構體的例項進行了零初始化。重要的是,如果非同步方法同步完成,該狀態機將永遠不會離開堆疊。這意味著沒有與狀態機相關的分配,除非該方法需要非同步完成,也就是說它需要等待一些尚未完成的任務。稍後會有更多關於這方面的內容。

該結構體是方法的狀態機,不僅包含開發人員編寫的所有轉換邏輯,還包含用於跟蹤該方法中當前位置的欄位,以及編譯器從方法中提取的所有“本地”狀態,這些狀態需要在 MoveNext 呼叫之間生存。它在邏輯上等價於迭代器中的 IEnumerable<T>/IEnumerator<T> 實現。(請注意,我展示的程式碼來自發布版本;在除錯構建中,C# 編譯器將實際生成這些狀態機型別作為類,因為這樣做可以幫助某些除錯工作)。

在初始化狀態機之後,我們看到對 AsyncTaskMethodBuilder.Create() 的呼叫。雖然我們目前關注的是 Tasks,但 C# 語言和編譯器允許從非同步方法返回任意型別(“task-like”型別),例如,我可以編寫一個方法 public async MyTask CopyStreamToStreamAsync,只要我們以適當的方式擴充套件我們前面定義的 MyTask,它就能順利編譯。這種適當性包括宣告一個相關的“builder”型別,並透過 AsyncMethodBuilder 屬性將其與該型別關聯起來:

[AsyncMethodBuilder(typeof(MyTaskMethodBuilder))]public class MyTask{
    ...}
public struct MyTaskMethodBuilder{
    public static MyTaskMethodBuilder Create() { ... }

    public void Start<TStateMachine>(ref TStateMachine stateMachine) where TStateMachine : IAsyncStateMachine { ... }
    public void SetStateMachine(IAsyncStateMachine stateMachine) { ... }

    public void SetResult() { ... }
    public void SetException(Exception exception) { ... }

    public void AwaitOnCompleted<TAwaiter, TStateMachine>(
        ref TAwaiter awaiter, ref TStateMachine stateMachine)
        where TAwaiter : INotifyCompletion
        where TStateMachine : IAsyncStateMachine { ... }
    public void AwaitUnsafeOnCompleted<TAwaiter, TStateMachine>(
        ref TAwaiter awaiter, ref TStateMachine stateMachine)
        where TAwaiter : ICriticalNotifyCompletion
        where TStateMachine : IAsyncStateMachine { ... }

    public MyTask Task { get { ... } }}

在這種情況下,這樣的“builder”知道如何建立該型別的例項(Task 屬性),如何成功完成並在適當的情況下有結果(SetResult)或有異常(SetException),以及如何處理連線等待尚未完成的事務的延續(AwaitOnCompleted/AwaitUnsafeOnCompleted)。在 System.Threading.Tasks.Task 的情況下,它預設與 AsyncTaskMethodBuilder 相關聯。通常情況下,這種關聯是透過應用在型別上的 [AsyncMethodBuilder(…)] 屬性提供的,但在 C# 中,Task 是已知的,因此實際上沒有該屬性。因此,編譯器已經讓構建器使用這個非同步方法,並使用模式中的 Create 方法構建它的例項。請注意,與狀態機一樣,AsyncTaskMethodBuilder 也是一個結構體,因此這裡也沒有記憶體分配。

然後用這個入口點方法的引數填充狀態機。這些引數需要能夠被移動到 MoveNext 中的方法體訪問,因此這些引數需要儲存在狀態機中,以便後續呼叫 MoveNext 時程式碼可以引用它們。該狀態機也被初始化為初始-1狀態。如果 MoveNext 被呼叫且狀態為-1,那麼邏輯上我們將從方法的開始處開始。

現在是最不顯眼但最重要的一行:呼叫構建器的 Start 方法。這是模式的另一部分,必須在 async 方法的返回位置所使用的型別上公開,它用於在狀態機上執行初始的 MoveNext。構建器的 Start 方法實際上是這樣的:

public void Start<TStateMachine>(ref TStateMachine stateMachine) where TStateMachine : IAsyncStateMachine{
    stateMachine.MoveNext();}

例如,呼叫 stateMachine.<>t__builder.Start(ref stateMachine); 實際上只是呼叫 stateMachine.MoveNext()。在這種情況下,為什麼編譯器不直接發出這個訊號呢?為什麼還要有 Start 呢?答案是,Start 的內容比我所說的要多一點。但為此,我們需要簡單地瞭解一下 ExecutionContext。

❖ ExecutionContext

我們都熟悉在方法之間傳遞狀態。呼叫一個方法,如果該方法指定了形參,就使用實參呼叫該方法,以便將該資料傳遞給被呼叫方。這是顯式傳遞資料。但還有其他更隱蔽的方法。例如,方法可以是無引數的,但可以指定在呼叫方法之前填充某些特定的靜態欄位,然後從那裡獲取狀態。這個方法的簽名中沒有任何東西表明它接收引數,因為它確實沒有:只是呼叫者和被呼叫者之間有一個隱含的約定,即呼叫者可能填充某些記憶體位置,而被呼叫者可能讀取這些記憶體位置。被呼叫者和呼叫者甚至可能沒有意識到它的發生,如果他們是中介,方法 A 可能填充靜態資訊,然後呼叫 B, B 呼叫 C, C 呼叫 D,最終呼叫 E,讀取這些靜態資訊的值。這通常被稱為“環境”資料:它不是透過引數傳遞給你的,而是掛在那裡,如果需要的話,你可以使用。

我們可以更進一步,使用執行緒區域性狀態。執行緒區域性狀態,在 .NET 中是透過屬性為 [ThreadStatic] 的靜態欄位或透過 ThreadLocal<T> 型別實現的,可以以相同的方式使用,但資料僅限於當前執行的執行緒,每個執行緒都能夠擁有這些欄位的自己的隔離副本。這樣,您就可以填充執行緒靜態,進行方法呼叫,然後在方法完成後將更改還原到執行緒靜態,從而啟用這種隱式傳遞資料的完全隔離形式。

如果我們進行非同步方法呼叫,而非同步方法中的邏輯想要訪問環境資料,它會怎麼做?如果資料儲存在常規靜態中,非同步方法將能夠訪問它,但一次只能有一個這樣的方法在執行,因為多個呼叫者在寫入這些共享靜態欄位時可能會覆蓋彼此的狀態。如果資料儲存線上程靜態資訊中,非同步方法將能夠訪問它,但只有在呼叫執行緒停止同步執行之前;如果它將 continuation 連線到它發起的某個操作,並且該 continuation 最終在某個其他執行緒上執行,那麼它將不再能夠訪問執行緒靜態資訊。即使它碰巧執行在同一個執行緒上,無論是偶然的還是由於排程器的強制,在它這樣做的時候,資料可能已經被該執行緒發起的其他操作刪除和/或覆蓋。對於非同步,我們需要一種機制,允許任意環境資料在這些非同步點上流動,這樣在 async 方法的整個邏輯中,無論何時何地執行,它都可以訪問相同的資料。

輸入 ExecutionContext。ExecutionContext 型別是非同步操作和非同步操作之間傳遞環境資料的媒介。它存在於一個 [ThreadStatic] 中,但是當某些非同步操作啟動時,它被“捕獲”(從該執行緒靜態中讀取副本的一種奇特的方式),儲存,然後當該非同步操作的延續被執行時,ExecutionContext 首先被恢復到即將執行該操作的執行緒中的 [ThreadStatic] 中。ExecutionContext 是實現 AsyncLocal<T> 的機制(事實上,在 .NET Core 中,ExecutionContext 完全是關於 AsyncLocal<T> 的,僅此而已),例如,如果你將一個值儲存到 AsyncLocal<T> 中,然後例如佇列一個工作項在 ThreadPool 上執行,該值將在該 AsyncLocal<T> 中可見,在該工作項上執行:

var number = new AsyncLocal<int>();

number.Value = 42;ThreadPool.QueueUserWorkItem(_ => Console.WriteLine(number.Value));
number.Value = 0;
Console.ReadLine();

這段程式碼每次執行時都會列印42。在我們對委託進行排隊之後,我們將 AsyncLocal<int> 的值重置為0,這無關緊要,因為 ExecutionContext 是作為 QueueUserWorkItem 呼叫的一部分被捕獲的,而該捕獲包含了當時 AsyncLocal<int> 的狀態。

❖ Back To Start

當我在寫 AsyncTaskMethodBuilder.Start 的實現時,我們繞道討論了 ExecutionContext,我說這是有效的:

public void Start<TStateMachine>(ref TStateMachine stateMachine) where TStateMachine : IAsyncStateMachine{
    stateMachine.MoveNext();}

然後建議我簡化一下。這種簡化忽略了一個事實,即該方法實際上需要將 ExecutionContext 考慮在內,因此更像是這樣:

public void Start<TStateMachine>(ref TStateMachine stateMachine) where TStateMachine : IAsyncStateMachine{
    ExecutionContext previous = Thread.CurrentThread._executionContext; // [ThreadStatic] field
    try
    {
        stateMachine.MoveNext();
    }
    finally
    {
        ExecutionContext.Restore(previous); // internal helper
    }}

這裡不像我之前建議的那樣只呼叫 statemmachine .MoveNext(),而是在這裡做了一個動作:獲取當前的 ExecutionContext,再呼叫 MoveNext,然後在它完成時將當前上下文重置為呼叫 MoveNext 之前的狀態。

這樣做的原因是為了防止非同步方法將環境資料洩露給呼叫者。一個示例方法說明了為什麼這很重要:

async Task ElevateAsAdminAndRunAsync(){
    using (WindowsIdentity identity = LoginAdmin())
    {
        using (WindowsImpersonationContext impersonatedUser = identity.Impersonate())
        {
            await DoSensitiveWorkAsync();
        }
    }}

“冒充”是將當前使用者的環境資訊改為其他人的;這讓程式碼可以代表其他人,使用他們的特權和訪問許可權。在 .NET 中,這種模擬跨非同步操作流動,這意味著它是 ExecutionContext 的一部分。現在想象一下,如果 Start 沒有恢復之前的上下文,考慮下面的程式碼:

Task t = ElevateAsAdminAndRunAsync();PrintUser();await t;

這段程式碼可以發現,ElevateAsAdminAndRunAsync 中修改的 ExecutionContext 在 ElevateAsAdminAndRunAsync 返回到它的同步呼叫者之後仍然存在(這發生在該方法第一次等待尚未完成的內容時)。這是因為在呼叫 Impersonate 之後,我們呼叫了 DoSensitiveWorkAsync 並等待它返回的任務。假設任務沒有完成,它將導致對 ElevateAsAdminAndRunAsync 的呼叫 yield 並返回到呼叫者,模擬仍然在當前執行緒上有效。這不是我們想要的。因此,Start 設定了這個保護機制,以確保對 ExecutionContext 的任何修改都不會從同步方法呼叫中流出,而只會隨著方法執行的任何後續工作一起流出。

❖ MoveNext

因此,呼叫了入口點方法,初始化了狀態機結構體,呼叫了 Start,然後呼叫了 MoveNext。什麼是 MoveNext?這個方法包含了開發者方法中所有的原始邏輯,但做了一大堆修改。讓我們先看看這個方法的腳手架。下面是編譯器為我們的方法生成的反編譯版本,但刪除了生成的 try 塊中的所有內容:

private void MoveNext(){
    try
    {
        ... // all of the code from the CopyStreamToStreamAsync method body, but not exactly as it was written
    }
    catch (Exception exception)
    {
        <>1__state = -2;
        <buffer>5__2 = null;
        <>t__builder.SetException(exception);
        return;
    }

    <>1__state = -2;
    <buffer>5__2 = null;
    <>t__builder.SetResult();}

無論 MoveNext 執行什麼其他工作,當所有工作完成後,它都有責任完成 async Task 方法返回的任務。如果 try 程式碼塊的主體丟擲了未處理的異常,那麼任務就會丟擲該異常。如果 async 方法成功到達它的終點(相當於同步方法返回),它將成功完成返回的任務。在任何一種情況下,它都將設定狀態機的狀態以表示完成。(我有時聽到開發人員從理論上說,當涉及到異常時,在第一個 await 之前丟擲的異常和在第一個 await 之後丟擲的異常是有區別的……基於上述,應該清楚情況並非如此。任何未在 async 方法中處理的異常,不管它在方法的什麼位置,也不管方法是否產生了結果,都會在上面的 catch 塊中結束,然後被捕獲的異常會儲存在 async 方法返回的任務中。)

還要注意,這個完成過程是透過構建器完成的,使用它的 SetException 和 SetResult 方法,這是編譯器預期的構建器模式的一部分。如果 async 方法之前已經掛起了,那麼構建器將不得不再掛起處理中建立一個 Task (稍後我們會看到如何以及在哪裡執行),在這種情況下,呼叫 SetException/SetResult 將完成該任務。然而,如果 async 方法之前沒有掛起,那麼我們還沒有建立任務或向呼叫者返回任何東西,因此構建器在生成任務時有更大的靈活性。如果你還記得之前在入口點方法中,它做的最後一件事是將任務返回給呼叫者,它透過訪問構建器的 Task 屬性返回結果:

public Task CopyStreamToStreamAsync(Stream source, Stream destination)
{
    ...
    return stateMachine.<>t__builder.Task;
}

構建器知道該方法是否掛起過,如果掛起了,它就會返回已經建立的任務。如果方法從未掛起,而且構建器還沒有任務,那麼它可以在這裡建立一個完成的任務。在成功完成的情況下,它可以直接使用 Task.CompletedTask 而不是分配一個新的任務,避免任何分配。如果是一般的任務 <TResult>,構建者可以直接使用 Task.FromResult<TResult>(TResult result)。

構建器還可以對它建立的物件進行任何它認為合適的轉換。例如,Task 實際上有三種可能的最終狀態:成功、失敗和取消。AsyncTaskMethodBuilder 的 SetException 方法處理特殊情況 OperationCanceledException,將任務轉換為 TaskStatus。如果提供的異常是 OperationCanceledException 或源自 OperationCanceledException,則將任務轉換為 TaskStatus.Canceled 最終狀態;否則,任務以 TaskStatus.Faulted 結束;這種區別在使用程式碼時往往不明顯;因為無論異常被標記為取消還是故障,都會被儲存到 Task 中,等待該任務的程式碼將無法觀察到狀態之間的區別(無論哪種情況,原始異常都會被傳播)...... 它隻影響與任務直接互動的程式碼,例如透過 ContinueWith,它具有過載,允許僅為完成狀態的子集呼叫 continuation。

現在我們瞭解了生命週期方面的內容,下面是在 MoveNext 的 try 塊內填寫的所有內容:

private void MoveNext()
{
    try
    {
        int num = <>1__state;

        TaskAwaiter<int> awaiter;
        if (num != 0)
        {
            if (num != 1)
            {
                <buffer>5__2 = new byte[4096];
                goto IL_008b;
            }

            awaiter = <>u__2;
            <>u__2 = default(TaskAwaiter<int>);
            num = (<>1__state = -1);
            goto IL_00f0;
        }

        TaskAwaiter awaiter2 = <>u__1;
        <>u__1 = default(TaskAwaiter);
        num = (<>1__state = -1);
        IL_0084:
        awaiter2.GetResult();

        IL_008b:
        awaiter = source.ReadAsync(<buffer>5__2, 0, <buffer>5__2.Length).GetAwaiter();
        if (!awaiter.IsCompleted)
        {
            num = (<>1__state = 1);
            <>u__2 = awaiter;
            <>t__builder.AwaitUnsafeOnCompleted(ref awaiter, ref this);
            return;
        }
        IL_00f0:
        int result;
        if ((result = awaiter.GetResult()) != 0)
        {
            awaiter2 = destination.WriteAsync(<buffer>5__2, 0, result).GetAwaiter();
            if (!awaiter2.IsCompleted)
            {
                num = (<>1__state = 0);
                <>u__1 = awaiter2;
                <>t__builder.AwaitUnsafeOnCompleted(ref awaiter2, ref this);
                return;
            }
            goto IL_0084;
        }
    }
    catch (Exception exception)
    {
        <>1__state = -2;
        <buffer>5__2 = null;
        <>t__builder.SetException(exception);
        return;
    }

    <>1__state = -2;
    <buffer>5__2 = null;
    <>t__builder.SetResult();
}

這種複雜的情況可能感覺有點熟悉。還記得我們基於 APM 手動實現的 BeginCopyStreamToStream 有多複雜嗎?這沒有那麼複雜,但也更好,因為編譯器為我們做了這些工作,以延續傳遞的形式重寫了方法,同時確保為這些延續保留了所有必要的狀態。即便如此,我們也可以眯著眼睛跟著走。請記住,狀態在入口點被初始化為-1。然後我們進入 MoveNext,發現這個狀態(現在儲存在本地 num 中)既不是0也不是1,因此執行建立臨時緩衝區的程式碼,然後跳轉到標籤 IL_008b,在這裡呼叫 stream.ReadAsync。注意,在這一點上,我們仍然從呼叫 MoveNext 同步執行,因此從開始到入口點都同步執行,這意味著開發者的程式碼呼叫了 CopyStreamToStreamAsync,它仍然在同步執行,還沒有返回一個 Task 來表示這個方法的最終完成。

我們呼叫 Stream.ReadAsync,從中得到一個 Task<int>。讀取可能是同步完成的,也可能是非同步完成的,但速度快到現在已經完成,也可能還沒有完成。不管怎麼說,我們有一個表示最終完成的 Task<int>,編譯器發出的程式碼會檢查該 Task<int> 以決定如何繼續:如果該 Task<int> 確實已經完成(不管它是同步完成還是隻是在我們檢查時完成),那麼這個方法的程式碼就可以繼續同步執行......當我們可以在這裡繼續執行時,沒有必要花不必要的開銷排隊處理該方法執行的剩餘部分。但是為了處理 Task<int> 還沒有完成的情況,編譯器需要發出程式碼來為 Task 掛上一個延續。因此,它需要發出程式碼,詢問任務 "你完成了嗎?" 它是否是直接與任務對話來問這個問題?

如果你在 C# 中唯一可以等待的東西是 System.Threading.Tasks.Task,這將是一種限制。同樣地,如果 C# 編譯器必須知道每一種可能被等待的型別,那也是一種限制。相反,C# 在這種情況下通常會做的是:它採用了一種 api 模式。程式碼可以等待任何公開適當模式(“awaiter”模式)的東西(就像您可以等待任何提供適當的“可列舉”模式的東西一樣)。例如,我們可以增強前面寫的 MyTask 型別來實現 awaiter 模式:

class MyTask
{
    ...
    public MyTaskAwaiter GetAwaiter() => new MyTaskAwaiter { _task = this };

    public struct MyTaskAwaiter : ICriticalNotifyCompletion
    {
        internal MyTask _task;

        public bool IsCompleted => _task._completed;
        public void OnCompleted(Action continuation) => _task.ContinueWith(_ => continuation());
        public void UnsafeOnCompleted(Action continuation) => _task.ContinueWith(_ => continuation());
        public void GetResult() => _task.Wait();
    }
}

如果一個型別公開了 getwaiter() 方法,就可以等待它,Task 就是這樣做的。這個方法需要返回一些內容,而這些內容又公開了幾個成員,包括一個 IsCompleted 屬性,用於在呼叫 IsCompleted 時檢查操作是否已經完成。你可以看到正在發生的事情:在 IL_008b,從 ReadAsync 返回的任務已經呼叫了 getwaiter,然後在 struct awaiter 例項上完成訪問。如果 IsCompleted 返回 true,那麼最終會執行到 IL_00f0,在這裡程式碼會呼叫 awaiter 的另一個成員:GetResult()。如果操作失敗,GetResult() 負責丟擲異常,以便將其傳播到 async 方法中的 await 之外;否則,GetResult() 負責返回操作的結果。在 ReadAsync 的例子中,如果結果為0,那麼我們跳出讀寫迴圈,到方法的末尾呼叫 SetResult,就完成了。

不過,回過頭來看一下,真正有趣的部分是,如果 IsCompleted 檢查實際上返回 false,會發生什麼。如果它返回 true,我們就繼續處理迴圈,類似於在 APM 模式中 completedsynchronized 返回 true,Begin 方法的呼叫者負責繼續執行,而不是回撥函式。但是如果 IsCompleted 返回 false,我們需要暫停 async 方法的執行,直到 await 操作完成。這意味著從 MoveNext 中返回,因為這是 Start 的一部分,我們仍然在入口點方法中,這意味著將任務返回給呼叫者。但在發生任何事情之前,我們需要將 continuation 連線到正在等待的任務(注意,為了避免像在 APM 情況中那樣的 stack dives,如果非同步操作在 IsCompleted 返回 false 後完成,但在我們連線 continuation 之前,continuation 仍然需要從呼叫執行緒非同步呼叫,因此它將進入佇列)。由於我們可以等待任何東西,我們不能直接與任務例項對話;相反,我們需要透過一些基於模式的方法來執行此操作。

Awaiter 公開了一個方法來連線 continuation。編譯器可以直接使用它,除了一個非常關鍵的問題:continuation 到底應該是什麼?更重要的是,它應該與什麼物件相關聯?請記住,狀態機結構體在棧上,我們當前執行的 MoveNext 呼叫是對該例項的方法呼叫。我們需要儲存狀態機,以便在恢復時我們擁有所有正確的狀態,這意味著狀態機不能一直存在於棧中;它需要被複制到堆上的某個地方,因為棧最終將被用於該執行緒執行的其他後續的、無關的工作。然後,延續需要在堆上的狀態機副本上呼叫 MoveNext 方法。

此外,ExecutionContext 也與此相關。狀態機需要確儲存儲在 ExecutionContext 中的任何環境資料在暫停時被捕獲,然後在恢復時被應用,這意味著延續也需要合併該 ExecutionContext。因此,僅僅在狀態機上建立一個指向 MoveNext 的委託是不夠的。這也是我們不想要的開銷。如果當我們掛起時,我們在狀態機上建立了一個指向 MoveNext 的委託,那麼每次這樣做我們都要對狀態機結構進行裝箱(即使它已經作為其他物件的一部分在堆上)並分配一個額外的委託(委託的這個物件引用將是該結構體的一個新裝箱的副本)。因此,我們需要做一個複雜的動作,即確保我們只在方法第一次暫停執行時將該結構從堆疊中提升到堆中,而在其他時候都使用相同的堆物件作為 MoveNext 的目標,並在這個過程中確保我們捕獲了正確的上下文,並在恢復時確保我們使用捕獲的上下文來呼叫該操作。

你可以在 C# 編譯器生成的程式碼中看到,當我們需要掛起時就會發生:

if (!awaiter.IsCompleted) // we need to suspend when IsCompleted is false
{
    <>1__state = 1;
    <>u__2 = awaiter;
    <>t__builder.AwaitUnsafeOnCompleted(ref awaiter, ref this);
    return;
}

我們將狀態 id 儲存到 state 欄位中,該 id 表示當方法恢復時應該跳轉到的位置。然後,我們將 awaiter 本身持久化到一個欄位中,以便在恢復後可以使用它來呼叫 GetResult。然後在返回 MoveNext 呼叫之前,我們要做的最後一件事是呼叫 <>t__builder.AwaitUnsafeOnCompleted(ref awaiter, ref this),要求構建器為這個狀態機連線一個 continuation 到 awaiter。(注意,它呼叫構建器的 AwaitUnsafeOnCompleted 而不是構建器的 AwaitOnCompleted,因為 awaiter 實現了 iccriticalnotifycompletion;狀態機處理流動的 ExecutionContext,所以我們不需要 awaiter,正如前面提到的,這樣做只會帶來重複和不必要的開銷。)

AwaitUnsafeOnCompleted 方法的實現太複雜了,不能在這裡詳述,所以我將總結它在 .NET Framework 上的作用:

1.它使用 ExecutionContext.Capture() 來獲取當前上下文。
2.然後它分配一個 MoveNextRunner 物件來包裝捕獲的上下文和裝箱的狀態機(如果這是該方法第一次掛起,我們還沒有狀態機,所以我們只使用 null 作為佔位符)。
3.然後,它建立一個操作委託給該 MoveNextRunner 上的 Run 方法;這就是它如何能夠獲得一個委託,該委託將在捕獲的 ExecutionContext 的上下文中呼叫狀態機的 MoveNext。
4.如果這是該方法第一次掛起,我們還沒有裝箱的狀態機,所以此時它會將其裝箱,透過將例項儲存到本地型別的 IAsyncStateMachine 介面中,在堆上建立一個副本。然後,這個盒子會被儲存到已分配的 MoveNextRunner 中。
5.現在到了一個有些令人費解的步驟。如果您檢視狀態機結構體的定義,它包含構建器,public AsyncTaskMethodBuilder <>t__builder;,如果你檢視構建器的定義,它包含內部的 IAsyncStateMachine m_stateMachine;。構建器需要引用裝箱的狀態機,以便在後續的掛起中它可以看到它已經裝箱了狀態機,並且不需要再次這樣做。但是我們只是裝箱了狀態機,並且該狀態機包含一個 m_stateMachine 欄位為 null 的構建器。我們需要改變裝箱狀態機的構建器的 m_stateMachine 指向它的父容器。為了實現這一點,編譯器生成的狀態機結構體實現了 IAsyncStateMachine 介面,其中包括一個 void SetStateMachine(IAsyncStateMachine stateMachine) ;方法,該狀態機結構體包含了該介面方法的實現:

private void SetStateMachine(IAsyncStateMachine stateMachine) =>
<>t__builder.SetStateMachine(stateMachine);

因此,構建器對狀態機進行裝箱,然後將裝箱傳遞給裝箱的 SetStateMachine 方法,該方法會呼叫構建器的 SetStateMachine 方法,將裝箱儲存到欄位中。
6.最後,我們有一個表示 continuation 的 Action,它被傳遞給 awaiter 的 UnsafeOnCompleted 方法。在 TaskAwaiter 的情況下,任務將將該操作儲存到任務的 continuation 列表中,這樣當任務完成時,它將呼叫該操作,透過 MoveNextRunner.Run 回撥,透過 ExecutionContext.Run 回撥,最後呼叫狀態機的 MoveNext 方法重新進入狀態機,並從它停止的地方繼續執行。

這就是在 .NET Framework 中發生的事情,你可以在分析器中看到結果,例如透過執行分配分析器來檢視每個 await 上的分配情況。讓我們看看這個愚蠢的程式,我寫這個程式只是為了強調其中涉及的分配成本:

using System.Threading;
using System.Threading.Tasks;

class Program
{
    static async Task Main()
    {
        var al = new AsyncLocal<int>() { Value = 42 };
        for (int i = 0; i < 1000; i++)
        {
            await SomeMethodAsync();
        }
    }

    static async Task SomeMethodAsync()
    {
        for (int i = 0; i < 1000; i++)
        {
            await Task.Yield();
        }
    }
}

這個程式建立了一個 AsyncLocal<int>,讓值42透過所有後續的非同步操作。然後它呼叫 SomeMethodAsync 1000次,每次暫停/恢復1000次。在 Visual Studio 中,我使用  .NET Object Allocation Tracking profiler 執行它,結果如下:

圖片

那是很多的分配!讓我們來研究一下它們的來源。

ExecutionContext。有超過一百萬個這樣的內容被分配。為什麼?因為在 .NET Framework 中,ExecutionContext 是一個可變的資料結構。由於我們希望流轉一個非同步操作被 fork 時的資料,並且我們不希望它在 fork 之後看到執行的變更,我們需要複製 ExecutionContext。每個單獨的 fork 操作都需要這樣的副本,因此有1000次對 SomeMethodAsync 的呼叫,每個呼叫都會暫停/恢復1000次,我們有100萬個 ExecutionContext 例項。

Action。類似地,每次我們等待尚未完成的任務時(我們的百萬個 await Task.Yield()s就是這種情況),我們最終分配一個新的操作委託來傳遞給 awaiter 的 UnsafeOnCompleted 方法。

MoveNextRunner。同樣的,有一百萬個這樣的例子,因為在前面的步驟大綱中,每次我們暫停時,我們都要分配一個新的 MoveNextRunner 來儲存 Action和 ExecutionContext,以便使用後者來執行前者。

LogicalCallContext。這些是 .NET Framework 上 AsyncLocal<T> 的實現細節;AsyncLocal<T> 將其資料儲存到 ExecutionContext 的“邏輯呼叫上下文”中,這是表示與 ExecutionContext 一起流動的一般狀態的一種奇特方式。如果我們要複製一百萬個 ExecutionContext,我們也會複製一百萬個 LogicalCallContext。

QueueUserWorkItemCallback。每個 Task.Yield() 都將一個工作項排隊到執行緒池中,導致分配了100萬個工作項物件用於表示這100萬個操作。

Task< VoidResult >。這裡有一千個這樣的,所以至少我們脫離了"百萬"俱樂部。每個非同步完成的非同步任務呼叫都需要分配一個新的 Task 例項來表示該呼叫的最終完成。

< SomeMethodAsync > d__1。這是編譯器生成的狀態機結構的盒子。1000個方法掛起,1000個盒子出現。

QueueSegment / IThreadPoolWorkItem[]。有幾千個這樣的方法,從技術上講,它們與具體的非同步方法無關,而是與執行緒池中的佇列工作有關。在 .NET 框架中,執行緒池的佇列是一個非迴圈段的連結串列。這些段不會被重用;對於長度為 N 的段,一旦 N 個工作項被加入到該段的佇列中並從該段中退出,該段就會被丟棄並當作垃圾回收。

這就是 .NET Framework。這是 .NET Core:

image.png

對於 .NET Framework 上的這個示例,有超過500萬次分配,總共分配了大約145MB的記憶體。對於 .NET Core 上的相同示例,只有大約1000個記憶體分配,總共只有大約109KB。為什麼這麼少?

ExecutionContext。在 .NET Core 中,ExecutionContext 現在是不可變的。這樣做的缺點是,對上下文的每次更改,例如將值設定為 AsyncLocal<T>,都需要分配一個新的 ExecutionContext。然而,好處是,流動的上下文比改變它更常見,而且由於 ExecutionContext 現在是不可變的,我們不再需要作為流動的一部分進行克隆。“捕獲”上下文實際上就是從欄位中讀取它,而不是讀取它並複製其內容。因此,流動不僅比變化更常見,而且更便宜。

LogicalCallContext。這在 .NET Core 中已經不存在了。在 .NET Core 中,ExecutionContext 唯一存在的東西是 AsyncLocal<T> 的儲存。其他在 ExecutionContext 中有自己特殊位置的東西都是以 AsyncLocal<T> 為模型的。例如,在 .NET Framework 中,模擬將作為 SecurityContext 的一部分流動,而SecurityContext 是 ExecutionContext 的一部分;在 .NET Core 中,模擬透過 AsyncLocal<SafeAccessTokenHandle> 流動,它使用 valueChangedHandler 來對當前執行緒進行適當的更改。

QueueSegment / IThreadPoolWorkItem[]。在 .NET Core 中,ThreadPool 的全域性佇列現在被實現為 ConcurrentQueue<T>,而 ConcurrentQueue<T> 已經被重寫為一個由非固定大小的迴圈段組成的連結串列。一旦段的長度大到永遠不會被填滿因為穩態的出佇列能夠跟上穩態的入佇列,就不需要再分配額外的段,相同的足夠大的段就會被無休止地使用。

那麼其他的分配呢,比如 Action、MoveNextRunner 和 <SomeMethodAsync>d__1? 要理解剩餘的分配是如何被移除的,需要深入瞭解它在 .NET Core 上是如何工作的。

讓我們回到討論掛起時發生的事情:

if (!awaiter.IsCompleted) // we need to suspend when IsCompleted is false
{
    <>1__state = 1;
    <>u__2 = awaiter;
    <>t__builder.AwaitUnsafeOnCompleted(ref awaiter, ref this);
    return;
}

不管目標是哪個平臺,這裡發出的程式碼都是相同的,所以不管是 .NET Framework 還是,為這個掛起生成的 IL 都是相同的。但是,改變的是 AwaitUnsafeOnCompleted 方法的實現,在 .NET Core 中有很大的不同:

1.事情的開始是一樣的:該方法呼叫 ExecutionContext.Capture() 來獲取當前執行上下文。
2.然後,事情偏離了 .NET Framework。.NET Core 中的 builder 只有一個欄位:

public struct AsyncTaskMethodBuilder
{
    private Task<VoidTaskResult>? m_task;
    ...
}

在捕獲 ExecutionContext 之後,它檢查 m_task 欄位是否包含一個 AsyncStateMachineBox<TStateMachine> 的例項,其中 TStateMachine 是編譯器生成的狀態機結構體的型別。AsyncStateMachineBox<TStateMachine> 型別定義如下:

private class AsyncStateMachineBox<TStateMachine> :
    Task<TResult>, IAsyncStateMachineBox
    where TStateMachine : IAsyncStateMachine
{
    private Action? _moveNextAction;
    public TStateMachine? StateMachine;
    public ExecutionContext? Context;
    ...
}

與其說這是一個單獨的 Task,不如說這是一個任務(注意其基本型別)。該結構並沒有對狀態機進行裝箱,而是作為該任務的強型別欄位存在。我們不需要用單獨的 MoveNextRunner 來儲存 Action 和 ExecutionContext,它們只是這個型別的欄位,而且由於這是儲存在構建器的 m_task 欄位中的例項,我們可以直接訪問它,不需要在每次暫停時重新分配。如果 ExecutionContext 發生變化,我們可以用新的上下文覆蓋該欄位,而不需要分配其他東西;我們的任何 Action 仍然指向正確的地方。所以,在捕獲了 ExecutionContext 之後,如果我們已經有了這個 AsyncStateMachineBox<TStateMachine> 的例項,這就不是這個方法第一次掛起了,我們可以直接把新捕獲的 ExecutionContext 儲存到其中。如果我們還沒有一個AsyncStateMachineBox<TStateMachine> 的例項,那麼我們需要分配它:

var box = new AsyncStateMachineBox<TStateMachine>();
taskField = box; // important: this must be done before storing stateMachine into box.StateMachine!
box.StateMachine = stateMachine;
box.Context = currentContext;

請注意源註釋為“重要”的那一行。這取代了 .NET Framework 中複雜的 SetStateMachine,使得 SetStateMachine 在 .NET Core 中根本沒有使用。你看到的 taskField 有一個指向 AsyncTaskMethodBuilder 的 m_task 欄位的 ref。我們分配 AsyncStateMachineBox< tstatemachinebox >,然後透過 taskField 將物件儲存到構建器的 m_task 中(這是在棧上的狀態機結構中的構建器),然後將基於堆疊的狀態機(現在已經包含對盒子的引用)複製到基於堆的 AsyncStateMachineBox< tstatemachinebox > 中,這樣 AsyncStateMachineBox<TStateMachine> 適當地並遞迴地結束引用自己。這仍然是令人費解的,但卻是一種更有效的費解。
3.然後,我們可以對這個 Action 上的一個方法進行操作,該方法將呼叫其 MoveNext 方法,該方法將在呼叫 StateMachine 的 MoveNext 之前執行適當的 ExecutionContext 恢復。該 Action 可以快取到 _moveNextAction 欄位中,以便任何後續使用都可以重用相同的 Action。然後,該 Action 被傳遞給 awaiter 的 UnsafeOnCompleted 來連線 continuation。

它解釋了為什麼剩下的大部分分配都沒有了:<SomeMethodAsync>d__1 沒有被裝箱,而是作為任務本身的一個欄位存在,MoveNextRunner 不再需要,因為它的存在只是為了儲存 Action 和 ExecutionContext。但是,根據這個解釋,我們仍然應該看到1000個操作分配,每個方法呼叫一個,但我們沒有。為什麼?還有那些 QueueUserWorkItemCallback 物件呢?我們仍然在 Task.Yield() 中進行排隊,為什麼它們沒有出現呢?

正如我所提到的,將實現細節推入核心庫的好處之一是,它可以隨著時間的推移改進實現,我們已經看到了它是如何從 .NET Framework 發展到 .NET Core 的。它在最初為 .NET Core 重寫的基礎上進一步發展,增加了額外的最佳化,這得益於對系統關鍵元件的內部訪問。特別是,非同步基礎設施知道 Task 和 TaskAwaiter 等核心型別。而且因為它知道它們並具有內部訪問許可權,所以它不必遵循公開定義的規則。C# 語言遵循的 awaiter 模式要求 awaiter 具有 AwaitOnCompleted 或 AwaitUnsafeOnCompleted 方法,這兩個方法都將 continuation 作為一個操作,這意味著基礎結構需要能夠建立一個操作來表示 continuation,以便與基礎結構不知道的任意 awaiter 一起工作。但是,如果基礎設施遇到它知道的 awaiter,它沒有義務採取相同的程式碼路徑。對於 System.Private 中定義的所有核心 awaiter。因此,CoreLib 的基礎設施可以遵循更簡潔的路徑,完全不需要操作。這些 awaiter 都知道 IAsyncStateMachineBoxes,並且能夠將 box 物件本身作為 continuation。例如,Task 返回的 YieldAwaitable.Yield 能夠將 IAsyncStateMachineBox 本身作為工作項直接放入 ThreadPool 中,而等待任務時使用的 TaskAwaiter 能夠將 IAsyncStateMachineBox 本身直接儲存到任務的延續列表中。不需要操作,也不需要 QueueUserWorkItemCallback。

因此,在非常常見的情況下,async 方法只等待 System.Private.CoreLib (Task, Task<TResult>, ValueTask, ValueTask<TResult>,YieldAwaitable,以及它們的ConfigureAwait 變體),最壞的情況下,只有一次開銷分配與 async 方法的整個生命週期相關:如果這個方法掛起了,它會分配一個單一的 Task-derived 型別來儲存所有其他需要的狀態,如果這個方法從來沒有掛起,就不會產生額外的分配。

如果願意,我們也可以去掉最後一個分配,至少以平攤的方式。如所示,有一個預設構建器與 Task(AsyncTaskMethodBuilder) 相關聯,類似地,有一個預設構建器與任務 <TResult> (AsyncTaskMethodBuilder<TResult>) 和 ValueTask 和ValueTask<TResult> (AsyncValueTaskMethodBuilder 和 AsyncValueTaskMethodBuilder<TResult>,分別)相關聯。對於 ValueTask/ValueTask<TResult>,構造器實際上相當簡單,因為它們本身只處理同步且成功完成的情況,在這種情況下,非同步方法完成而不掛起,構建器可以只返回一個 ValueTask.Completed 或者一個包含結果值的 ValueTask<TResult>。對於其他所有事情,它們只是委託給 AsyncTaskMethodBuilder/AsyncTaskMethodBuilder<TResult>,因為 ValueTask/ValueTask<TResult> 會被返回包裝一個 Task,它可以共享所有相同的邏輯。但是 .NET 6 and C# 10 引入了一個方法可以覆蓋逐個方法使用的構建器的能力,併為 ValueTask/ValueTask<TResult> 引入了幾個專門的構建器,它們能夠池化 IValueTaskSource/IValueTaskSource<TResult> 物件來表示最終的完成,而不是使用 Tasks。

我們可以在我們的樣本中看到這一點的影響。稍微調整一下之前分析的 SomeMethodAsync 函式,讓它返回 ValueTask 而不是 Task:

static async ValueTask SomeMethodAsync()
{
    for (int i = 0; i < 1000; i++)
    {
        await Task.Yield();
    }
}

這將生成以下入口點:

[AsyncStateMachine(typeof(<SomeMethodAsync>d__1))]
private static ValueTask SomeMethodAsync()
{
    <SomeMethodAsync>d__1 stateMachine = default;
    stateMachine.<>t__builder = AsyncValueTaskMethodBuilder.Create();
    stateMachine.<>1__state = -1;
    stateMachine.<>t__builder.Start(ref stateMachine);
    return stateMachine.<>t__builder.Task;
}

現在,我們新增 [AsyncMethodBuilder(typeof(PoolingAsyncValueTaskMethodBuilder))] 到 SomeMethodAsync 的宣告中:

[AsyncMethodBuilder(typeof(PoolingAsyncValueTaskMethodBuilder))]
static async ValueTask SomeMethodAsync()
{
    for (int i = 0; i < 1000; i++)
    {
        await Task.Yield();
    }
}

編譯器輸出如下:

[AsyncStateMachine(typeof(<SomeMethodAsync>d__1))]
[AsyncMethodBuilder(typeof(PoolingAsyncValueTaskMethodBuilder))]
private static ValueTask SomeMethodAsync()
{
    <SomeMethodAsync>d__1 stateMachine = default;
    stateMachine.<>t__builder = PoolingAsyncValueTaskMethodBuilder.Create();
    stateMachine.<>1__state = -1;
    stateMachine.<>t__builder.Start(ref stateMachine);
    return stateMachine.<>t__builder.Task;
}

整個實現的實際 C# 程式碼生成,包括整個狀態機(沒有顯示),幾乎是相同的;唯一的區別是建立和儲存的構建器的型別,因此在我們之前看到的任何引用構建器的地方都可以使用。如果你看一下 PoolingAsyncValueTaskMethodBuilder 的程式碼,你會看到它的結構幾乎與 AsyncTaskMethodBuilder 相同,包括使用一些完全相同的共享例程來做一些事情,如特殊套管已知的 awaiter 型別。關鍵的區別是,當方法第一次掛起時,它不是執行新的 AsyncStateMachineBox<TStateMachine>(),而是執行 StateMachineBox<TStateMachine>. rentfromcache(),並且在 async 方法 (SomeMethodAsync) 完成並等待返回的 ValueTask 完成時,租用的盒子會被返回到快取中。這意味著(平攤)零分配:

圖片

這個快取本身有點意思。物件池可能是一個好主意,也可能是一個壞主意。建立一個物件的成本越高,共享它們的價值就越大;因此,例如,對非常大的陣列進行池化比對非常小的陣列進行池化更有價值,因為更大的陣列不僅需要更多的 CPU 週期和記憶體訪問為零,它們還會給垃圾收集器帶來更大的壓力,使其更頻繁地收集垃圾。然而,對於非常小的物件,將它們池化可能會帶來負面影響。池只是記憶體分配器,GC 也是,所以當您使用池時,您是在權衡與一個分配器相關的成本與另一個分配器相關的成本,並且 GC 在處理大量微小的、生存期短的物件方面非常高效。如果你在物件的建構函式中做了很多工作,避免這些工作可以使分配器本身的開銷相形見絀,從而使池變得有價值。但是,如果您在物件的建構函式中幾乎沒有做任何工作,並且將其進行池化,則您將打賭您的分配器(您的池)就所採用的訪問模式而言比 GC 更有效,而這通常是一個糟糕的賭注。還涉及其他成本,在某些情況下,您可能最終會有效地對抗 GC 的啟發式方法;例如,垃圾回收是基於一個前提進行最佳化的,即從較高代(如gen2)物件到較低代(如gen0)物件的引用相對較少,但池化物件可以使這些前提失效。

我們今天為大家介紹了 C# 迭代器和 async/await under the covers,下期文章,我們將繼續介紹 SynchronizationContext 和 ConfigureAwait,歡迎持續關注。

點我閱讀原部落格~

相關文章